Files
ray-project--ray/python/ray/tests/test_task_metrics.py
T
2026-07-13 13:17:40 +08:00

869 lines
21 KiB
Python

import copy
import multiprocessing
import sys
from collections import defaultdict
import pytest
import ray
from ray._common.test_utils import (
PrometheusTimeseries,
run_string_as_driver,
wait_for_condition,
)
from ray._private.metrics_agent import RAY_WORKER_TIMEOUT_S
from ray._private.test_utils import (
raw_metric_timeseries,
run_string_as_driver_nonblocking,
wait_for_assertion,
wait_for_dashboard_agent_available,
)
METRIC_CONFIG = {
"_system_config": {
"metrics_report_interval_ms": 100,
}
}
SLOW_METRIC_CONFIG = {
"_system_config": {
"metrics_report_interval_ms": 3000,
}
}
def tasks_by_state(info, timeseries: PrometheusTimeseries, flush: bool = False) -> dict:
if flush:
timeseries.flush()
return tasks_breakdown(info, lambda s: s.labels["State"], timeseries)
def tasks_by_name_and_state(info, timeseries: PrometheusTimeseries) -> dict:
return tasks_breakdown(
info, lambda s: (s.labels["Name"], s.labels["State"]), timeseries
)
def tasks_by_all(info, timeseries: PrometheusTimeseries) -> dict:
return tasks_breakdown(
info,
lambda s: (s.labels["Name"], s.labels["State"], s.labels["IsRetry"]),
timeseries,
)
def tasks_breakdown(info, key_fn, timeseries: PrometheusTimeseries) -> dict:
res = raw_metric_timeseries(info, timeseries)
if "ray_tasks" in res:
breakdown = defaultdict(int)
for sample in res["ray_tasks"]:
key = key_fn(sample)
breakdown[key] += sample.value
if breakdown[key] == 0:
del breakdown[key]
print("Task label breakdown: {}".format(breakdown))
return breakdown
else:
return {}
# TODO(ekl) in all these tests, we use run_string_as_driver_nonblocking to work around
# stats reporting issues if Ray is repeatedly restarted in unit tests.
def test_task_basic(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
driver = """
import time
import ray
from ray._common.test_utils import wait_for_condition
ray.init("auto")
@ray.remote
def a():
time.sleep(999)
@ray.remote
def b():
time.sleep(999)
@ray.remote(num_cpus=3)
def c():
time.sleep(999)
refs = [a.remote(), b.remote()]
wait_for_condition(
lambda: ray.available_resources().get("CPU", 0) == 0,
)
ray.get(refs + [c.remote() for _ in range(8)])
"""
proc = run_string_as_driver_nonblocking(driver)
timeseries = PrometheusTimeseries()
expected = {
"RUNNING": 2.0,
"PENDING_NODE_ASSIGNMENT": 8.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
assert tasks_by_name_and_state(info, timeseries) == {
("a", "RUNNING"): 1.0,
("b", "RUNNING"): 1.0,
("c", "PENDING_NODE_ASSIGNMENT"): 8.0,
}
proc.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_task_custom_name_metrics(shutdown_only):
"""Verify that custom task names set via .options(name=...) are used in metrics.
This tests that RUNNING tasks use the custom name consistently with
FINISHED/FAILED tasks. Previously there was a bug where RUNNING metrics used
the function name (FunctionDescriptor->CallString()) but FINISHED/FAILED used
the custom name (TaskSpec::GetName()).
"""
info = ray.init(num_cpus=2, **METRIC_CONFIG)
driver = """
import ray
import time
ray.init("auto")
@ray.remote
def my_function():
time.sleep(999)
# Submit tasks with custom names
a = [my_function.options(name="custom_task_name").remote() for _ in range(4)]
ray.get(a)
"""
proc = run_string_as_driver_nonblocking(driver)
timeseries = PrometheusTimeseries()
# Verify that RUNNING tasks use the custom name, not the function name.
# With 2 CPUs, 2 tasks should be running and 2 should be pending.
expected = {
("custom_task_name", "RUNNING"): 2.0,
("custom_task_name", "PENDING_NODE_ASSIGNMENT"): 2.0,
}
wait_for_condition(
lambda: tasks_by_name_and_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
# Verify the original function name is NOT used in metrics
breakdown = tasks_by_name_and_state(info, timeseries)
assert (
"my_function",
"RUNNING",
) not in breakdown, "RUNNING tasks should use custom name, not function name"
assert (
"my_function",
"PENDING_NODE_ASSIGNMENT",
) not in breakdown, "PENDING tasks should use custom name, not function name"
proc.kill()
def test_task_job_ids(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote(num_cpus=0)
def {func_name}():
time.sleep(999)
a = [{func_name}.remote() for _ in range(1)]
ray.get(a)
"""
# We make sure the task name is unique for each job so that we can distinguish the metric sample by task name across jobs.
procs = [
run_string_as_driver_nonblocking(driver.format(func_name=f"f_{i}"))
for i in range(3)
]
expected = {
"RUNNING": 3.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
# Check we have three jobs reporting "RUNNING".
metrics = raw_metric_timeseries(info, timeseries)
jobs_at_state = defaultdict(set)
for sample in metrics["ray_tasks"]:
jobs_at_state[sample.labels["State"]].add(sample.labels["JobId"])
print("Jobs at state: {}".format(jobs_at_state))
assert len(jobs_at_state["RUNNING"]) == 3, jobs_at_state
for proc in procs:
proc.kill()
def test_task_nested(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote(num_cpus=0)
def wrapper():
@ray.remote
def a():
time.sleep(999)
@ray.remote
def b():
time.sleep(999)
@ray.remote
def c():
time.sleep(999)
ray.get([a.remote(), b.remote()] + [c.remote() for _ in range(8)])
w = wrapper.remote()
ray.get(w)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"RUNNING": 2.0,
"RUNNING_IN_RAY_GET": 1.0,
"PENDING_NODE_ASSIGNMENT": 8.0,
}
def check_task_state():
assert tasks_by_state(info, timeseries) == expected
wait_for_assertion(
check_task_state,
timeout=30,
retry_interval_ms=2000,
)
assert tasks_by_name_and_state(info, timeseries) == {
("wrapper", "RUNNING_IN_RAY_GET"): 1.0,
("a", "RUNNING"): 1.0,
("b", "RUNNING"): 1.0,
("c", "PENDING_NODE_ASSIGNMENT"): 8.0,
}
proc.kill()
def test_task_nested_wait(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote(num_cpus=0)
def wrapper():
@ray.remote
def a():
time.sleep(999)
@ray.remote
def b():
time.sleep(999)
@ray.remote
def c():
time.sleep(999)
ray.wait([a.remote(), b.remote()] + [c.remote() for _ in range(8)])
w = wrapper.remote()
ray.get(w)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"RUNNING": 2.0,
"RUNNING_IN_RAY_WAIT": 1.0,
"PENDING_NODE_ASSIGNMENT": 8.0,
}
def check_task_state():
assert tasks_by_state(info, timeseries) == expected
wait_for_assertion(
check_task_state,
timeout=30,
retry_interval_ms=2000,
)
assert tasks_by_name_and_state(info, timeseries) == {
("wrapper", "RUNNING_IN_RAY_WAIT"): 1.0,
("a", "RUNNING"): 1.0,
("b", "RUNNING"): 1.0,
("c", "PENDING_NODE_ASSIGNMENT"): 8.0,
}
proc.kill()
def driver_for_test_task_fetch_args(head_info):
ray.init("auto")
timeseries = PrometheusTimeseries()
@ray.remote(resources={"worker": 1})
def task1():
return [1] * 1024 * 1024
@ray.remote(resources={"head": 1})
def task2(obj):
pass
o1 = task1.remote()
o2 = task2.remote(o1)
wait_for_condition(
lambda: tasks_by_state(head_info, timeseries).get("PENDING_ARGS_FETCH", 0.0)
== 1.0
)
ray.cancel(o2)
wait_for_condition(
lambda: tasks_by_state(head_info, timeseries).get("PENDING_ARGS_FETCH", 0.0)
== 0.0
)
def test_task_fetch_args(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(
resources={"head": 1},
_system_config={
"metrics_report_interval_ms": 100,
"testing_asio_delay_us": "ObjectManagerService.grpc_server.Pull=5000000000:5000000000", # noqa: E501
},
)
head_info = ray.init(address=cluster.address)
cluster.add_node(resources={"worker": 1})
cluster.wait_for_nodes()
multiprocessing.set_start_method("spawn")
p = multiprocessing.Process(
target=driver_for_test_task_fetch_args, args=(head_info,)
)
p.start()
p.join()
assert p.exitcode == 0
def test_task_wait_on_deps(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote
def f():
time.sleep(999)
@ray.remote
def g(x):
time.sleep(999)
x = f.remote()
a = [g.remote(x) for _ in range(5)]
ray.get(a)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"RUNNING": 1.0,
"PENDING_ARGS_AVAIL": 5.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
assert tasks_by_name_and_state(info, timeseries) == {
("f", "RUNNING"): 1.0,
("g", "PENDING_ARGS_AVAIL"): 5.0,
}
proc.kill()
def test_actor_tasks_queued(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote
class F:
def f(self):
time.sleep(999)
def g(self):
pass
a = F.remote()
[a.g.remote() for _ in range(10)]
[a.f.remote() for _ in range(1)] # Further tasks should be blocked on this one.
z = [a.g.remote() for _ in range(9)]
ray.get(z)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
("F.__init__", "FINISHED"): 1.0,
("F.g", "FINISHED"): 10.0,
("F.f", "RUNNING"): 1.0,
("F.g", "SUBMITTED_TO_WORKER"): 9.0,
}
wait_for_condition(
lambda: tasks_by_name_and_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
proc.kill()
def test_task_finish(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote
def f():
return "ok"
@ray.remote
def g():
assert False
f.remote()
g.remote()
time.sleep(999)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"FAILED": 1.0,
"FINISHED": 1.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
assert tasks_by_name_and_state(info, timeseries) == {
("g", "FAILED"): 1.0,
("f", "FINISHED"): 1.0,
}
proc.kill()
def test_task_retry(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote
def sleep():
time.sleep(999)
@ray.remote
class Phaser:
def __init__(self):
self.i = 0
def inc(self):
self.i += 1
if self.i < 3:
raise ValueError("First two tries will fail")
phaser = Phaser.remote()
@ray.remote(retry_exceptions=True, max_retries=3)
def f():
ray.get(phaser.inc.remote())
ray.get(sleep.remote())
f.remote()
time.sleep(999)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
("sleep", "RUNNING", "0"): 1.0,
("f", "FAILED", "0"): 1.0,
("f", "FAILED", "1"): 1.0,
("f", "RUNNING_IN_RAY_GET", "1"): 1.0,
("Phaser.__init__", "FINISHED", "0"): 1.0,
("Phaser.inc", "FINISHED", "0"): 1.0,
("Phaser.inc", "FAILED", "0"): 2.0,
}
wait_for_condition(
lambda: expected.items() <= tasks_by_all(info, timeseries).items(),
timeout=20,
retry_interval_ms=500,
)
proc.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows. Timing out.")
def test_actor_task_retry(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import os
import time
ray.init("auto")
@ray.remote
class Phaser:
def __init__(self):
self.i = 0
def inc(self):
self.i += 1
if self.i < 3:
raise ValueError("First two tries will fail")
phaser = Phaser.remote()
@ray.remote(max_restarts=10, max_task_retries=10)
class F:
def f(self):
try:
ray.get(phaser.inc.remote())
except Exception:
print("RESTART")
os._exit(1)
f = F.remote()
ray.get(f.f.remote())
time.sleep(999)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
("F.__init__", "FINISHED", "0"): 1.0,
("F.f", "FAILED", "0"): 1.0,
("F.f", "FAILED", "1"): 1.0,
("F.f", "FINISHED", "1"): 1.0,
("Phaser.__init__", "FINISHED", "0"): 1.0,
("Phaser.inc", "FINISHED", "0"): 1.0,
}
wait_for_condition(
lambda: expected.items() <= tasks_by_all(info, timeseries).items(),
timeout=20,
retry_interval_ms=500,
)
proc.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_task_failure(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
import os
ray.init("auto")
@ray.remote(max_retries=0)
def f():
print("RUNNING FAILING TASK")
os._exit(1)
@ray.remote
def g():
assert False
f.remote()
g.remote()
time.sleep(999)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"FAILED": 2.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
proc.kill()
def test_concurrent_actor_tasks(shutdown_only):
info = ray.init(num_cpus=2, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import asyncio
ray.init("auto")
@ray.remote(max_concurrency=30)
class A:
async def f(self):
await asyncio.sleep(300)
a = A.remote()
ray.get([a.f.remote() for _ in range(40)])
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"RUNNING": 30.0,
"SUBMITTED_TO_WORKER": 10.0,
"FINISHED": 1.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
proc.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_metrics_export_now(shutdown_only, ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(
**SLOW_METRIC_CONFIG,
num_cpus=2,
)
wait_for_dashboard_agent_available(cluster)
info = ray.init(address=cluster.address)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
ray.init("auto")
@ray.remote
def {func_name}():
pass
a = [{func_name}.remote() for _ in range(10)]
ray.get(a)
"""
# If force export at process death is broken, we won't see the recently completed
# tasks from the drivers. We also make sure the task name is unique for each job so
# that we can distinguish the metric sample by task name across jobs.
for i in range(10):
print("Run job", i)
run_string_as_driver(driver.format(func_name=f"f_{i}"))
tasks_by_state(info, timeseries)
expected = {
"FINISHED": 100.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
@pytest.mark.skipif(sys.platform == "darwin", reason="Flaky on macos")
def test_pull_manager_stats(shutdown_only):
info = ray.init(num_cpus=2, object_store_memory=100_000_000, **METRIC_CONFIG)
timeseries = PrometheusTimeseries()
driver = """
import ray
import time
import numpy as np
ray.init("auto")
# Spill a lot of 10MiB objects. The object store is 100MiB, so pull manager will
# only be able to pull ~9 total into memory at once, including running tasks.
buf = []
for _ in range(100):
buf.append(ray.put(np.ones(10 * 1024 * 1024, dtype=np.uint8)))
@ray.remote
def a(x):
time.sleep(999)
@ray.remote
def b(x):
time.sleep(999)
@ray.remote
def c(x):
time.sleep(999)
ray.get([a.remote(buf[0]), b.remote(buf[1])] + [c.remote(x) for x in buf[2:]])
"""
proc = run_string_as_driver_nonblocking(driver)
# This test is non-deterministic since pull bundles can sometimes end up fallback
# allocated. This leads to slightly more objects pulled than you'd expect.
def close_to_expected(stats):
# A scheduled task can momentarily sit in SUBMITTED_TO_WORKER (lease granted,
# waiting on the worker to fetch its spilled arg) before it reports RUNNING.
# Under the object store pressure this test creates, that transition can be
# slow, so count SUBMITTED_TO_WORKER as running to avoid flakiness.
running = stats.get("RUNNING", 0) + stats.get("SUBMITTED_TO_WORKER", 0)
assert running == 2, stats
assert 7 <= stats["PENDING_NODE_ASSIGNMENT"] <= 17, stats
assert 81 <= stats["PENDING_OBJ_STORE_MEM_AVAIL"] <= 91, stats
assert set(stats.keys()).issubset(
{
"RUNNING",
"SUBMITTED_TO_WORKER",
"PENDING_NODE_ASSIGNMENT",
"PENDING_OBJ_STORE_MEM_AVAIL",
}
), stats
assert sum(stats.values()) == 100, stats
return True
wait_for_condition(
lambda: close_to_expected(tasks_by_state(info, timeseries)),
timeout=20,
retry_interval_ms=500,
)
proc.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_stale_view_cleanup_when_job_exits(monkeypatch, shutdown_only):
timeseries = PrometheusTimeseries()
with monkeypatch.context() as m:
m.setenv(RAY_WORKER_TIMEOUT_S, 5)
info = ray.init(num_cpus=2, **METRIC_CONFIG)
print(info)
driver = """
import ray
import time
import numpy as np
ray.init("auto")
@ray.remote
def g():
time.sleep(999)
ray.get(g.remote())
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
"RUNNING": 1.0,
}
wait_for_condition(
lambda: tasks_by_state(info, timeseries) == expected,
timeout=20,
retry_interval_ms=500,
)
proc.kill()
print("Killing a driver.")
expected = {}
wait_for_condition(
lambda: tasks_by_state(info, timeseries, flush=True) == expected,
timeout=20,
retry_interval_ms=500,
)
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows. Timing out.")
def test_metrics_batch(shutdown_only):
"""Verify metrics_report_batch_size works correctly without data loss."""
config_copy = copy.deepcopy(METRIC_CONFIG)
config_copy["_system_config"].update({"metrics_report_batch_size": 1})
info = ray.init(num_cpus=2, **config_copy)
timeseries = PrometheusTimeseries()
driver = """
import ray
import os
import time
ray.init("auto")
@ray.remote
class Phaser:
def __init__(self):
self.i = 0
def inc(self):
self.i += 1
if self.i < 3:
raise ValueError("First two tries will fail")
phaser = Phaser.remote()
@ray.remote(max_restarts=10, max_task_retries=10)
class F:
def f(self):
try:
ray.get(phaser.inc.remote())
except Exception:
print("RESTART")
os._exit(1)
f = F.remote()
ray.get(f.f.remote())
time.sleep(999)
"""
proc = run_string_as_driver_nonblocking(driver)
expected = {
("F.__init__", "FINISHED", "0"): 1.0,
("F.f", "FAILED", "0"): 1.0,
("F.f", "FAILED", "1"): 1.0,
("F.f", "FINISHED", "1"): 1.0,
("Phaser.__init__", "FINISHED", "0"): 1.0,
("Phaser.inc", "FINISHED", "0"): 1.0,
}
wait_for_condition(
lambda: expected.items() <= tasks_by_all(info, timeseries).items(),
timeout=20,
retry_interval_ms=500,
)
proc.kill()
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))