Files
2026-07-13 13:17:40 +08:00

762 lines
26 KiB
Python

import json
import os
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Tuple
import pytest
import ray
from ray.data._internal.execution.dataset_state import DatasetState
from ray.data._internal.logical.interfaces import LogicalOperator
from ray.data._internal.metadata_exporter import (
UNKNOWN,
DataContextMetadata,
Operator,
Topology,
sanitize_for_struct,
)
from ray.data._internal.stats import get_or_create_stats_actor
from ray.data.context import DataContext
from ray.tests.conftest import _ray_start
STUB_JOB_ID = "stub_job_id"
STUB_DATASET_ID = "stub_dataset_id"
def _get_export_file_path() -> str:
return os.path.join(
ray._private.worker._global_node.get_session_dir_path(),
"logs",
"export_events",
"event_EXPORT_DATASET_METADATA.log",
)
def _get_exported_data():
exported_file = _get_export_file_path()
assert os.path.isfile(exported_file)
with open(exported_file, "r") as f:
data = f.readlines()
return [json.loads(line) for line in data]
@pytest.fixture
def ray_start_cluster_with_export_api_config(shutdown_only):
"""Enable export API for the EXPORT_TRAIN_RUN resource type."""
with _ray_start(
num_cpus=4,
runtime_env={
"env_vars": {
"RAY_enable_export_api_write_config": "EXPORT_DATASET_METADATA"
}
},
) as res:
yield res
@pytest.fixture
def ray_start_cluster_with_export_api_write(shutdown_only):
"""Enable export API for all resource types."""
with _ray_start(
num_cpus=4,
runtime_env={"env_vars": {"RAY_enable_export_api_write": "1"}},
) as res:
yield res
@dataclass
class TestDataclass:
"""A test dataclass for testing dataclass serialization."""
list_field: list = None
dict_field: dict = None
string_field: str = "test"
int_field: int = 1
float_field: float = 1.0
set_field: set = None
tuple_field: Tuple[int] = None
bool_field: bool = True
none_field: None = None
def __post_init__(self):
self.list_field = [1, 2, 3]
self.dict_field = {1: 2, "3": "4"}
self.set_field = {1, 2, 3}
self.tuple_field = (1, 2, 3)
@dataclass(frozen=True, repr=False, eq=False)
class DummyLogicalOperator(LogicalOperator):
"""A dummy logical operator for testing _get_logical_args with various data types."""
_name: str = field(init=False, default="DummyOperator", repr=False)
_input_dependencies: List[LogicalOperator] = field(
init=False, default_factory=list, repr=False
)
_num_outputs: None = field(init=False, default=None, repr=False)
_string_value: str = "test_string"
_int_value: int = 42
_float_value: float = 3.14
_bool_value: bool = True
_none_value: None = None
_list_value: List[Any] = field(default_factory=lambda: [1, 2, 3, "string", None])
_dict_value: Dict[str, Any] = field(
default_factory=lambda: {"key1": "value1", "key2": 123, "key3": None}
)
_nested_dict: Dict[str, Any] = field(
default_factory=lambda: {
"level1": {
"level2": {
"level3": "deep_value",
"numbers": [1, 2, 3],
"mixed": {"a": 1, "b": "string", "c": None},
}
}
}
)
_tuple_value: Tuple[Any, ...] = (1, "string", None, 3.14)
_set_value: set = field(default_factory=lambda: {1})
_bytes_value: bytes = b"binary_data"
_complex_dict: Dict[str, Any] = field(
default_factory=lambda: {
"string_keys": {"a": 1, "b": 2},
"int_keys": {
1: "one",
2: "two",
}, # This should cause issues if not handled
"mixed_keys": {"str": "value", 1: "int_key", None: "none_key"},
}
)
_empty_containers: Dict[str, Any] = field(
default_factory=lambda: {
"empty_list": [],
"empty_dict": {},
"empty_tuple": (),
"empty_set": set(),
}
)
_special_values: Dict[str, Any] = field(
default_factory=lambda: {
"zero": 0,
"negative": -1,
"large_int": 999999999999999999,
"small_float": 0.0000001,
"inf": float("inf"),
"neg_inf": float("-inf"),
"nan": float("nan"),
}
)
_data_class: TestDataclass = field(default_factory=TestDataclass)
@property
def num_outputs(self):
return self._num_outputs
@pytest.fixture
def dummy_dataset_topology():
"""Create a dummy Topology."""
dummy_operator = DummyLogicalOperator()
dummy_topology = Topology(
operators=[
Operator(
name="Input",
id="Input_0",
uuid="uuid_0",
input_dependencies=[],
sub_stages=[],
execution_start_time=1.0,
execution_end_time=1.0,
state="FINISHED",
args=sanitize_for_struct(dummy_operator._get_args()),
),
Operator(
name="ReadRange->Map(<lambda>)->Filter(<lambda>)",
id="ReadRange->Map(<lambda>)->Filter(<lambda>)_1",
uuid="uuid_1",
input_dependencies=["Input_0"],
sub_stages=[],
execution_start_time=0.0,
execution_end_time=0.0,
state="RUNNING",
args=sanitize_for_struct(dummy_operator._get_args()),
),
],
)
return dummy_topology
@pytest.fixture
def dummy_dataset_topology_expected_output():
return {
"operators": [
{
"name": "Input",
"id": "Input_0",
"uuid": "uuid_0",
"args": {
"_num_outputs": "None",
"_int_value": "42",
"_special_values": {
"negative": "-1",
"inf": "inf",
"zero": "0",
"large_int": "999999999999999999",
"small_float": "1e-07",
"neg_inf": "-inf",
"nan": "nan",
},
"_none_value": "None",
"_name": "DummyOperator",
"_output_dependencies": [],
"_float_value": "3.14",
"_list_value": ["1", "2", "3", "string", "None"],
"_dict_value": {"key1": "value1", "key3": "None", "key2": "123"},
"_set_value": ["1"],
"_tuple_value": ["1", "string", "None", "3.14"],
"_bytes_value": [
"98",
"105",
"110",
"97",
"114",
"121",
"95",
"100",
"97",
"116",
"97",
],
"_input_dependencies": [],
"_empty_containers": {
"empty_set": [],
"empty_tuple": [],
"empty_dict": {},
"empty_list": [],
},
"_bool_value": "True",
"_nested_dict": {
"level1": {
"level2": {
"mixed": {"a": "1", "b": "string", "c": "None"},
"numbers": ["1", "2", "3"],
"level3": "deep_value",
}
}
},
"_string_value": "test_string",
"_complex_dict": {
"string_keys": {"a": "1", "b": "2"},
"mixed_keys": {
"None": "none_key",
"str": "value",
"1": "int_key",
},
"int_keys": {"1": "one", "2": "two"},
},
"_data_class": {
"list_field": ["1", "2", "3"],
"dict_field": {"3": "4", "1": "2"},
"tuple_field": ["1", "2", "3"],
"set_field": ["1", "2", "3"],
"int_field": "1",
"none_field": "None",
"bool_field": "True",
"string_field": "test",
"float_field": "1.0",
},
},
"input_dependencies": [],
"sub_stages": [],
"execution_start_time": 1.0,
"execution_end_time": 1.0,
"state": "FINISHED",
},
{
"name": "ReadRange->Map(<lambda>)->Filter(<lambda>)",
"id": "ReadRange->Map(<lambda>)->Filter(<lambda>)_1",
"uuid": "uuid_1",
"input_dependencies": ["Input_0"],
"args": {
"_num_outputs": "None",
"_int_value": "42",
"_special_values": {
"negative": "-1",
"inf": "inf",
"zero": "0",
"large_int": "999999999999999999",
"small_float": "1e-07",
"neg_inf": "-inf",
"nan": "nan",
},
"_none_value": "None",
"_name": "DummyOperator",
"_output_dependencies": [],
"_float_value": "3.14",
"_list_value": ["1", "2", "3", "string", "None"],
"_dict_value": {"key1": "value1", "key3": "None", "key2": "123"},
"_set_value": ["1"],
"_tuple_value": ["1", "string", "None", "3.14"],
"_bytes_value": [
"98",
"105",
"110",
"97",
"114",
"121",
"95",
"100",
"97",
"116",
"97",
],
"_input_dependencies": [],
"_empty_containers": {
"empty_set": [],
"empty_tuple": [],
"empty_dict": {},
"empty_list": [],
},
"_bool_value": "True",
"_nested_dict": {
"level1": {
"level2": {
"mixed": {"a": "1", "b": "string", "c": "None"},
"numbers": ["1", "2", "3"],
"level3": "deep_value",
}
}
},
"_string_value": "test_string",
"_complex_dict": {
"string_keys": {"a": "1", "b": "2"},
"mixed_keys": {
"None": "none_key",
"str": "value",
"1": "int_key",
},
"int_keys": {"1": "one", "2": "two"},
},
"_data_class": {
"list_field": ["1", "2", "3"],
"dict_field": {"3": "4", "1": "2"},
"tuple_field": ["1", "2", "3"],
"set_field": ["1", "2", "3"],
"int_field": "1",
"none_field": "None",
"bool_field": "True",
"string_field": "test",
"float_field": "1.0",
},
},
"sub_stages": [],
"execution_start_time": 0.0,
"execution_end_time": 0.0,
"state": "RUNNING",
},
]
}
def test_export_disabled(ray_start_regular, dummy_dataset_topology):
"""Test that no export files are created when export API is disabled."""
stats_actor = get_or_create_stats_actor()
# Create or update train run
ray.get(
stats_actor.register_dataset.remote(
dataset_tag="test_dataset",
operator_tags=["ReadRange->Map(<lambda>)->Filter(<lambda>)"],
topology=dummy_dataset_topology,
job_id=STUB_JOB_ID,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
# Check that no export files were created
assert not os.path.exists(_get_export_file_path())
def _test_dataset_metadata_export(topology, dummy_dataset_topology_expected_output):
"""Test that dataset metadata export events are written when export API is enabled."""
stats_actor = get_or_create_stats_actor()
# Simulate a dataset registration
ray.get(
stats_actor.register_dataset.remote(
dataset_tag=STUB_DATASET_ID,
operator_tags=["ReadRange->Map(<lambda>)->Filter(<lambda>)"],
topology=topology,
job_id=STUB_JOB_ID,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
# Check that export files were created
data = _get_exported_data()
assert len(data) == 1
assert data[0]["source_type"] == "EXPORT_DATASET_METADATA"
assert data[0]["event_data"]["topology"] == dummy_dataset_topology_expected_output
assert data[0]["event_data"]["dataset_id"] == STUB_DATASET_ID
assert data[0]["event_data"]["job_id"] == STUB_JOB_ID
assert data[0]["event_data"]["start_time"] is not None
def test_export_dataset_metadata_enabled_by_config(
ray_start_cluster_with_export_api_config,
dummy_dataset_topology,
dummy_dataset_topology_expected_output,
):
_test_dataset_metadata_export(
dummy_dataset_topology, dummy_dataset_topology_expected_output
)
def test_export_dataset_metadata(
ray_start_cluster_with_export_api_write,
dummy_dataset_topology,
dummy_dataset_topology_expected_output,
):
_test_dataset_metadata_export(
dummy_dataset_topology, dummy_dataset_topology_expected_output
)
@pytest.mark.parametrize(
"expected_logical_op_args",
[
{
"fn_args": [1],
"fn_constructor_kwargs": [2],
"fn_kwargs": {"a": 3},
"fn_constructor_args": {"b": 4},
"compute": ray.data.ActorPoolStrategy(max_tasks_in_flight_per_actor=2),
},
],
)
def test_logical_op_args(
ray_start_cluster_with_export_api_write, expected_logical_op_args
):
class Udf:
def __init__(self, a, b):
self.a = a
self.b = b
def __call__(self, x):
return x
ds = ray.data.range(1).map_batches(
Udf,
**expected_logical_op_args,
)
dag = ds._logical_plan.dag
args = dag._get_args()
assert len(args) > 0, "Export args should not be empty"
for k, v in expected_logical_op_args.items():
k = f"_{k}"
assert k in args, f"Export args should contain key '{k}'"
assert (
args[k] == v
), f"Export args for key '{k}' should match expected value {v}, found {args[k]}"
def test_export_multiple_datasets(
ray_start_cluster_with_export_api_write,
dummy_dataset_topology,
dummy_dataset_topology_expected_output,
):
"""Test that multiple datasets can be exported when export API is enabled."""
stats_actor = get_or_create_stats_actor()
# Create a second dataset structure that's different from the dummy one
second_topology = Topology(
operators=[
Operator(
name="Input",
id="Input_0",
uuid="second_uuid_0",
input_dependencies=[],
sub_stages=[],
execution_start_time=1.0,
execution_end_time=1.0,
state="FINISHED",
),
Operator(
name="ReadRange->Map(<lambda>)",
id="ReadRange->Map(<lambda>)_1",
uuid="second_uuid_1",
input_dependencies=["Input_0"],
sub_stages=[],
execution_start_time=2.0,
execution_end_time=0.0,
state="RUNNING",
),
],
)
# Dataset IDs for each dataset
first_dataset_id = "first_dataset"
second_dataset_id = "second_dataset"
# Register the first dataset
ray.get(
stats_actor.register_dataset.remote(
dataset_tag=first_dataset_id,
operator_tags=["ReadRange->Map(<lambda>)->Filter(<lambda>)"],
topology=dummy_dataset_topology,
job_id=STUB_JOB_ID,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
# Register the second dataset
ray.get(
stats_actor.register_dataset.remote(
dataset_tag=second_dataset_id,
operator_tags=["ReadRange->Map(<lambda>)"],
topology=second_topology,
job_id=STUB_JOB_ID,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
# Check that export files were created with both datasets
data = _get_exported_data()
assert len(data) == 2, f"Expected 2 exported datasets, got {len(data)}"
# Create a map of dataset IDs to their exported data for easier verification
datasets_by_id = {entry["event_data"]["dataset_id"]: entry for entry in data}
# Verify first dataset
assert (
first_dataset_id in datasets_by_id
), f"First dataset {first_dataset_id} not found in exported data"
first_entry = datasets_by_id[first_dataset_id]
assert first_entry["source_type"] == "EXPORT_DATASET_METADATA"
assert (
first_entry["event_data"]["topology"] == dummy_dataset_topology_expected_output
)
assert first_entry["event_data"]["job_id"] == STUB_JOB_ID
assert first_entry["event_data"]["start_time"] is not None
# Verify second dataset
assert (
second_dataset_id in datasets_by_id
), f"Second dataset {second_dataset_id} not found in exported data"
second_entry = datasets_by_id[second_dataset_id]
assert second_entry["source_type"] == "EXPORT_DATASET_METADATA"
assert second_entry["event_data"]["topology"] == asdict(second_topology)
assert second_entry["event_data"]["job_id"] == STUB_JOB_ID
assert second_entry["event_data"]["start_time"] is not None
class UnserializableObject:
"""A test class that can't be JSON serialized or converted to string easily."""
def __str__(self):
raise ValueError("Cannot convert to string")
def __repr__(self):
raise ValueError("Cannot convert to repr")
class BasicObject:
"""A test class that can be converted to string."""
def __init__(self, value):
self.value = value
def __str__(self):
return f"BasicObject({self.value})"
@pytest.mark.parametrize(
"input_obj,expected_output,truncate_length",
[
# Basic types - should return as strings
(42, "42", 100),
(3.14, "3.14", 100),
(True, "True", 100),
(False, "False", 100),
(None, "None", 100),
# Strings - short strings return as-is
("hello", "hello", 100),
# Strings - long strings get truncated
("a" * 150, "a" * 100 + "...", 100),
("hello world", "hello...", 5),
# Mappings - should recursively sanitize values
({"key": "value"}, {"key": "value"}, 100),
({"long_key": "a" * 150}, {"long_key": "a" * 100 + "..."}, 100),
({"nested": {"inner": "value"}}, {"nested": {"inner": "value"}}, 100),
# Sequences - should recursively sanitize elements (convert to strings)
([1, 2, 3], ["1", "2", "3"], 100),
(["short", "a" * 150], ["short", "a" * 100 + "..."], 100),
# Complex nested structures
(
{"list": [1, "a" * 150], "dict": {"key": "a" * 150}},
{"list": ["1", "a" * 100 + "..."], "dict": {"key": "a" * 100 + "..."}},
100,
),
# Objects that can be converted to string
(BasicObject("test"), "BasicObject(test)", 100), # Falls back to str()
# Sets can be converted to Lists of strings
({1, 2, 3}, ["1", "2", "3"], 100),
((1, 2, 3), ["1", "2", "3"], 100),
# Objects that can't be serialized or stringified
(UnserializableObject(), f"{UNKNOWN}: {UnserializableObject.__name__}", 100),
# Empty containers
({}, {}, 100),
([], [], 100),
# Mixed type sequences - all converted to strings
(
[1, "hello", {"key": "value"}, None],
["1", "hello", {"key": "value"}, "None"],
100,
),
# Bytearrays/bytes - should be converted to lists of string representations
(bytearray(b"hello"), ["104", "101", "108", "108", "111"], 100),
(bytearray([1, 2, 3, 4, 5]), ["1", "2", "3", "4", "5"], 100),
(bytes(b"test"), ["116", "101", "115", "116"], 100),
# Dataclass
(
TestDataclass(),
{
"list_field": ["1", "2", "3"],
"dict_field": {"1": "2", "3": "4"}, # key should be strings
"string_field": "test",
"int_field": "1",
"float_field": "1.0",
"set_field": [
"1",
"2",
"3",
], # sets will be converted to Lists of strings
"tuple_field": [
"1",
"2",
"3",
], # tuples will be converted to Lists of strings
"bool_field": "True",
"none_field": "None",
},
100,
),
# Test sequence truncation - list longer than truncate_length gets truncated
(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
["1", "2", "3", "..."], # Only first 3 elements after truncation + ...
3,
),
],
)
def test_sanitize_for_struct(input_obj, expected_output, truncate_length):
"""Test sanitize_for_struct with various input types and truncation lengths."""
result = sanitize_for_struct(input_obj, truncate_length)
assert result == expected_output, f"Expected {expected_output}, got {result}"
def test_update_dataset_metadata_state(
ray_start_cluster_with_export_api_write, dummy_dataset_topology
):
"""Test dataset state update at the export API"""
stats_actor = get_or_create_stats_actor()
# Register dataset
ray.get(
stats_actor.register_dataset.remote(
job_id=STUB_JOB_ID,
dataset_tag=STUB_DATASET_ID,
operator_tags=["Input_0", "ReadRange->Map(<lambda>)->Filter(<lambda>)_1"],
topology=dummy_dataset_topology,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
# Check that export files were created as expected
data = _get_exported_data()
assert len(data) == 1
assert data[0]["event_data"]["state"] == DatasetState.PENDING.name
# Test update state to RUNNING
ray.get(
stats_actor.update_dataset_metadata_state.remote(
dataset_id=STUB_DATASET_ID, new_state=DatasetState.RUNNING.name
)
)
data = _get_exported_data()
assert len(data) == 2
assert data[1]["event_data"]["state"] == DatasetState.RUNNING.name
assert data[1]["event_data"]["execution_start_time"] > 0
# Test update to FINISHED
ray.get(
stats_actor.update_dataset_metadata_state.remote(
dataset_id=STUB_DATASET_ID, new_state=DatasetState.FINISHED.name
)
)
data = _get_exported_data()
assert len(data) == 3
assert data[2]["event_data"]["state"] == DatasetState.FINISHED.name
assert data[2]["event_data"]["execution_end_time"] > 0
assert (
data[2]["event_data"]["topology"]["operators"][1]["state"]
== DatasetState.FINISHED.name
)
assert data[2]["event_data"]["topology"]["operators"][1]["execution_end_time"] > 0
def test_update_dataset_metadata_operator_states(
ray_start_cluster_with_export_api_write, dummy_dataset_topology
):
stats_actor = get_or_create_stats_actor()
# Register dataset
ray.get(
stats_actor.register_dataset.remote(
dataset_tag=STUB_DATASET_ID,
operator_tags=["Input_0", "ReadRange->Map(<lambda>)->Filter(<lambda>)_1"],
topology=dummy_dataset_topology,
job_id=STUB_JOB_ID,
data_context=DataContextMetadata.from_data_context(
DataContext.get_current()
),
)
)
data = _get_exported_data()
assert len(data) == 1
assert (
data[0]["event_data"]["topology"]["operators"][1]["state"]
== DatasetState.RUNNING.name
)
# Test update to FINISHED
operator_id = "ReadRange->Map(<lambda>)->Filter(<lambda>)_1"
ray.get(
stats_actor.update_dataset_metadata_operator_states.remote(
dataset_id=STUB_DATASET_ID,
operator_states={operator_id: DatasetState.FINISHED.name},
)
)
data = _get_exported_data()
assert len(data) == 2
assert (
data[1]["event_data"]["topology"]["operators"][1]["state"]
== DatasetState.FINISHED.name
)
assert data[1]["event_data"]["topology"]["operators"][1]["execution_end_time"] > 0
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))