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

295 lines
11 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
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 collector's opt-out gate open. Patch the gate directly instead.
monkeypatch.setattr(collector, "usage_stats_enabled", lambda: True)
# The callback reads these module-level cluster metric readers; pin them to
# zero so tests never touch the real cluster and deltas are deterministic.
monkeypatch.setattr(collector, "cluster_spilled_bytes", lambda: 0)
monkeypatch.setattr(collector, "cluster_dead_node_count", lambda: 0)
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 deltas are populated from the injected readers; both readers
# return 0 at start and end, so the clamped deltas are 0.
assert entry["performance"]["bytes_spilled"] == 0
assert entry["performance"]["node_deaths"] == 0
# 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 collector.compute_delta(100, 250) == 150
# Cumulative counters shouldn't go backwards, but clamp to 0 if they do.
assert collector.compute_delta(250, 100) == 0
assert collector.compute_delta(None, 100) is None
assert collector.compute_delta(100, None) is None
assert collector.compute_delta(None, None) is None
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))