390 lines
15 KiB
Python
390 lines
15 KiB
Python
"""Unit tests for the Ray Data usage-stats collector."""
|
|
|
|
import json
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data._internal.issue_detection.issue_detector import IssueType
|
|
from ray.data._internal.usage import collector, poller, util
|
|
from ray.data._internal.usage.execution_callback import UsageCallback
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_record(monkeypatch):
|
|
recorded = []
|
|
monkeypatch.setattr(
|
|
collector,
|
|
"record_extra_usage_tag",
|
|
lambda key, value: recorded.append((key, value)),
|
|
)
|
|
return recorded
|
|
|
|
|
|
@pytest.fixture
|
|
def executor():
|
|
# A fake executor with no issue detection registered, standing in for the
|
|
# StreamingExecutor the callback receives at runtime.
|
|
executor = MagicMock()
|
|
executor.issue_detector_manager = None
|
|
return executor
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_collector(monkeypatch):
|
|
collector.reset_for_testing()
|
|
monkeypatch.delenv("RAY_DATA_USAGE_DISABLED", raising=False)
|
|
# ``ray.init()`` force-sets RAY_USAGE_STATS_ENABLED=0 for driver-created
|
|
# clusters, so the env var can't keep the opt-out gate open. Patch the gate.
|
|
monkeypatch.setattr(collector, "usage_stats_enabled", lambda: True)
|
|
# Prometheus isn't running in tests, so stub the counter query fn;
|
|
# the readers degrade to None without any network I/O.
|
|
monkeypatch.setattr(collector, "query_prometheus_counter", lambda promql: None)
|
|
yield
|
|
collector.reset_for_testing()
|
|
|
|
|
|
def test_round_trip_payload_shape(reset_collector, mock_record, executor):
|
|
"""End-to-end: a full callback lifecycle yields a valid payload with
|
|
anonymized plan tree, plan_str, env, and performance filled in."""
|
|
ds = ray.data.range(1).map_batches(lambda b: b)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
callback.after_execution_succeeds(executor)
|
|
|
|
_, payload_json = mock_record[-1]
|
|
payload = json.loads(payload_json)
|
|
entry = payload["executions"][0]
|
|
assert entry["id"] == callback._execution_id
|
|
read_usage_id = collector.make_usage_op_id(0, "ReadRange")
|
|
map_batches_usage_id = collector.make_usage_op_id(1, "MapBatches")
|
|
assert entry["workload"]["plan"] == {
|
|
"usage_id": map_batches_usage_id,
|
|
"op": "MapBatches",
|
|
"inputs": [{"usage_id": read_usage_id, "op": "ReadRange", "inputs": []}],
|
|
}
|
|
assert [(op["usage_id"], op["name"]) for op in entry["workload"]["ops"]] == [
|
|
(read_usage_id, "ReadRange"),
|
|
(map_batches_usage_id, "MapBatches"),
|
|
]
|
|
assert entry["workload"]["plan_str"] == "MapBatches\n+- ReadRange\n"
|
|
assert "pyarrow" in entry["env"]
|
|
# Performance carries all four metric fields. Values are None in this
|
|
# hermetic run (no cluster / Prometheus); the delta math is covered by
|
|
# test_compute_delta and the query path by the query_prometheus_counter tests.
|
|
assert set(entry["performance"]) == {
|
|
"bytes_spilled",
|
|
"node_deaths",
|
|
"oom_kills",
|
|
"unexpected_worker_kills",
|
|
}
|
|
# No issues detected in this run; the key is present and empty.
|
|
assert entry["detected_issues"] == []
|
|
|
|
|
|
def test_detected_issues_in_payload(reset_collector, mock_record, monkeypatch):
|
|
"""Detected issues are recorded as (issue_type, operator) pairs, serialized
|
|
as a list of ``{"issue_type", "operator"}`` objects in the payload."""
|
|
monkeypatch.setattr(
|
|
collector,
|
|
"physical_op_name_with_id",
|
|
lambda operator, usage_id_map=None, op_name_fn=None: operator,
|
|
)
|
|
executor = MagicMock()
|
|
executor.issue_detector_manager.get_detected_issues.return_value = [
|
|
(IssueType.HANGING, "MapBatches"),
|
|
(IssueType.HIGH_MEMORY, "ReadRange"),
|
|
]
|
|
ds = ray.data.range(1).map_batches(lambda b: b)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
callback.after_execution_succeeds(executor)
|
|
|
|
_, payload_json = mock_record[-1]
|
|
entry = json.loads(payload_json)["executions"][0]
|
|
assert entry["detected_issues"] == [
|
|
{"issue_type": "hanging", "operator": "MapBatches"},
|
|
{"issue_type": "high memory", "operator": "ReadRange"},
|
|
]
|
|
|
|
|
|
def test_build_usage_id_map(reset_collector, mock_record):
|
|
ds = ray.data.range(1).map_batches(lambda b: b)
|
|
usage_id_map = collector.build_usage_id_map(ds._logical_plan)
|
|
|
|
map_batches_op = ds._logical_plan.dag
|
|
read_op = map_batches_op.input_dependencies[0]
|
|
assert usage_id_map[id(read_op)] == collector.make_usage_op_id(0, "ReadRange")
|
|
assert usage_id_map[id(map_batches_op)] == collector.make_usage_op_id(
|
|
1, "MapBatches"
|
|
)
|
|
|
|
|
|
def test_self_zip_one_usage_id_per_operator(reset_collector, mock_record, executor):
|
|
"""``ds.zip(ds)`` reuses the same logical operator instances across both zip
|
|
branches (a shared-node DAG). Each discrete operator must be assigned
|
|
exactly one usage_id."""
|
|
ds = ray.data.range(1).map_batches(lambda b: b)
|
|
zipped = ds.zip(ds)
|
|
|
|
callback = UsageCallback(zipped._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
usage_id_map = collector.build_usage_id_map(zipped._logical_plan)
|
|
|
|
_, payload_json = mock_record[-1]
|
|
entry = json.loads(payload_json)["executions"][0]
|
|
|
|
recorded_ids = [op["usage_id"] for op in entry["workload"]["ops"]]
|
|
# build_usage_id_map is keyed by operator id, so its length is the number
|
|
# of discrete operators. Each should map to exactly one recorded ID.
|
|
num_discrete_ops = len(usage_id_map)
|
|
assert len(recorded_ids) == len(set(recorded_ids)) == num_discrete_ops
|
|
|
|
|
|
def test_detected_issues_absent_defaults_empty(reset_collector, mock_record, executor):
|
|
"""A run with no detected issues leaves detected_issues empty."""
|
|
ds = ray.data.range(1)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
callback.after_execution_succeeds(executor)
|
|
|
|
_, payload_json = mock_record[-1]
|
|
entry = json.loads(payload_json)["executions"][0]
|
|
assert entry["detected_issues"] == []
|
|
|
|
|
|
def test_unknown_operators_anonymized(reset_collector):
|
|
"""Custom, user-defined operators / datasources / datasinks defined outside
|
|
``ray.data.*`` must collapse to sentinel names (``Unknown`` /
|
|
``ReadCustom`` / ``WriteCustom``) so user-defined class names never
|
|
leak into the payload."""
|
|
from ray.data._internal.logical.operators import Read, Write
|
|
from ray.data.datasource.datasink import Datasink
|
|
from ray.data.datasource.datasource import Datasource
|
|
|
|
# Arbitrary LogicalOperator subclass defined in user code.
|
|
class FakeOp:
|
|
name = "FakeOp"
|
|
input_dependencies = []
|
|
|
|
assert (
|
|
collector.anonymize_op_name(FakeOp()) # pyrefly: ignore[bad-argument-type]
|
|
== "Unknown"
|
|
)
|
|
|
|
# User-defined Datasource: real class living outside ray.data.* should
|
|
# appear as "ReadCustom", not "FakeDatasource".
|
|
class FakeDatasource(Datasource):
|
|
pass
|
|
|
|
read_op = Read.__new__(Read)
|
|
object.__setattr__(read_op, "datasource", FakeDatasource())
|
|
assert collector.anonymize_op_name(read_op) == "ReadCustom"
|
|
|
|
# User-defined Datasink: same guarantee on the write side.
|
|
class FakeDatasink(Datasink):
|
|
def write(self, blocks, ctx):
|
|
pass
|
|
|
|
write_op = Write.__new__(Write)
|
|
object.__setattr__(write_op, "datasink_or_legacy_datasource", FakeDatasink())
|
|
assert collector.anonymize_op_name(write_op) == "WriteCustom"
|
|
|
|
|
|
def test_limit_anonymized_to_class_name(reset_collector, executor):
|
|
"""Limit's runtime name embeds the row count (e.g. ``limit=10``); telemetry
|
|
must collapse it back to ``Limit`` so the value isn't recorded."""
|
|
ds = ray.data.range(100).limit(10)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
entry = collector.get_executions()[callback._execution_id]
|
|
plan_ops = [op.name for op in entry.workload.ops]
|
|
assert "Limit" in plan_ops
|
|
assert not any(op.startswith("limit=") for op in plan_ops)
|
|
|
|
|
|
def test_does_not_record_when_disabled_via_env_var(
|
|
reset_collector, mock_record, monkeypatch, executor
|
|
):
|
|
"""Privacy gate: RAY_DATA_USAGE_DISABLED=1 must produce zero side effects."""
|
|
monkeypatch.setenv("RAY_DATA_USAGE_DISABLED", "1")
|
|
ds = ray.data.range(10)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
callback.after_execution_succeeds(executor)
|
|
|
|
assert mock_record == []
|
|
assert collector.get_executions() == {}
|
|
|
|
|
|
def test_does_not_record_when_usage_stats_opted_out(
|
|
reset_collector, mock_record, monkeypatch, executor
|
|
):
|
|
"""Privacy gate: opting out of Ray usage stats (RAY_USAGE_STATS_ENABLED=0,
|
|
``ray disable-usage-stats``, etc.) must also disable Ray Data collection."""
|
|
monkeypatch.setattr(collector, "usage_stats_enabled", lambda: False)
|
|
ds = ray.data.range(10)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor)
|
|
callback.after_execution_succeeds(executor)
|
|
|
|
assert mock_record == []
|
|
assert collector.get_executions() == {}
|
|
|
|
|
|
def test_does_not_raise_on_internal_errors(
|
|
reset_collector, mock_record, monkeypatch, executor
|
|
):
|
|
"""Safety: a bug in collection must never break user execution."""
|
|
monkeypatch.setattr(
|
|
collector,
|
|
"collect_workload",
|
|
lambda *_: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
ds = ray.data.range(10)
|
|
callback = UsageCallback(ds._logical_plan)
|
|
callback.before_execution_starts(executor) # must not raise
|
|
assert mock_record == []
|
|
|
|
|
|
def test_physical_op_name_joins_fused_logical_ops(monkeypatch):
|
|
"""A fused physical op maps to multiple logical ops; their anonymized names
|
|
are joined with "->", matching operator fusion's naming."""
|
|
monkeypatch.setattr(collector, "anonymize_op_name", lambda op: op)
|
|
operator = MagicMock()
|
|
operator._logical_operators = ["ReadParquet", "MapBatches", "Filter"]
|
|
assert (
|
|
collector.physical_op_name_with_id(operator)
|
|
== "ReadParquet->MapBatches->Filter"
|
|
)
|
|
|
|
|
|
def test_physical_op_name_includes_usage_ids(monkeypatch):
|
|
monkeypatch.setattr(collector, "anonymize_op_name", lambda op: op)
|
|
operator = MagicMock()
|
|
operator._logical_operators = ["ReadParquet", "MapBatches", "Filter"]
|
|
usage_id_map = {
|
|
id(operator._logical_operators[0]): "aaaaaaaa",
|
|
id(operator._logical_operators[1]): "bbbbbbbb",
|
|
id(operator._logical_operators[2]): "cccccccc",
|
|
}
|
|
assert (
|
|
collector.physical_op_name_with_id(operator, usage_id_map)
|
|
== "ReadParquet-aaaaaaaa->MapBatches-bbbbbbbb->Filter-cccccccc"
|
|
)
|
|
|
|
|
|
def test_physical_op_name_without_logical_ops():
|
|
"""An operator with no logical source collapses to "Unknown"."""
|
|
operator = MagicMock()
|
|
operator._logical_operators = []
|
|
assert collector.physical_op_name_with_id(operator) == "Unknown"
|
|
|
|
|
|
def test_compute_delta():
|
|
"""Cluster metric deltas (bytes_spilled, node_deaths) are the non-negative
|
|
increase between start and end samples, or None if either sample is missing."""
|
|
assert util.compute_delta(100, 250) == 150
|
|
# Cumulative counters shouldn't go backwards, but clamp to 0 if they do.
|
|
assert util.compute_delta(250, 100) == 0
|
|
assert util.compute_delta(None, 100) is None
|
|
assert util.compute_delta(100, None) is None
|
|
assert util.compute_delta(None, None) is None
|
|
|
|
|
|
def test_query_prometheus_counter_sums_results(monkeypatch):
|
|
"""A successful instant query sums the value of every returned series."""
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {
|
|
"data": {"result": [{"value": [0, "3"]}, {"value": [0, "4"]}]}
|
|
}
|
|
monkeypatch.setattr(util.requests, "get", lambda *a, **k: resp)
|
|
assert util.query_prometheus_counter("q") == 7
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"get_fn",
|
|
[
|
|
# Non-200 response.
|
|
lambda *a, **k: MagicMock(status_code=500),
|
|
# 200 but no series matched.
|
|
lambda *a, **k: MagicMock(
|
|
status_code=200, json=lambda: {"data": {"result": []}}
|
|
),
|
|
# Prometheus unreachable
|
|
MagicMock(side_effect=util.requests.ConnectionError("no prometheus")),
|
|
# 200 but malformed response body
|
|
lambda *a, **k: MagicMock(status_code=200, json=lambda: {"unexpected": 1}),
|
|
],
|
|
)
|
|
def test_query_prometheus_counter_returns_none_on_failure(monkeypatch, get_fn):
|
|
"""Each realistic query failure yields None so usage collection never breaks."""
|
|
monkeypatch.setattr(util.requests, "get", get_fn)
|
|
assert util.query_prometheus_counter("q") is None
|
|
|
|
|
|
def test_session_scoped_metric_query():
|
|
"""The query sums the metric, scoped by SessionName plus any extra labels."""
|
|
assert (
|
|
collector._session_scoped_metric_query("m", "sess")
|
|
== "sum(m{SessionName='sess'})"
|
|
)
|
|
assert (
|
|
collector._session_scoped_metric_query("m", "sess", {"State": "Spilled"})
|
|
== "sum(m{State='Spilled',SessionName='sess'})"
|
|
)
|
|
# No session -> unscoped, but extra labels still apply.
|
|
assert collector._session_scoped_metric_query("m", None) == "sum(m)"
|
|
assert (
|
|
collector._session_scoped_metric_query("m", None, {"State": "Spilled"})
|
|
== "sum(m{State='Spilled'})"
|
|
)
|
|
|
|
|
|
def test_poller_snapshots():
|
|
"""The poller publishes the latest snapshot every poll and pins the first."""
|
|
counter = {"v": 100}
|
|
p = poller.ClusterMetricsPoller({"m": lambda: counter["v"]})
|
|
assert p.latest_snapshot() is None and p.first_snapshot() is None
|
|
p.poll_once() # first = latest = 100
|
|
counter["v"] = 500
|
|
p.poll_once() # latest = 500, first pinned at 100
|
|
assert p.first_snapshot() == {"m": 100}
|
|
assert p.latest_snapshot() == {"m": 500}
|
|
|
|
|
|
def test_callback_deltas_from_captured_baseline(monkeypatch):
|
|
"""The callback measures its delta from the baseline it captured at start
|
|
(latest snapshot minus that baseline)."""
|
|
counter = {"v": 100}
|
|
p = poller.ClusterMetricsPoller({"m": lambda: counter["v"]})
|
|
monkeypatch.setattr(poller, "_poller", p)
|
|
p.poll_once() # latest = 100
|
|
cb = UsageCallback(MagicMock())
|
|
cb._baseline_snapshot = p.latest_snapshot() # baseline = 100
|
|
counter["v"] = 500
|
|
p.poll_once() # latest = 500
|
|
assert cb._compute_cluster_deltas() == {"m": 400}
|
|
|
|
|
|
def test_callback_none_baseline_falls_back_to_first_snapshot(monkeypatch):
|
|
"""A callback whose execution started before any poll completed (None
|
|
baseline) measures its delta from the poller's first snapshot."""
|
|
counter = {"v": 100}
|
|
p = poller.ClusterMetricsPoller({"m": lambda: counter["v"]})
|
|
monkeypatch.setattr(poller, "_poller", p)
|
|
cb = UsageCallback(MagicMock())
|
|
cb._baseline_snapshot = p.latest_snapshot() # None: no poll completed yet
|
|
assert cb._baseline_snapshot is None
|
|
p.poll_once() # first = latest = 100
|
|
counter["v"] = 400
|
|
p.poll_once() # latest = 400
|
|
assert cb._compute_cluster_deltas() == {"m": 300}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|