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

1427 lines
50 KiB
Python

import json
import threading
import time
from collections import OrderedDict, defaultdict
from unittest.mock import MagicMock, patch
import pytest
import ray
from ray.actor import ActorHandle
from ray.data._internal.execution.interfaces.execution_options import (
ExecutionOptions,
ExecutionResources,
)
from ray.runtime_env import RuntimeEnv
from ray.train import BackendConfig, DataConfig
from ray.train.v2._internal.callbacks.state_manager import (
StateManagerCallback,
TrainingFramework,
_get_framework_version,
)
from ray.train.v2._internal.exceptions import WorkerGroupStartupTimeoutError
from ray.train.v2._internal.execution.context import DistributedContext
from ray.train.v2._internal.execution.controller.state import (
ErroredState,
FinishedState,
InitializingState,
ReschedulingState,
ResizingState,
RestartingState,
RunningState,
SchedulingState,
ShuttingDownState,
)
from ray.train.v2._internal.execution.scaling_policy import ResizeDecision
from ray.train.v2._internal.execution.worker_group import (
ActorMetadata,
Worker,
WorkerGroup,
WorkerGroupContext,
)
from ray.train.v2._internal.state.schema import (
ActorStatus,
BackendConfig as BackendConfigSchema,
CheckpointConfig as CheckpointConfigSchema,
DataConfig as DataConfigSchema,
DataExecutionOptions,
ExecutionOptions as ExecutionOptionsSchema,
FailureConfig as FailureConfigSchema,
RunAttemptStatus,
RunConfig as RunConfigSchema,
RunSettings,
RunStatus,
ScalingConfig as ScalingConfigSchema,
TrainResources,
TrainRun,
TrainRunAttempt,
_to_json_serializable_value,
)
from ray.train.v2._internal.state.state_actor import (
TrainStateActor,
get_state_actor,
)
from ray.train.v2._internal.state.state_manager import TrainStateManager
from ray.train.v2._internal.state.util import (
_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
construct_data_config,
execution_options_to_model,
)
from ray.train.v2.api.config import (
CheckpointConfig,
FailureConfig,
RunConfig,
ScalingConfig,
)
from ray.train.v2.api.exceptions import ControllerError, WorkerGroupError
from ray.train.v2.tests.util import (
create_dummy_run_context,
create_mock_train_run,
create_mock_train_run_attempt,
)
from ray.util.state.common import ActorState
def create_mock_actor_state(state: ActorStatus):
return ActorState(
state=state,
actor_id="mock_actor_id",
class_name="mock_class_name",
job_id="mock_job_id",
name="mock_name",
node_id="mock_node_id",
pid=1234,
ray_namespace="mock_ray_namespace",
)
@pytest.fixture(scope="function")
def ray_start_regular():
ray.init()
yield
ray.shutdown()
@pytest.fixture
def mock_worker_group_context():
context = MagicMock(spec=WorkerGroupContext)
context.run_attempt_id = "attempt_1"
context.num_workers = 2
context.resources_per_worker = {"CPU": 1}
return context
def get_mock_actor(actor_id: str):
actor = MagicMock(spec=ActorHandle)
actor._actor_id.hex.return_value = actor_id
return actor
@pytest.fixture
def mock_worker():
actor = get_mock_actor("actor_1")
metadata = MagicMock(spec=ActorMetadata)
metadata.node_id = "node_1"
metadata.node_ip = "127.0.0.1"
metadata.pid = 1000
metadata.gpu_ids = []
distributed_context = MagicMock(spec=DistributedContext)
distributed_context.world_rank = 0
distributed_context.local_rank = 0
distributed_context.node_rank = 0
return Worker(
actor=actor,
metadata=metadata,
resources={"CPU": 1},
distributed_context=distributed_context,
log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-worker.log",
)
@pytest.fixture
def mock_worker_group(mock_worker_group_context, mock_worker):
group = MagicMock(spec=WorkerGroup)
group.get_worker_group_context.return_value = mock_worker_group_context
group.get_worker_group_state.return_value = MagicMock(workers=[mock_worker])
group.get_latest_poll_status.return_value = None
# Mocks the return value of _get_framework_version
group.execute_single.return_value = {"ray": ray.__version__}
return group
@pytest.fixture
def callback(monkeypatch):
# Mock the runtime context to return a fixed actor ID
mock_runtime_context = MagicMock()
mock_runtime_context.get_job_id.return_value = "test_job_id"
mock_runtime_context.get_actor_id.return_value = "test_controller_id"
monkeypatch.setattr(
ray.runtime_context, "get_runtime_context", lambda: mock_runtime_context
)
# Mock the log path function
expected_controller_log_path = (
"/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log"
)
monkeypatch.setattr(
ray.train.v2._internal.callbacks.state_manager,
"get_train_application_controller_log_path",
lambda: expected_controller_log_path,
)
callback = StateManagerCallback(datasets={})
callback.after_controller_start(train_run_context=create_dummy_run_context())
return callback
# =============================================================================
# TrainStateActor: CRUD and dead-controller reconciliation
# =============================================================================
def test_train_state_actor_create_and_get_run(ray_start_regular):
"""Test basic CRUD operations for train runs in the state actor."""
actor = ray.remote(TrainStateActor).remote()
# Test creation with minimal fields
run = TrainRun(
id="test_run",
name="test",
job_id="job_1",
status=RunStatus.INITIALIZING,
status_detail=None,
controller_actor_id="controller_1",
start_time_ns=1000,
end_time_ns=None,
controller_log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log",
framework_versions={"ray": ray.__version__},
run_settings=RunSettings(
train_loop_config=None,
backend_config=BackendConfigSchema(framework=None, config={}),
scaling_config=ScalingConfigSchema(
num_workers=1,
use_gpu=False,
resources_per_worker=None,
placement_strategy="PACK",
accelerator_type=None,
use_tpu=False,
topology=None,
bundle_label_selector=None,
),
datasets=["dataset_1"],
data_config=DataConfigSchema(
datasets_to_split="all",
data_execution_options=DataExecutionOptions(
default=execution_options_to_model(
DataConfig.default_ingest_options()
),
),
enable_shard_locality=True,
),
run_config=RunConfigSchema(
name="test",
failure_config=FailureConfigSchema(
max_failures=0, controller_failure_limit=-1
),
worker_runtime_env={"type": "conda"},
checkpoint_config=CheckpointConfigSchema(
num_to_keep=None,
checkpoint_score_attribute=None,
checkpoint_score_order="max",
),
storage_path="s3://bucket/path",
),
),
)
ray.get(actor.create_or_update_train_run.remote(run))
runs = ray.get(actor.get_train_runs.remote())
assert len(runs) == 1
assert "test_run" in runs
stored_run = runs["test_run"]
assert stored_run == run # Check full equality
# Test update preserves unmodified fields
updated_run = run.copy(
update={"status": RunStatus.RUNNING, "status_detail": "Now running"}
)
ray.get(actor.create_or_update_train_run.remote(updated_run))
runs = ray.get(actor.get_train_runs.remote())
stored_run = runs["test_run"]
assert stored_run == updated_run
assert stored_run.start_time_ns == run.start_time_ns # Original field preserved
def test_train_state_actor_create_and_get_run_attempt(ray_start_regular):
actor = ray.remote(TrainStateActor).remote()
resources = [TrainResources(resources={"CPU": 1})]
run_attempt = TrainRunAttempt(
run_id="test_run",
attempt_id="attempt_1",
status=RunAttemptStatus.PENDING,
status_detail=None,
start_time_ns=1000,
resources=resources,
workers=[],
)
# Test creation
ray.get(actor.create_or_update_train_run_attempt.remote(run_attempt))
attempts = ray.get(actor.get_train_run_attempts.remote())
assert "test_run" in attempts
assert "attempt_1" in attempts["test_run"]
attempt = attempts["test_run"]["attempt_1"]
assert attempt.status == RunAttemptStatus.PENDING
assert attempt.start_time_ns == 1000
assert attempt.resources == resources
assert len(attempt.workers) == 0
# Test update
updated_attempt = run_attempt.copy(update={"status": RunAttemptStatus.RUNNING})
ray.get(actor.create_or_update_train_run_attempt.remote(updated_attempt))
attempts = ray.get(actor.get_train_run_attempts.remote())
assert attempts["test_run"]["attempt_1"].status == RunAttemptStatus.RUNNING
def test_train_state_actor_abort_dead_controller_live_runs(monkeypatch):
# Monkeypatch get_actor to return correct actor state per controller actor ID.
def get_actor(actor_id: str, timeout: float):
if actor_id == "nonexistent_controller_no_attempts_id":
return None
if actor_id in [
"dead_controller_one_attempt_id",
"dead_controller_two_attempts_id",
"finished_controller_id",
]:
return create_mock_actor_state(state="DEAD")
if actor_id == "live_controller_one_attempt_id":
return create_mock_actor_state(state="ALIVE")
raise ValueError(f"Unknown actor {actor_id}.")
monkeypatch.setattr("ray.train.v2._internal.state.util.get_actor", get_actor)
monkeypatch.setattr("uuid.uuid4", lambda: MagicMock(hex="mock_uuid"))
monkeypatch.setattr("time.time_ns", lambda: 1000)
# Create TrainStateActor with interesting runs and run attempts.
# NOTE: TrainStateActor will poll for real but its updates are idempotent.
actor = TrainStateActor(
enable_state_actor_reconciliation=True,
controllers_to_poll_per_iteration=5,
)
finished_controller_run = create_mock_train_run(
status=RunStatus.FINISHED,
controller_actor_id="finished_controller_id",
id="finished_controller_run_id",
)
live_controller_one_attempt_run = create_mock_train_run(
status=RunStatus.RUNNING,
controller_actor_id="live_controller_one_attempt_id",
id="live_controller_one_attempt_run_id",
)
actor._runs = OrderedDict(
{
"nonexistent_controller_no_attempts_run_id": create_mock_train_run(
status=RunStatus.INITIALIZING,
controller_actor_id="nonexistent_controller_no_attempts_id",
id="nonexistent_controller_no_attempts_run_id",
),
"dead_controller_one_attempt_run_id": create_mock_train_run(
status=RunStatus.INITIALIZING,
controller_actor_id="dead_controller_one_attempt_id",
id="dead_controller_one_attempt_run_id",
),
"dead_controller_two_attempts_run_id": create_mock_train_run(
status=RunStatus.SCHEDULING,
controller_actor_id="dead_controller_two_attempts_id",
id="dead_controller_two_attempts_run_id",
),
"finished_controller_run_id": finished_controller_run,
"live_controller_one_attempt_run_id": live_controller_one_attempt_run,
}
)
live_controller_one_attempt_run_attempt = create_mock_train_run_attempt(
status=RunAttemptStatus.RUNNING,
run_id="live_controller_one_attempt_run_id",
attempt_id="attempt_1",
)
dead_controller_two_attempts_first_attempt = (
create_mock_train_run_attempt(
attempt_id="attempt_1",
status=RunAttemptStatus.ERRORED,
run_id="dead_controller_two_attempts_run_id",
),
)
actor._run_attempts = {
"nonexistent_controller_no_attempts_run_id": {},
"dead_controller_one_attempt_run_id": {
"attempt_1": create_mock_train_run_attempt(
attempt_id="attempt_1",
status=RunAttemptStatus.PENDING,
run_id="dead_controller_one_attempt_run_id",
),
},
"dead_controller_two_attempts_run_id": OrderedDict(
{
"attempt_1": dead_controller_two_attempts_first_attempt,
"attempt_2": create_mock_train_run_attempt(
status=RunAttemptStatus.RUNNING,
attempt_id="attempt_2",
run_id="dead_controller_two_attempts_run_id",
),
}
),
"finished_controller_run_id": {},
"live_controller_one_attempt_run_id": {
"attempt_1": live_controller_one_attempt_run_attempt,
},
}
# Assert correct runs and run attempts get aborted.
assert (
actor._abort_live_runs_with_dead_controllers(
"dead_controller_two_attempts_run_id"
)
== "dead_controller_two_attempts_run_id"
)
assert actor._runs == OrderedDict(
{
"nonexistent_controller_no_attempts_run_id": create_mock_train_run(
status=RunStatus.ABORTED,
controller_actor_id="nonexistent_controller_no_attempts_id",
end_time_ns=1000,
id="nonexistent_controller_no_attempts_run_id",
status_detail=_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
),
"dead_controller_one_attempt_run_id": create_mock_train_run(
status=RunStatus.ABORTED,
controller_actor_id="dead_controller_one_attempt_id",
end_time_ns=1000,
id="dead_controller_one_attempt_run_id",
status_detail=_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
),
"dead_controller_two_attempts_run_id": create_mock_train_run(
status=RunStatus.ABORTED,
controller_actor_id="dead_controller_two_attempts_id",
end_time_ns=1000,
id="dead_controller_two_attempts_run_id",
status_detail=_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
),
"finished_controller_run_id": finished_controller_run,
"live_controller_one_attempt_run_id": live_controller_one_attempt_run,
}
)
assert actor._run_attempts == {
"nonexistent_controller_no_attempts_run_id": {},
"dead_controller_one_attempt_run_id": {
"attempt_1": create_mock_train_run_attempt(
status=RunAttemptStatus.ABORTED,
run_id="dead_controller_one_attempt_run_id",
attempt_id="attempt_1",
end_time_ns=1000,
worker_status=ActorStatus.DEAD,
status_detail=_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
)
},
"dead_controller_two_attempts_run_id": OrderedDict(
{
"attempt_1": dead_controller_two_attempts_first_attempt,
"attempt_2": create_mock_train_run_attempt(
status=RunAttemptStatus.ABORTED,
run_id="dead_controller_two_attempts_run_id",
attempt_id="attempt_2",
end_time_ns=1000,
worker_status=ActorStatus.DEAD,
status_detail=_DEAD_CONTROLLER_ABORT_STATUS_DETAIL,
),
}
),
"finished_controller_run_id": {},
"live_controller_one_attempt_run_id": {
"attempt_1": live_controller_one_attempt_run_attempt,
},
}
@patch("ray.train.v2._internal.state.util.get_actor", autospec=True)
def test_train_state_actor_abort_dead_controller_live_runs_server_unavailable(
mock_get_actor,
):
mock_get_actor.side_effect = ray.util.state.exception.ServerUnavailable
actor = TrainStateActor(
enable_state_actor_reconciliation=True,
reconciliation_interval_s=0,
)
actor.create_or_update_train_run(
create_mock_train_run(
status=RunStatus.RUNNING,
controller_actor_id="controller_actor_id",
id="run_id",
)
)
# Still RUNNING after ServerUnavailable
while mock_get_actor.call_count == 0:
time.sleep(0.01)
assert actor.get_train_runs()["run_id"].status == RunStatus.RUNNING
# ABORTED after detecting dead controller
mock_get_actor.side_effect = lambda actor_id, timeout: create_mock_actor_state(
state="DEAD"
)
while actor.get_train_runs()["run_id"].status != RunStatus.ABORTED:
time.sleep(0.01)
assert actor.get_train_runs()["run_id"].status == RunStatus.ABORTED
# =============================================================================
# TrainStateManager: run and run-attempt lifecycle
# =============================================================================
# max_concurrency=2 lets open_gate run alongside a gated create_or_update call;
# otherwise the actor's single-threaded queue would deadlock.
@ray.remote(max_concurrency=2)
class _GatedStateActor:
"""Mimics TrainStateActor but blocks create_or_update calls until released.
Used to verify that TrainStateManager.create_*/update_* calls with block=True
do not return until the state actor has finished processing the request.
"""
def __init__(self):
self._runs = {}
self._run_attempts = defaultdict(dict)
self._gate_open = False
def open_gate(self):
self._gate_open = True
def _wait_for_gate(self):
while not self._gate_open:
time.sleep(0.01)
def create_or_update_train_run(self, run):
self._wait_for_gate()
self._runs[run.id] = run
def create_or_update_train_run_attempt(self, attempt):
self._wait_for_gate()
self._run_attempts[attempt.run_id][attempt.attempt_id] = attempt
def get_train_runs(self):
return self._runs
def get_train_run_attempts(self):
return self._run_attempts
def test_create_train_run_blocks_for_caller_death_safety(
ray_start_regular, monkeypatch
):
"""create_train_run must not return until the state actor has finished
recording the run.
Without this, the controller could exit between .remote() submission and
the task being delivered to the state actor, losing the run entirely.
"""
gated = _GatedStateActor.remote()
monkeypatch.setattr(
"ray.train.v2._internal.state.state_manager.get_or_create_state_actor",
lambda: gated,
)
manager = TrainStateManager()
finished = threading.Event()
def call():
manager.create_train_run(
id="test_run",
name="test",
job_id="job_1",
controller_actor_id="controller_1",
controller_log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log",
run_config=RunConfig(
name="test",
failure_config=FailureConfig(max_failures=1),
storage_path="s3://bucket/path",
),
train_loop_config=None,
scaling_config=ScalingConfig(num_workers=1),
backend_config=BackendConfig(),
datasets={},
dataset_config=DataConfig(),
)
finished.set()
thread = threading.Thread(target=call)
thread.start()
# While the gate is closed, the manager must remain blocked.
finished.wait(timeout=1.0)
assert not finished.is_set(), (
"create_train_run returned before the state actor processed the "
"request — block=True is not enforcing caller-death safety."
)
# Opening the gate lets the state actor finish; the manager call unblocks.
ray.get(gated.open_gate.remote())
finished.wait(timeout=10)
assert finished.is_set()
thread.join()
runs = ray.get(gated.get_train_runs.remote())
assert "test_run" in runs
def test_update_train_run_attempt_finished_blocks_for_caller_death_safety(
ray_start_regular, monkeypatch
):
"""update_train_run_attempt_finished must not return until the state actor
has recorded the terminal status.
Without blocking on terminal-status writes, the controller could exit with
the attempt still showing as RUNNING in the state actor.
"""
gated = _GatedStateActor.remote()
monkeypatch.setattr(
"ray.train.v2._internal.state.state_manager.get_or_create_state_actor",
lambda: gated,
)
manager = TrainStateManager()
# Skip the create flow (which uses block=False for attempt creation) and
# seed the manager's in-memory state so the terminal update has something
# to act on.
manager._run_attempts["test_run"]["attempt_1"] = create_mock_train_run_attempt(
attempt_id="attempt_1",
run_id="test_run",
status=RunAttemptStatus.RUNNING,
)
finished = threading.Event()
def call():
manager.update_train_run_attempt_finished(
run_id="test_run", attempt_id="attempt_1"
)
finished.set()
thread = threading.Thread(target=call)
thread.start()
finished.wait(timeout=1.0)
assert not finished.is_set(), (
"update_train_run_attempt_finished returned before the state actor "
"processed the request — block=True is not enforcing caller-death "
"safety on terminal status."
)
ray.get(gated.open_gate.remote())
finished.wait(timeout=10)
assert finished.is_set()
thread.join()
attempts = ray.get(gated.get_train_run_attempts.remote())
assert attempts["test_run"]["attempt_1"].status == RunAttemptStatus.FINISHED
def test_train_state_manager_run_lifecycle(ray_start_regular):
"""Test the complete lifecycle of a training run through the state manager."""
manager = TrainStateManager()
# Test run creation with validation
run_id = "test_run"
manager.create_train_run(
id=run_id,
name="test",
job_id="job_1",
controller_actor_id="controller_1",
controller_log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log",
run_config=RunConfig(
name="test",
failure_config=FailureConfig(max_failures=1),
worker_runtime_env={"type": "conda"},
checkpoint_config=CheckpointConfig(num_to_keep=1),
storage_path="s3://bucket/path",
storage_filesystem=None,
),
train_loop_config={"epochs": 10},
scaling_config=ScalingConfig(num_workers=2),
backend_config=BackendConfig(),
datasets={"dataset_1": ray.data.from_items([1, 2, 3])},
dataset_config=DataConfig(datasets_to_split="all"),
)
def get_run():
state_actor = get_state_actor()
runs = ray.get(state_actor.get_train_runs.remote())
return runs[run_id]
# Verify initial state
run = get_run()
assert run.status == RunStatus.INITIALIZING
assert run.start_time_ns is not None
assert run.end_time_ns is None
# Test state transitions with timestamps
state_transitions = [
(manager.update_train_run_scheduling, RunStatus.SCHEDULING),
(manager.update_train_run_running, RunStatus.RUNNING),
(manager.update_train_run_finished, RunStatus.FINISHED),
]
for update_fn, expected_status in state_transitions:
update_fn(run_id)
run = get_run()
assert run.status == expected_status
if expected_status == RunStatus.FINISHED:
assert run.end_time_ns is not None
else:
assert run.end_time_ns is None
def test_train_state_manager_run_attempt_lifecycle(ray_start_regular):
manager = TrainStateManager()
# Create initial run
manager.create_train_run(
id="test_run",
name="test",
job_id="job_1",
controller_actor_id="controller_1",
controller_log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log",
run_config=RunConfig(
name="test",
failure_config=FailureConfig(max_failures=1),
worker_runtime_env=RuntimeEnv(env_vars={"DUMMY_VAR": "abcd"}),
checkpoint_config=CheckpointConfig(),
storage_path="s3://bucket/path",
),
train_loop_config={"epochs": 10},
scaling_config=ScalingConfig(num_workers=2),
backend_config=BackendConfig(),
datasets={"dataset_1": ray.data.from_items([1, 2, 3])},
dataset_config=DataConfig(datasets_to_split="all"),
)
# Test attempt creation
manager.create_train_run_attempt(
run_id="test_run",
attempt_id="attempt_1",
num_workers=2,
resources_per_worker={"CPU": 1},
)
state_actor = get_state_actor()
attempts = ray.get(state_actor.get_train_run_attempts.remote())
assert "test_run" in attempts
assert "attempt_1" in attempts["test_run"]
attempt = attempts["test_run"]["attempt_1"]
assert attempt.status == RunAttemptStatus.PENDING
assert len(attempt.resources) == 2
assert all(r.resources == {"CPU": 1} for r in attempt.resources)
# Test running state with workers
workers = [
Worker(
actor=get_mock_actor(f"actor_{i}"),
metadata=MagicMock(
node_id="node_1", node_ip="127.0.0.1", pid=1000 + i, gpu_ids=[]
),
resources={"CPU": 1},
distributed_context=MagicMock(world_rank=i, local_rank=i, node_rank=0),
log_file_path="/tmp/ray/session_xxx/logs/train/ray-train-app-worker.log",
)
for i in range(2)
]
manager.update_train_run_attempt_running(
run_id="test_run",
attempt_id="attempt_1",
workers=workers,
)
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = attempts["test_run"]["attempt_1"]
assert attempt.status == RunAttemptStatus.RUNNING
assert len(attempt.workers) == 2
assert all(w.status == ActorStatus.ALIVE for w in attempt.workers)
# Test finished state
manager.update_train_run_attempt_finished(
run_id="test_run",
attempt_id="attempt_1",
)
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = attempts["test_run"]["attempt_1"]
assert attempt.status == RunAttemptStatus.FINISHED
assert attempt.end_time_ns is not None
assert len(attempt.workers) == 2
assert all(w.status == ActorStatus.DEAD for w in attempt.workers)
# =============================================================================
# StateManagerCallback: controller state, worker group, and log paths
# =============================================================================
def test_callback_controller_state_transitions(ray_start_regular, callback):
states = [
InitializingState(),
SchedulingState(
scaling_decision=ResizeDecision(num_workers=2, resources_per_worker={})
),
RunningState(),
RestartingState(
training_failed_error=WorkerGroupError(error_message="", worker_failures={})
),
SchedulingState(
scaling_decision=ResizeDecision(num_workers=2, resources_per_worker={})
),
RunningState(),
ResizingState(
scaling_decision=ResizeDecision(num_workers=4, resources_per_worker={})
),
SchedulingState(
scaling_decision=ResizeDecision(num_workers=4, resources_per_worker={})
),
ReschedulingState(
training_failed_error=ControllerError(WorkerGroupStartupTimeoutError(0))
),
SchedulingState(
scaling_decision=ResizeDecision(num_workers=2, resources_per_worker={})
),
RunningState(),
ShuttingDownState(next_state=FinishedState()),
FinishedState(),
]
expected_statuses = [
RunStatus.INITIALIZING,
RunStatus.SCHEDULING,
RunStatus.RUNNING,
RunStatus.RESTARTING,
RunStatus.SCHEDULING,
RunStatus.RUNNING,
RunStatus.RESIZING,
RunStatus.SCHEDULING,
RunStatus.SCHEDULING, # Rescheduling
RunStatus.SCHEDULING,
RunStatus.RUNNING,
RunStatus.RUNNING, # Shutting down
RunStatus.FINISHED,
]
state_actor = get_state_actor()
for i in range(len(states) - 1):
callback.after_controller_state_update(states[i], states[i + 1])
runs = ray.get(state_actor.get_train_runs.remote())
run = runs[callback._run_id]
assert run.status == expected_statuses[i + 1]
def test_callback_error_state_transition(ray_start_regular, callback):
error_msg = "Test error"
error_state = ErroredState(
training_failed_error=ControllerError(Exception(error_msg))
)
callback.after_controller_state_update(RunningState(), error_state)
state_actor = get_state_actor()
runs = ray.get(state_actor.get_train_runs.remote())
run = list(runs.values())[0]
print(runs)
assert run.status == RunStatus.ERRORED
assert error_msg in run.status_detail
assert run.end_time_ns is not None
def test_callback_aborted_with_worker_group_context(
ray_start_regular, callback, mock_worker_group_context
):
callback.before_worker_group_start(mock_worker_group_context)
callback.before_worker_group_abort(mock_worker_group_context)
state_actor = get_state_actor()
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = list(attempts.values())[0]["attempt_1"]
assert attempt.status == RunAttemptStatus.ABORTED
def test_callback_worker_group_lifecycle(
ray_start_regular, callback, mock_worker_group, mock_worker_group_context
):
"""Test the complete lifecycle of a worker group through state callbacks."""
state_actor = get_state_actor()
def get_attempt():
attempts = ray.get(state_actor.get_train_run_attempts.remote())
return list(attempts.values())[0]["attempt_1"]
# Test initialization
callback.before_worker_group_start(mock_worker_group_context)
attempt = get_attempt()
assert attempt.status == RunAttemptStatus.PENDING
assert len(attempt.resources) == mock_worker_group_context.num_workers
assert all(
r.resources == mock_worker_group_context.resources_per_worker
for r in attempt.resources
)
# Test startup
callback.after_worker_group_start(mock_worker_group)
attempt = get_attempt()
assert attempt.status == RunAttemptStatus.RUNNING
assert len(attempt.workers) == len(
mock_worker_group.get_worker_group_state().workers
)
for worker in attempt.workers:
assert worker.status == ActorStatus.ALIVE
assert (
worker.resources.resources == mock_worker_group_context.resources_per_worker
)
# Test shutdown
callback.before_worker_group_shutdown(mock_worker_group)
attempt = get_attempt()
assert attempt.status == RunAttemptStatus.FINISHED
assert attempt.end_time_ns is not None
def test_callback_worker_group_error(
ray_start_regular, callback, mock_worker_group, mock_worker_group_context
):
state_actor = get_state_actor()
callback.before_worker_group_start(mock_worker_group_context)
callback.after_worker_group_start(mock_worker_group)
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = list(attempts.values())[0]["attempt_1"]
assert attempt.status == RunAttemptStatus.RUNNING
assert len(attempt.workers) == 1
assert attempt.workers[0].status == ActorStatus.ALIVE
# Simulate error in worker group
error_msg = "Test error"
error_status = MagicMock()
error_status.errors = [error_msg]
error_status.get_error_string.return_value = error_msg
mock_worker_group.get_latest_poll_status.return_value = error_status
callback.before_worker_group_shutdown(mock_worker_group)
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = list(attempts.values())[0]["attempt_1"]
assert attempt.status == RunAttemptStatus.ERRORED
assert attempt.status_detail == error_msg
assert attempt.end_time_ns is not None
assert len(attempt.workers) == 1
assert attempt.workers[0].status == ActorStatus.DEAD
def test_callback_log_file_paths(
ray_start_regular,
monkeypatch,
mock_worker_group_context,
mock_worker,
):
"""Test that StateManagerCallback correctly captures and propagates log file paths."""
# Mock the runtime context
mock_runtime_context = MagicMock()
mock_runtime_context.get_job_id.return_value = "test_job_id"
mock_runtime_context.get_actor_id.return_value = "test_controller_id"
monkeypatch.setattr(
ray.runtime_context, "get_runtime_context", lambda: mock_runtime_context
)
# Mock the log path function
expected_controller_log_path = (
"/tmp/ray/session_xxx/logs/train/ray-train-app-controller.log"
)
monkeypatch.setattr(
ray.train.v2._internal.callbacks.state_manager,
"get_train_application_controller_log_path",
lambda: expected_controller_log_path,
)
# Create the callback
callback = StateManagerCallback(datasets={})
# Initialize the callback
callback.after_controller_start(train_run_context=create_dummy_run_context())
# Verify the log path was set in the state actor
state_actor = get_state_actor()
runs = ray.get(state_actor.get_train_runs.remote())
run = runs[callback._run_id]
assert run.controller_log_file_path == expected_controller_log_path
# Now test worker log paths
# Create a mock worker with a log file path
mock_worker = mock_worker
mock_worker.log_file_path = (
"/tmp/ray/session_xxx/logs/train/ray-train-app-worker.log"
)
# Create a mock worker group
mock_worker_group = MagicMock(spec=WorkerGroup)
mock_worker_group.get_worker_group_context.return_value = mock_worker_group_context
mock_worker_group.get_worker_group_state.return_value = MagicMock(
workers=[mock_worker]
)
# Mocks the return value of _get_framework_version
mock_worker_group.execute_single.return_value = {"ray": ray.__version__}
# mock_worker_group.get_latest_poll_status.return_value = None
# Start the worker group
callback.before_worker_group_start(mock_worker_group_context)
callback.after_worker_group_start(mock_worker_group)
# Verify the worker log path was set in the state actor
attempts = ray.get(state_actor.get_train_run_attempts.remote())
attempt = list(attempts.values())[0][mock_worker_group_context.run_attempt_id]
assert len(attempt.workers) == 1
assert attempt.workers[0].log_file_path == mock_worker.log_file_path
# =============================================================================
# Helpers: framework version detection and DataConfig serialization
# =============================================================================
def test_get_framework_version():
"""Test _get_framework_version with None and every TrainingFramework value."""
# None should return only the ray version.
versions = _get_framework_version(None)
assert list(versions.keys()) == ["ray"]
assert versions["ray"] == ray.__version__
# Mock importlib.import_module to prevent heavy imports
mock_versions = {
name: f"{name}-mock-1.2.3"
for framework in TrainingFramework
for name in framework.module_names()
}
def mock_import(name):
module = MagicMock()
module.__version__ = mock_versions[name]
return module
with patch(
"ray.train.v2._internal.callbacks.state_manager.importlib"
) as mock_importlib:
mock_importlib.import_module.side_effect = mock_import
for framework in TrainingFramework:
versions = _get_framework_version(framework)
assert versions["ray"] == ray.__version__
for module_name in framework.module_names():
assert versions[module_name] == mock_versions[module_name]
def test_execution_options_to_model_defaults_and_custom():
"""Test execution_options_to_model with default and fully customized options."""
# Default options
default_result = execution_options_to_model(ExecutionOptions())
assert isinstance(default_result, ExecutionOptionsSchema)
assert default_result.preserve_order is False
assert default_result.actor_locality_enabled is True
# All custom values
custom_result = execution_options_to_model(
ExecutionOptions(
resource_limits=ExecutionResources(
cpu=8.0, gpu=4.0, object_store_memory=1e9
),
exclude_resources=ExecutionResources(cpu=2.0, gpu=0.5),
preserve_order=True,
actor_locality_enabled=False,
verbose_progress=False,
)
)
assert custom_result.resource_limits["CPU"] == 8.0
assert custom_result.resource_limits["GPU"] == 4.0
assert custom_result.resource_limits["object_store_memory"] == 1e9
assert custom_result.exclude_resources["CPU"] == 2.0
assert custom_result.exclude_resources["GPU"] == 0.5
assert custom_result.preserve_order is True
assert custom_result.actor_locality_enabled is False
assert custom_result.verbose_progress is False
def test_construct_data_config_defaults_and_split_variants():
"""Test construct_data_config with default config and different split options."""
# Default: data_execution_options.default mirrors the library default ingest
# options and per_dataset_execution_options is empty.
default = construct_data_config(DataConfig())
assert isinstance(default, DataConfigSchema)
assert default.datasets_to_split == "all"
assert default.enable_shard_locality is True
assert isinstance(default.data_execution_options, DataExecutionOptions)
assert default.data_execution_options.default == execution_options_to_model(
DataConfig.default_ingest_options()
)
assert default.data_execution_options.per_dataset_execution_options == {}
# Specific dataset list
result = construct_data_config(DataConfig(datasets_to_split=["train", "eval"]))
assert result.datasets_to_split == ["train", "eval"]
# Empty list
result = construct_data_config(DataConfig(datasets_to_split=[]))
assert result.datasets_to_split == []
# Shard locality disabled
result = construct_data_config(DataConfig(enable_shard_locality=False))
assert result.enable_shard_locality is False
def test_construct_data_config_single_execution_options():
"""A single ExecutionOptions lands in data_execution_options.default and
leaves per_dataset_execution_options empty."""
shared = ExecutionOptions(
resource_limits=ExecutionResources(cpu=8.0, gpu=2.0),
exclude_resources=ExecutionResources(cpu=1.0),
preserve_order=True,
actor_locality_enabled=False,
verbose_progress=False,
)
result = construct_data_config(
DataConfig(
datasets_to_split=["train", "eval"],
execution_options=shared,
)
)
assert result.data_execution_options.default == execution_options_to_model(shared)
assert result.data_execution_options.per_dataset_execution_options == {}
def test_construct_data_config_per_dataset_execution_options():
"""Per-dataset ExecutionOptions land in per_dataset_execution_options while
default remains the library default."""
config = DataConfig(
datasets_to_split=["ds1", "ds2", "ds3"],
execution_options={
"ds1": ExecutionOptions(
resource_limits=ExecutionResources(cpu=16.0, gpu=8.0),
exclude_resources=ExecutionResources(cpu=4.0),
preserve_order=True,
actor_locality_enabled=False,
verbose_progress=False,
),
"ds2": ExecutionOptions(
verbose_progress=False,
),
"ds3": ExecutionOptions(
exclude_resources=ExecutionResources(cpu=0.5, gpu=0.5),
),
},
enable_shard_locality=False,
)
result = construct_data_config(config)
assert result.datasets_to_split == ["ds1", "ds2", "ds3"]
assert result.enable_shard_locality is False
# default reflects the library default ingest options.
assert result.data_execution_options.default == execution_options_to_model(
DataConfig.default_ingest_options()
)
overrides = result.data_execution_options.per_dataset_execution_options
assert set(overrides.keys()) == {"ds1", "ds2", "ds3"}
ds1 = overrides["ds1"]
assert ds1.resource_limits["CPU"] == 16.0
assert ds1.resource_limits["GPU"] == 8.0
assert ds1.exclude_resources["CPU"] == 4.0
assert ds1.preserve_order is True
assert ds1.actor_locality_enabled is False
assert ds1.verbose_progress is False
ds2 = overrides["ds2"]
assert ds2.verbose_progress is False
ds3 = overrides["ds3"]
assert ds3.exclude_resources["CPU"] == 0.5
assert ds3.exclude_resources["GPU"] == 0.5
def test_construct_data_config_partial_per_dataset_execution_options():
"""User dict covering a subset of datasets populates only those overrides
while default remains the library default."""
custom = ExecutionOptions(
resource_limits=ExecutionResources(cpu=4.0),
preserve_order=True,
)
config = DataConfig(
datasets_to_split=["train", "eval", "predict"],
execution_options={"train": custom},
)
result = construct_data_config(config)
assert result.data_execution_options.default == execution_options_to_model(
DataConfig.default_ingest_options()
)
overrides = result.data_execution_options.per_dataset_execution_options
assert set(overrides.keys()) == {"train"}
assert overrides["train"] == execution_options_to_model(custom)
# =============================================================================
# Schema sanitization tests
# =============================================================================
def test_to_json_serializable_value_standalone_inputs():
"""The sanitizer accepts any value, not just dicts.
Covers JSON-native primitives (passthrough), edge floats (stringified),
bytes (str fallback), modules (str fallback), and a custom object
(uses __str__).
"""
class Obj:
def __str__(self):
return "Obj()"
# JSON-native primitives pass through unchanged.
assert _to_json_serializable_value(None) is None
assert _to_json_serializable_value(True) is True
assert _to_json_serializable_value(42) == 42
assert _to_json_serializable_value("hello") == "hello"
assert _to_json_serializable_value(3.14) == 3.14
assert _to_json_serializable_value([1, "a", None]) == [1, "a", None]
# Non-finite floats get stringified (not valid JSON otherwise).
assert _to_json_serializable_value(float("inf")) == "inf"
assert _to_json_serializable_value(float("-inf")) == "-inf"
assert _to_json_serializable_value(float("nan")) == "nan"
# Bytes fall through to str() (no special handling).
assert _to_json_serializable_value(b"hello") == "b'hello'"
# A module uses its repr (modules define one, so we don't fall back to type name).
assert _to_json_serializable_value(json).startswith("<module 'json'")
# A custom object with __str__ uses that.
assert _to_json_serializable_value(Obj()) == "Obj()"
# A module uses default python string representation.
import ray
assert _to_json_serializable_value(ray).startswith("<module 'ray'")
def test_to_json_serializable_value_collection_coercion():
"""tuple, set, and frozenset are all coerced to lists."""
# tuple → list
assert _to_json_serializable_value({"t": (1, 2, 3)}) == {"t": [1, 2, 3]}
# set → list (use sorted comparison since set iteration order isn't guaranteed)
result = _to_json_serializable_value({"s": {3, 1, 2}})
assert sorted(result["s"]) == [1, 2, 3]
# frozenset → list
result = _to_json_serializable_value({"f": frozenset({3, 1, 2})})
assert sorted(result["f"]) == [1, 2, 3]
# Empty containers preserved.
assert _to_json_serializable_value({"d": {}, "l": [], "s": set()}) == {
"d": {},
"l": [],
"s": [],
}
def test_to_json_serializable_value_non_string_keys():
"""All dict keys are coerced via str(), regardless of original type."""
class KeyObj:
def __str__(self):
return "key_obj"
obj = {
1: "int",
2.5: "float",
None: "none",
(1, 2): "tuple",
KeyObj(): "custom",
}
assert _to_json_serializable_value(obj) == {
"1": "int",
"2.5": "float",
"None": "none",
"(1, 2)": "tuple",
"key_obj": "custom",
}
def test_to_json_serializable_value_max_depth():
"""Test that _to_json_serializable_value respects the max_depth argument."""
class CustomObj:
def __str__(self) -> str:
return "CustomObj"
obj = {
"native": 42,
"sequence": [1, CustomObj()],
"nested": {"inner": {"deep": 99}},
"obj": CustomObj(),
"inf_float": float("inf"),
}
with pytest.raises(ValueError, match="max_depth must be greater than 0"):
_to_json_serializable_value(obj, max_depth=0)
assert _to_json_serializable_value(obj, max_depth=2) == {
"native": 42,
"nested": {"inner": "..."},
"obj": "CustomObj",
"sequence": [1, "CustomObj"],
"inf_float": "inf",
}
assert _to_json_serializable_value(obj, max_depth=3) == {
"native": 42,
"nested": {"inner": {"deep": 99}},
"obj": "CustomObj",
"sequence": [1, "CustomObj"],
"inf_float": "inf",
}
def test_to_json_serializable_value_falls_back_to_type_name():
"""Objects without custom string representation are rendered as their class name."""
class NoCustomStr:
pass
class HasRepr:
def __repr__(self):
return "HasRepr(meaningful)"
obj = {"plain": NoCustomStr(), "with_repr": HasRepr()}
assert _to_json_serializable_value(obj) == {
"plain": "NoCustomStr",
"with_repr": "HasRepr(meaningful)",
}
def test_train_run_schema_sanitizes_all_validated_fields():
"""End-to-end: every dict field with a sanitizer validator coerces
non-JSON values at construction time, and the resulting TrainRun
serializes via pydantic's JSON dump without raising.
Covers:
- RunSettings.train_loop_config
- RunConfig.worker_runtime_env
- RunConfig.storage_filesystem
- BackendConfig.config
- ExecutionOptions.resource_limits / exclude_resources
"""
import pyarrow.fs
class CustomCfg:
def __str__(self):
return "CustomCfg()"
run = TrainRun(
id="r1",
name="test_run",
job_id="job_1",
controller_actor_id="controller_1",
status=RunStatus.RUNNING,
status_detail=None,
start_time_ns=1,
end_time_ns=None,
controller_log_file_path=None,
framework_versions={"ray": ray.__version__},
run_settings=RunSettings(
train_loop_config={"epochs": 3, "obj": CustomCfg(), "fn": lambda x: x},
backend_config=BackendConfigSchema(
framework=None,
config={"hook": lambda: None, "module": json},
),
scaling_config=ScalingConfigSchema(
num_workers=1,
use_gpu=False,
placement_strategy="PACK",
use_tpu=False,
),
datasets=["dataset_1"],
data_config=DataConfigSchema(
datasets_to_split="all",
data_execution_options=DataExecutionOptions(
default=ExecutionOptionsSchema(
resource_limits={"CPU": float("inf"), "obj": CustomCfg()},
exclude_resources={"GPU": float("nan")},
preserve_order=False,
actor_locality_enabled=True,
verbose_progress=True,
),
),
enable_shard_locality=True,
),
run_config=RunConfigSchema(
name="test_run",
failure_config=FailureConfigSchema(
max_failures=0, controller_failure_limit=-1
),
worker_runtime_env={"setup_hook": lambda: None, "type": "conda"},
checkpoint_config=CheckpointConfigSchema(checkpoint_score_order="max"),
storage_path="s3://bucket/path",
storage_filesystem=pyarrow.fs.LocalFileSystem(),
),
),
)
# Pydantic JSON dump must not raise, since every field was sanitized.
payload = json.loads(run.model_dump_json())
rs = payload["run_settings"]
# train_loop_config
assert rs["train_loop_config"]["epochs"] == 3
assert rs["train_loop_config"]["obj"] == "CustomCfg()"
assert rs["train_loop_config"]["fn"].startswith("<function ")
# backend_config.config
assert rs["backend_config"]["config"]["hook"].startswith("<function ")
assert rs["backend_config"]["config"]["module"].startswith("<module 'json'")
# data_config.data_execution_options.default.resource_limits / exclude_resources
default_opts = rs["data_config"]["data_execution_options"]["default"]
assert default_opts["resource_limits"]["CPU"] == "inf"
assert default_opts["resource_limits"]["obj"] == "CustomCfg()"
assert default_opts["exclude_resources"]["GPU"] == "nan"
# run_config.worker_runtime_env
assert rs["run_config"]["worker_runtime_env"]["setup_hook"].startswith("<function ")
assert rs["run_config"]["worker_runtime_env"]["type"] == "conda"
# run_config.storage_filesystem (pyarrow filesystems use default object repr,
# so they fall back to the type name)
assert rs["run_config"]["storage_filesystem"] == "LocalFileSystem"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))