# Copyright (c) Microsoft. All rights reserved. import asyncio import multiprocessing import sys from typing import Any, AsyncGenerator, Dict, Tuple, cast from unittest.mock import patch import aiohttp import pytest import pytest_asyncio from _pytest.monkeypatch import MonkeyPatch from aiohttp import ClientConnectorError, ClientResponseError, ServerDisconnectedError from opentelemetry.sdk.trace import ReadableSpan from portpicker import pick_unused_port from yarl import URL from agentlightning.store.base import UNSET, LightningStore from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer from agentlightning.store.memory import InMemoryLightningStore from agentlightning.types import ( LLM, EnqueueRolloutRequest, OtelResource, PaginatedResult, PromptTemplate, RolloutConfig, Span, TraceStatus, ) from agentlightning.utils.metrics import ( ConsoleMetricsBackend, MetricsBackend, MultiMetricsBackend, PrometheusMetricsBackend, ) from agentlightning.utils.server_launcher import LaunchMode, PythonServerLauncherArgs from ..common.prometheus_stub import make_prometheus_stub pytestmark = [pytest.mark.store] def _make_span(rollout_id: str, attempt_id: str, sequence_id: int, name: str) -> Span: return Span( rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=sequence_id, trace_id=f"{sequence_id:032x}", span_id=f"{sequence_id:016x}", parent_id=None, name=name, status=TraceStatus(status_code="OK"), attributes={}, events=[], links=[], start_time=None, end_time=None, context=None, parent=None, resource=OtelResource(attributes={}, schema_url=""), ) class MockResponse: """Wrapper that passes through to the original aiohttp context manager.""" def __init__(self, context_manager: Any): self._cm = context_manager async def __aenter__(self) -> aiohttp.ClientResponse: return await self._cm.__aenter__() async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: await self._cm.__aexit__(exc_type, exc_val, exc_tb) @pytest_asyncio.fixture async def server_client( store_fixture: LightningStore, ) -> AsyncGenerator[Tuple[LightningStoreServer, LightningStoreClient], None]: port = pick_unused_port() server = LightningStoreServer(store_fixture, "127.0.0.1", port) await server.start() client = LightningStoreClient(server.endpoint) try: yield server, client finally: await client.close() await server.stop() async def _exercise_server_metrics_backend(tracker: MetricsBackend) -> None: port = pick_unused_port() store = InMemoryLightningStore(tracker=tracker) server = LightningStoreServer(store, "127.0.0.1", port, tracker=tracker) await server.start() client = LightningStoreClient(server.endpoint) try: await _run_server_side_operations(server) await _run_client_side_operations(client) finally: await client.close() await server.stop() async def _run_server_side_operations(server: LightningStoreServer) -> None: await server.update_resources("metrics-server", {}) await server.get_latest_resources() started = await server.start_rollout(input={"origin": "server"}, config=RolloutConfig(timeout_seconds=1.0)) queued = await server.enqueue_rollout(input={"origin": "server-queue"}) dequeued = await server.dequeue_rollout(worker_id="metrics-server-worker") assert dequeued is not None await server.add_span(_make_span(dequeued.rollout_id, dequeued.attempt.attempt_id, 0, "server-span")) await server.update_attempt(queued.rollout_id, dequeued.attempt.attempt_id, status="running") await server.update_attempt(queued.rollout_id, dequeued.attempt.attempt_id, status="succeeded") await server.update_rollout(queued.rollout_id, status="succeeded") await server.wait_for_rollouts(rollout_ids=[queued.rollout_id], timeout=0.1) assert started is not None async def _run_client_side_operations(client: LightningStoreClient) -> None: await client.update_resources("metrics-client", {}) await client.get_latest_resources() await client.start_rollout(input={"origin": "client"}, mode="train", config=RolloutConfig(timeout_seconds=2.0)) queued = await client.enqueue_rollout( input={"origin": "client-queue"}, config=RolloutConfig(unresponsive_seconds=5.0) ) dequeued = await client.dequeue_rollout(worker_id="metrics-client-worker") assert dequeued is not None span = _make_span(dequeued.rollout_id, dequeued.attempt.attempt_id, 1, "client-span") await client.add_span(span) await client.update_attempt( dequeued.rollout_id, dequeued.attempt.attempt_id, status="running", worker_id="metrics-client-worker", ) await client.update_attempt(dequeued.rollout_id, dequeued.attempt.attempt_id, status="succeeded") await client.update_rollout(dequeued.rollout_id, status="succeeded") await client.wait_for_rollouts(rollout_ids=[dequeued.rollout_id], timeout=0.1) await client.query_rollouts() await client.query_attempts(dequeued.rollout_id) await client.get_worker_by_id("metrics-client-worker") assert queued.rollout_id == dequeued.rollout_id @pytest.mark.asyncio async def test_mp_server_does_not_work_with_inmemory_store() -> None: store = InMemoryLightningStore() with pytest.raises(ValueError, match="The store does not support zero-copy."): LightningStoreServer(store, "127.0.0.1", pick_unused_port(), launch_mode="mp") @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_server_start_rejects_port_conflict(caplog: pytest.LogCaptureFixture, launch_mode: LaunchMode) -> None: """Ensure startup fails loudly when the port is already owned by another store.""" store_a = InMemoryLightningStore() port = pick_unused_port() server_a = LightningStoreServer(store_a, "127.0.0.1", port) await server_a.start() store_b = InMemoryLightningStore() server_b = LightningStoreServer(store_b, "127.0.0.1", port, launch_mode=launch_mode) with pytest.raises(RuntimeError, match="did not start up within"): await server_b.start() assert "address already in use" in caplog.text await server_a.stop() @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_run_forever_rejects_port_conflict(caplog: pytest.LogCaptureFixture, launch_mode: LaunchMode) -> None: """Ensure run_forever also reports port conflicts with the friendly message.""" store_a = InMemoryLightningStore() port = pick_unused_port() server_a = LightningStoreServer(store_a, "127.0.0.1", port, launch_mode=launch_mode) await server_a.start() store_b = InMemoryLightningStore() server_b = LightningStoreServer(store_b, "127.0.0.1", port, launch_mode=launch_mode) with pytest.raises(RuntimeError, match="did not start up within"): await server_b.run_forever() assert "address already in use" in caplog.text await server_a.stop() @pytest.mark.asyncio async def test_server_accepts_custom_launcher_args(store_fixture: LightningStore) -> None: """Ensure providing launcher_args works end-to-end and is propagated to the launcher.""" port = pick_unused_port() launcher_args = PythonServerLauncherArgs( host="127.0.0.1", port=port, launch_mode="asyncio", healthcheck_url="/v1/agl/health", ) server = LightningStoreServer(store_fixture, launcher_args=launcher_args) assert server.launcher_args is launcher_args assert server.server_launcher.args is launcher_args assert server.server_launcher.health_url == f"http://127.0.0.1:{port}/v1/agl/health" await server.start() client = LightningStoreClient(server.endpoint) try: rollout = await client.start_rollout(input={"source": "launcher-args"}) assert rollout.rollout_id finally: await client.close() await server.stop() @pytest.mark.asyncio async def test_server_client_statistics_match(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: """Server and client should report identical statistics.""" server, client = server_client await client.start_rollout(input={"source": "statistics"}) server_stats = await server.statistics() client_stats = await client.statistics() assert {k: v for k, v in server_stats.items() if k != "uptime"} == { k: v for k, v in client_stats.items() if k != "uptime" } assert server_stats["uptime"] < client_stats["uptime"] # type: ignore expected_name = server.store.__class__.__name__ if server.store is not None else server_stats["name"] # type: ignore assert server_stats["name"] == expected_name # type: ignore assert server_stats["total_rollouts"] >= 1 # type: ignore @pytest.mark.asyncio async def test_client_start_rollout_propagates_worker_id( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: server, client = server_client attempt = await client.start_rollout(input={"source": "remote-worker"}, worker_id="client-worker-start") assert attempt.attempt.worker_id == "client-worker-start" worker = await server.get_worker_by_id("client-worker-start") assert worker is not None assert worker.status == "busy" assert worker.current_rollout_id == attempt.rollout_id assert worker.current_attempt_id == attempt.attempt.attempt_id @pytest.mark.asyncio async def test_client_start_attempt_propagates_worker_id( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: server, client = server_client initial = await client.start_rollout(input={"source": "retry-worker"}) retry = await client.start_attempt(initial.rollout_id, worker_id="client-worker-retry") assert retry.attempt.sequence_id == 2 assert retry.attempt.worker_id == "client-worker-retry" worker = await server.get_worker_by_id("client-worker-retry") assert worker is not None assert worker.status == "busy" assert worker.current_rollout_id == retry.rollout_id assert worker.current_attempt_id == retry.attempt.attempt_id @pytest.mark.asyncio async def test_console_metrics_backend_tracks_http_and_store_metrics() -> None: backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=3600.0, group_level=3) await _exercise_server_metrics_backend(backend) counter_metrics = {name for name, _ in backend._counter_state.keys()} # pyright: ignore[reportPrivateUsage] hist_metrics = {name for name, _ in backend._hist_state.keys()} # pyright: ignore[reportPrivateUsage] assert "agl.http.total" in counter_metrics assert "agl.store.total" in counter_metrics assert "agl.http.latency" in hist_metrics @pytest.mark.asyncio @pytest.mark.prometheus async def test_prometheus_metrics_backend_tracks_http_metrics(monkeypatch: pytest.MonkeyPatch) -> None: stub = make_prometheus_stub() monkeypatch.setitem(sys.modules, "prometheus_client", stub) backend = PrometheusMetricsBackend() await _exercise_server_metrics_backend(backend) http_counter = next(inst for inst in stub.counter_instances if inst.name == "agl_http_total") http_histogram = next(inst for inst in stub.histogram_instances if inst.name == "agl_http_latency") assert any(child.value > 0 for child in http_counter.children.values()) assert any(child.values for child in http_histogram.children.values()) @pytest.mark.asyncio @pytest.mark.prometheus async def test_multi_metrics_backend_updates_all_children(monkeypatch: pytest.MonkeyPatch) -> None: stub = make_prometheus_stub() monkeypatch.setitem(sys.modules, "prometheus_client", stub) console_backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=3600.0, group_level=3) prom_backend = PrometheusMetricsBackend() backend = MultiMetricsBackend([console_backend, prom_backend]) await _exercise_server_metrics_backend(backend) console_counters = { name for name, _ in console_backend._counter_state.keys() # pyright: ignore[reportPrivateUsage] } assert "agl.http.total" in console_counters prom_counter = next(inst for inst in stub.counter_instances if inst.name == "agl_http_total") assert any(child.value > 0 for child in prom_counter.children.values()) @pytest.mark.asyncio async def test_client_enqueue_many_rollouts_uses_batch_payload(monkeypatch: MonkeyPatch) -> None: client = LightningStoreClient("http://localhost:9000") captured: Dict[str, Any] = {} async def fake_request_json(_, method: str, path: str, *, json: Any = None, params: Any = None) -> Any: captured.update({"method": method, "path": path, "json": json}) count = len(json["rollouts"]) if json and "rollouts" in json else 0 # type: ignore[index] return [{"rollout_id": f"bulk-{idx}", "input": {"idx": idx}, "start_time": float(idx)} for idx in range(count)] monkeypatch.setattr(LightningStoreClient, "_request_json", fake_request_json, raising=False) # type: ignore requests = [ EnqueueRolloutRequest(input={"idx": 0}, mode="train", metadata={"batch": "left"}), EnqueueRolloutRequest(input={"idx": 1}, resources_id="resources-1"), ] rollouts = await client.enqueue_many_rollouts(requests) assert captured["method"] == "post" assert captured["path"] == "/queues/rollouts/enqueue" assert len(captured["json"]["rollouts"]) == 2 # type: ignore[index] assert captured["json"]["rollouts"][0]["mode"] == "train" # type: ignore[index] assert captured["json"]["rollouts"][1]["resources_id"] == "resources-1" # type: ignore[index] assert len(rollouts) == 2 await client.close() @pytest.mark.asyncio async def test_client_dequeue_methods_share_batch_logic(monkeypatch: MonkeyPatch) -> None: client = LightningStoreClient("http://localhost:9001") def attempt_payload(idx: int) -> Dict[str, Any]: attempt_id = f"attempt-{idx}" rollout_id = f"rollout-{idx}" return { "rollout_id": rollout_id, "input": {"idx": idx}, "start_time": float(idx), "status": "preparing", "attempt": { "rollout_id": rollout_id, "attempt_id": attempt_id, "sequence_id": 1, "start_time": float(idx), "status": "preparing", "worker_id": "batch-worker", }, } payload_queue = [ [attempt_payload(0), attempt_payload(1)], [attempt_payload(0)], ] class FakeResponse: def __init__(self, body: Any): self._body = body self.status = 200 async def __aenter__(self) -> "FakeResponse": return self async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: return None def raise_for_status(self) -> None: return None async def json(self) -> Any: return self._body class RecordingSession: def __init__(self) -> None: self.calls: list[Dict[str, Any]] = [] def post(self, url: str, json: Dict[str, Any]) -> FakeResponse: self.calls.append({"url": url, "json": json}) body = payload_queue.pop(0) return FakeResponse(body) session = RecordingSession() async def fake_get_session() -> RecordingSession: return session monkeypatch.setattr(client, "_get_session", fake_get_session) batch = await client.dequeue_many_rollouts(limit=2, worker_id="batch-worker") assert len(batch) == 2 single = await client.dequeue_rollout(worker_id="batch-worker") assert single is not None assert [call["json"]["limit"] for call in session.calls] == [2, 1] await client.close() @pytest.mark.asyncio async def test_client_dequeue_many_rollouts_skips_network_for_non_positive_limit(monkeypatch: MonkeyPatch) -> None: client = LightningStoreClient("http://localhost:9002") async def fail_get_session() -> None: pytest.fail("Client should not request a session when limit <= 0") monkeypatch.setattr(client, "_get_session", fail_get_session) assert await client.dequeue_many_rollouts(limit=0, worker_id="idle") == [] assert await client.dequeue_many_rollouts(limit=-5) == [] await client.close() @pytest.mark.asyncio async def test_client_concurrent_enqueue_many_rollouts( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client async def enqueue_batch(batch_idx: int) -> list[str]: requests = [EnqueueRolloutRequest(input={"batch": batch_idx, "idx": item}) for item in range(3)] rollouts = await client.enqueue_many_rollouts(requests) return [rollout.rollout_id for rollout in rollouts] batches = await asyncio.gather(*(enqueue_batch(batch_idx) for batch_idx in range(5))) all_ids = {rollout_id for batch in batches for rollout_id in batch} assert len(all_ids) == 15 queried = await client.query_rollouts(limit=-1) assert isinstance(queried, PaginatedResult) assert queried.total >= 15 @pytest.mark.asyncio async def test_client_concurrent_dequeue_many_rollouts( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: server, client = server_client requests = [EnqueueRolloutRequest(input={"idx": idx}) for idx in range(6)] # Seed queue from the server to avoid races with background processing await asyncio.gather(*(server.enqueue_rollout(**req.model_dump()) for req in requests)) async def consume(limit: int, worker: str): return await client.dequeue_many_rollouts(limit=limit, worker_id=worker) batches = await asyncio.gather( consume(3, "worker-a"), consume(3, "worker-b"), ) claimed_ids = {attempt.rollout_id for batch in batches for attempt in batch} assert len(claimed_ids) == 6 assert await client.dequeue_many_rollouts(limit=1) == [] @pytest.mark.asyncio async def test_add_resources_via_server(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: """Test that add_resources works correctly via server.""" server, _ = server_client # Add resources using add_resources llm = LLM( resource_type="llm", endpoint="http://localhost:8080/v1", model="test-model", sampling_parameters={"temperature": 0.7}, ) prompt = PromptTemplate(resource_type="prompt_template", template="Hello {name}!", engine="f-string") resources_update = await server.add_resources(cast(Any, {"main_llm": llm, "greeting": prompt})) # Verify resources_id was auto-generated assert resources_update.resources_id.startswith("rs-") assert len(resources_update.resources_id) == 15 # "rs-" + 12 char hash # Verify resources can be retrieved retrieved = await server.get_resources_by_id(resources_update.resources_id) assert retrieved is not None assert retrieved.resources_id == resources_update.resources_id assert isinstance(retrieved.resources["main_llm"], LLM) assert retrieved.resources["main_llm"].model == "test-model" assert isinstance(retrieved.resources["greeting"], PromptTemplate) assert retrieved.resources["greeting"].template == "Hello {name}!" # Verify it's set as latest latest = await server.get_latest_resources() assert latest is not None assert latest.resources_id == resources_update.resources_id @pytest.mark.asyncio async def test_add_resources_via_client(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: """Test that add_resources works correctly via HTTP client.""" from typing import cast from agentlightning.types import LLM _, client = server_client # Add resources using add_resources via HTTP llm = LLM( resource_type="llm", endpoint="http://localhost:9000/v1", model="client-model", sampling_parameters={"temperature": 0.5}, ) resources_update = await client.add_resources(cast(Any, {"client_llm": llm})) # Verify resources_id was auto-generated assert resources_update.resources_id.startswith("rs-") # Verify resources can be retrieved via client retrieved = await client.get_resources_by_id(resources_update.resources_id) assert retrieved is not None assert isinstance(retrieved.resources["client_llm"], LLM) assert retrieved.resources["client_llm"].model == "client-model" # Verify it's set as latest latest = await client.get_latest_resources() assert latest is not None assert latest.resources_id == resources_update.resources_id @pytest.mark.asyncio async def test_query_resources_history(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: """Server and client should return identical resource history ordering.""" server, client = server_client server_history_empty = await server.query_resources() assert isinstance(server_history_empty, PaginatedResult) assert len(server_history_empty) == 0 client_history_empty = await client.query_resources() assert isinstance(client_history_empty, PaginatedResult) assert len(client_history_empty) == 0 first = await server.add_resources( cast( Any, { "llm": LLM( resource_type="llm", endpoint="http://localhost:8000", model="hist-model-1", sampling_parameters={}, ) }, ) ) second = await server.update_resources( "manual-id", cast( Any, {"prompt": PromptTemplate(resource_type="prompt_template", template="Hi {user}", engine="f-string")}, ), ) server_history = await server.query_resources() client_history = await client.query_resources() expected_ids = [first.resources_id, second.resources_id] assert sorted([item.resources_id for item in server_history]) == sorted(expected_ids) assert sorted([item.resources_id for item in client_history]) == sorted(expected_ids) @pytest.mark.asyncio async def test_client_query_resources_filters_and_pagination( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client alpha = PromptTemplate(resource_type="prompt_template", template="alpha", engine="jinja") beta = PromptTemplate(resource_type="prompt_template", template="beta", engine="jinja") await client.update_resources("manual-alpha", cast(Any, {"prompt": alpha})) await client.update_resources("manual-beta", cast(Any, {"prompt": beta})) contains_beta = await client.query_resources(resources_id_contains="beta") assert [item.resources_id for item in contains_beta] == ["manual-beta"] sorted_ids = sorted(["manual-alpha", "manual-beta"], reverse=True) paged = await client.query_resources(sort_by="resources_id", sort_order="desc", limit=1, offset=1) assert [item.resources_id for item in paged] == sorted_ids[1:2] @pytest.mark.asyncio async def test_client_server_end_to_end( server_client: Tuple[LightningStoreServer, LightningStoreClient], mock_readable_span: ReadableSpan ) -> None: server, client = server_client # Server delegate coverage ------------------------------------------------- await server.update_resources("server-resources", {}) assert await server.get_resources_by_id("server-resources") is not None assert await server.get_latest_resources() is not None server_start_config = RolloutConfig(timeout_seconds=8.5) attempted_server = await server.start_rollout(input={"origin": "server"}, config=server_start_config) assert attempted_server.config.timeout_seconds == 8.5 server_queue_config = RolloutConfig(unresponsive_seconds=4.2, max_attempts=2) queued_rollout = await server.enqueue_rollout(input={"origin": "server-queue"}, config=server_queue_config) assert queued_rollout.config.unresponsive_seconds == 4.2 server_worker_id = "server-worker" dequeued = await server.dequeue_rollout(worker_id=server_worker_id) server_worker_after_dequeue = await server.get_worker_by_id(server_worker_id) assert server_worker_after_dequeue is not None assert server_worker_after_dequeue.status == "busy" # should be busy after dequeue assert server_worker_after_dequeue.last_dequeue_time is not None dequeue_time = server_worker_after_dequeue.last_dequeue_time started_attempt = await server.start_attempt(queued_rollout.rollout_id) await server.query_rollouts() await server.query_attempts(queued_rollout.rollout_id) assert await server.get_latest_attempt(queued_rollout.rollout_id) is not None stored_server_rollout = await server.get_rollout_by_id(queued_rollout.rollout_id) assert stored_server_rollout is not None assert stored_server_rollout.config.unresponsive_seconds == 4.2 assert dequeued is not None server_span = _make_span(dequeued.rollout_id, dequeued.attempt.attempt_id, 0, "server-span") await server.add_span(server_span) assert await server.get_next_span_sequence_id(dequeued.rollout_id, dequeued.attempt.attempt_id) == 1 with patch("agentlightning.store.client_server.Span.from_opentelemetry", autospec=True) as mocked: mocked.side_effect = lambda readable, rollout_id, attempt_id, sequence_id: _make_span( # pyright: ignore[reportUnknownLambdaType] rollout_id, # pyright: ignore[reportUnknownArgumentType] attempt_id, # pyright: ignore[reportUnknownArgumentType] sequence_id, # pyright: ignore[reportUnknownArgumentType] f"server-otel-{sequence_id}", # pyright: ignore[reportUnknownArgumentType] ) await server.add_otel_span(dequeued.rollout_id, dequeued.attempt.attempt_id, mock_readable_span) await server.query_spans(dequeued.rollout_id) await server.update_rollout(queued_rollout.rollout_id, status="running") await server.update_attempt( queued_rollout.rollout_id, started_attempt.attempt.attempt_id, status="running", worker_id=server_worker_id, metadata={"phase": "warmup"}, ) server_worker_busy = await server.get_worker_by_id(server_worker_id) assert server_worker_busy is not None assert server_worker_busy.status == "busy" assert server_worker_busy.current_rollout_id == queued_rollout.rollout_id assert server_worker_busy.current_attempt_id == started_attempt.attempt.attempt_id assert server_worker_busy.last_busy_time is not None assert server_worker_busy.last_busy_time >= dequeue_time await server.update_attempt(queued_rollout.rollout_id, "latest", status="succeeded") server_worker_idle = await server.get_worker_by_id(server_worker_id) assert server_worker_idle is not None assert server_worker_idle.status == "idle" assert server_worker_idle.current_rollout_id is None assert server_worker_idle.current_attempt_id is None assert server_worker_idle.last_idle_time is not None assert server_worker_idle.last_idle_time >= server_worker_busy.last_busy_time completed = await server.wait_for_rollouts(rollout_ids=[queued_rollout.rollout_id], timeout=0.1) assert completed and completed[0].status in {"succeeded", "failed", "cancelled"} # Client HTTP round trip --------------------------------------------------- resource_update = await client.update_resources("client-resources", {}) assert resource_update.resources == {} assert await client.get_resources_by_id("client-resources") is not None assert await client.get_latest_resources() is not None client_start_config = RolloutConfig(timeout_seconds=3.0, retry_condition=["timeout"]) attempted_client = await client.start_rollout( input={"origin": "client"}, mode="train", config=client_start_config, metadata={"step": 0}, ) assert attempted_client.config.timeout_seconds == 3.0 client_queue_config = RolloutConfig(unresponsive_seconds=6.0) enqueued = await client.enqueue_rollout(input={"origin": "client-queue"}, config=client_queue_config) assert enqueued.config.unresponsive_seconds == 6.0 client_worker_id = "client-worker" dequeued_client = await client.dequeue_rollout(worker_id=client_worker_id) assert dequeued_client is not None client_worker_after_dequeue = await client.get_worker_by_id(client_worker_id) assert client_worker_after_dequeue is not None assert client_worker_after_dequeue.status == "busy" # should be busy after dequeue assert client_worker_after_dequeue.last_dequeue_time is not None client_dequeue_time = client_worker_after_dequeue.last_dequeue_time started_client_attempt = await client.start_attempt(dequeued_client.rollout_id) all_rollouts = await client.query_rollouts() assert any(r.rollout_id == enqueued.rollout_id for r in all_rollouts) assert await client.query_rollouts(rollout_ids=[enqueued.rollout_id]) # Test that attempt is present in the rollout assert any(hasattr(r, "attempt") and r.attempt is not None for r in all_rollouts) # type: ignore attempts = await client.query_attempts(dequeued_client.rollout_id) assert attempts assert await client.get_latest_attempt(dequeued_client.rollout_id) is not None stored_client_rollout = await client.get_rollout_by_id(dequeued_client.rollout_id) assert stored_client_rollout is not None assert stored_client_rollout.config.unresponsive_seconds == 6.0 # Test that attempt is present in the rollout assert hasattr(stored_client_rollout, "attempt") and stored_client_rollout.attempt is not None # type: ignore client_span = _make_span(dequeued_client.rollout_id, dequeued_client.attempt.attempt_id, 101, "client-span") stored_span = await client.add_span(client_span) assert stored_span is not None and stored_span.name == "client-span" assert await client.get_next_span_sequence_id(dequeued_client.rollout_id, dequeued_client.attempt.attempt_id) == 102 with patch("agentlightning.store.client_server.Span.from_opentelemetry", autospec=True) as mocked: mocked.side_effect = lambda readable, rollout_id, attempt_id, sequence_id: _make_span( # pyright: ignore[reportUnknownLambdaType] rollout_id, # pyright: ignore[reportUnknownArgumentType] attempt_id, # pyright: ignore[reportUnknownArgumentType] sequence_id, # pyright: ignore[reportUnknownArgumentType] f"client-otel-{sequence_id}", ) await client.add_otel_span(dequeued_client.rollout_id, dequeued_client.attempt.attempt_id, mock_readable_span) spans = await client.query_spans(dequeued_client.rollout_id) assert spans await client.update_rollout(dequeued_client.rollout_id, mode="val", metadata={"step": 1}) await client.update_attempt( dequeued_client.rollout_id, started_client_attempt.attempt.attempt_id, worker_id=client_worker_id, metadata={"info": "started"}, ) client_worker_busy = await client.get_worker_by_id(client_worker_id) assert client_worker_busy is not None assert client_worker_busy.status == "busy" assert client_worker_busy.current_rollout_id == dequeued_client.rollout_id assert client_worker_busy.current_attempt_id == started_client_attempt.attempt.attempt_id assert client_worker_busy.last_busy_time is not None assert client_worker_busy.last_busy_time >= client_dequeue_time await client.update_attempt(dequeued_client.rollout_id, "latest", status="succeeded") await client.update_rollout(dequeued_client.rollout_id, status="succeeded") client_worker_idle = await client.get_worker_by_id(client_worker_id) assert client_worker_idle is not None assert client_worker_idle.status == "idle" assert client_worker_idle.current_rollout_id is None assert client_worker_idle.current_attempt_id is None assert client_worker_idle.last_idle_time is not None assert client_worker_idle.last_idle_time >= client_worker_busy.last_busy_time wait_result = await client.wait_for_rollouts(rollout_ids=[dequeued_client.rollout_id], timeout=0.05) assert wait_result and wait_result[0].status == "succeeded" @pytest.mark.asyncio async def test_client_query_rollouts_filters_and_pagination( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client rollouts = [await client.enqueue_rollout(input={"idx": idx}) for idx in range(3)] await client.update_rollout(rollout_id=rollouts[0].rollout_id, status="failed") failed = await client.query_rollouts(status_in=["failed"]) assert [rollout.rollout_id for rollout in failed] == [rollouts[0].rollout_id] substring = rollouts[2].rollout_id[-4:] contains = await client.query_rollouts(rollout_id_contains=substring) assert any(rollout.rollout_id == rollouts[2].rollout_id for rollout in contains) sorted_ids = sorted([rollout.rollout_id for rollout in rollouts], reverse=True) paged = await client.query_rollouts(sort_by="rollout_id", sort_order="desc", limit=1, offset=1) assert [rollout.rollout_id for rollout in paged] == sorted_ids[1:2] @pytest.mark.asyncio async def test_update_rollout_none_vs_unset(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: _, client = server_client attempted = await client.start_rollout(input={"payload": True}, metadata={"keep": True}) rollout_id = attempted.rollout_id await client.update_rollout(rollout_id, mode="train", metadata={"extra": 1}) updated = await client.get_rollout_by_id(rollout_id) assert updated is not None assert updated.mode == "train" assert updated.metadata is not None assert updated.metadata["extra"] == 1 await client.update_rollout(rollout_id, mode=None, metadata={"extra1": 2}) cleared = await client.get_rollout_by_id(rollout_id) assert cleared is not None assert cleared.mode is None assert cleared.metadata is not None assert cleared.metadata == {"extra1": 2} await client.update_rollout(rollout_id, mode=UNSET, metadata=UNSET, status="running") preserved = await client.get_rollout_by_id(rollout_id) assert preserved is not None assert preserved.mode is None assert preserved.metadata == {"extra1": 2} assert preserved.status == "running" @pytest.mark.asyncio @pytest.mark.parametrize( "bad_payload", [ {"status": None}, {"config": None}, ], ) async def test_update_rollout_rejects_none_values( server_client: Tuple[LightningStoreServer, LightningStoreClient], bad_payload: Dict[str, Any], ) -> None: _, client = server_client attempted = await client.start_rollout(input={"payload": "bad-none"}) with pytest.raises((ClientResponseError, AttributeError)) as exc_info: await client.update_rollout(attempted.rollout_id, **bad_payload) if isinstance(exc_info.value, ClientResponseError): assert exc_info.value.status == 400 @pytest.mark.asyncio async def test_update_attempt_none_vs_unset(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: _, client = server_client attempted = await client.start_rollout(input={"payload": True}) rollout_id = attempted.rollout_id attempt_id = attempted.attempt.attempt_id await client.update_attempt(rollout_id, attempt_id, worker_id="worker-1", metadata={"stage": "init"}) initial = await client.get_latest_attempt(rollout_id) assert initial is not None assert initial.worker_id == "worker-1" assert initial.metadata is not None assert initial.metadata["stage"] == "init" await client.update_attempt(rollout_id, "latest", worker_id="", metadata={}) cleared = await client.get_latest_attempt(rollout_id) assert cleared is not None assert cleared.worker_id == "" assert cleared.metadata == {} await client.update_attempt(rollout_id, "latest", status="running", worker_id=UNSET, metadata=UNSET) preserved = await client.get_latest_attempt(rollout_id) assert preserved is not None assert preserved.worker_id == "" assert preserved.metadata == {} assert preserved.status == "running" @pytest.mark.asyncio async def test_update_worker_records_heartbeat( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client first = await client.update_worker("runner-1", heartbeat_stats={"cpu": 0.4}) assert first.status == "unknown" assert first.heartbeat_stats == {"cpu": 0.4} assert first.last_heartbeat_time is not None second = await client.update_worker("runner-1") assert second.last_heartbeat_time is not None assert second.last_heartbeat_time >= first.last_heartbeat_time assert second.heartbeat_stats == {"cpu": 0.4} @pytest.mark.asyncio async def test_update_worker_rejects_none_stats( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client with pytest.raises(ClientResponseError) as exc_info: await client.update_worker("runner-err", heartbeat_stats=cast(Any, None)) assert exc_info.value.status == 400 @pytest.mark.asyncio async def test_worker_status_transitions_via_attempts( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client await client.enqueue_rollout(input={"payload": "worker"}) claimed = await client.dequeue_rollout(worker_id="runner-auto") assert claimed is not None await client.update_attempt(claimed.rollout_id, claimed.attempt.attempt_id, worker_id="runner-auto") busy = await client.get_worker_by_id("runner-auto") assert busy is not None assert busy.status == "busy" assert busy.current_rollout_id == claimed.rollout_id assert busy.current_attempt_id == claimed.attempt.attempt_id assert busy.last_dequeue_time is not None assert busy.last_busy_time is not None await client.update_attempt(claimed.rollout_id, claimed.attempt.attempt_id, status="succeeded") idle = await client.get_worker_by_id("runner-auto") assert idle is not None assert idle.status == "idle" assert idle.current_rollout_id is None assert idle.current_attempt_id is None @pytest.mark.asyncio async def test_client_query_workers_filters(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: _, client = server_client await client.update_worker("alpha-worker", heartbeat_stats={"cpu": 0.2}) await client.update_worker("beta-worker", heartbeat_stats={"cpu": 0.8}) busy_rollout = await client.start_rollout(input={"worker": "alpha"}) await client.update_attempt( busy_rollout.rollout_id, busy_rollout.attempt.attempt_id, worker_id="alpha-worker", status="running", ) busy_workers = await client.query_workers(status_in=["busy"]) assert [worker.worker_id for worker in busy_workers] == ["alpha-worker"] contains_beta = await client.query_workers(worker_id_contains="beta") assert [worker.worker_id for worker in contains_beta] == ["beta-worker"] or_filtered = await client.query_workers( status_in=["busy"], worker_id_contains="beta", filter_logic="or", sort_by="worker_id", sort_order="asc", ) assert [worker.worker_id for worker in or_filtered] == ["alpha-worker", "beta-worker"] @pytest.mark.asyncio async def test_get_worker_by_id(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None: server, client = server_client await server.update_worker("runner-lookup", heartbeat_stats={"cpu": 0.3}) server_worker = await server.get_worker_by_id("runner-lookup") assert server_worker is not None assert server_worker.worker_id == "runner-lookup" assert await server.get_worker_by_id("missing") is None client_worker = await client.get_worker_by_id("runner-lookup") assert client_worker is not None assert client_worker.worker_id == "runner-lookup" assert await client.get_worker_by_id("missing") is None @pytest.mark.asyncio @pytest.mark.parametrize( "bad_payload", [ {"last_heartbeat_time": None}, {"metadata": None}, ], ) async def test_update_attempt_rejects_none_values( server_client: Tuple[LightningStoreServer, LightningStoreClient], bad_payload: Dict[str, Any], ) -> None: _, client = server_client attempted = await client.start_rollout(input={"payload": "bad-none-attempt"}) with pytest.raises(ClientResponseError) as exc_info: await client.update_attempt(attempted.rollout_id, attempted.attempt.attempt_id, **bad_payload) assert exc_info.value.status == 400 @pytest.mark.asyncio async def test_client_query_spans_filters_and_pagination( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: server, client = server_client attempted = await server.start_rollout(input={"span": "filters"}) attempt_id = attempted.attempt.attempt_id spans = [ _make_span(attempted.rollout_id, attempt_id, 1, "planner"), _make_span(attempted.rollout_id, attempt_id, 2, "reward"), _make_span(attempted.rollout_id, attempt_id, 3, "tool-call"), ] for span in spans: await server.add_span(span) planner = await client.query_spans(attempted.rollout_id, attempt_id=attempt_id, name_contains="plan") assert [span.name for span in planner] == ["planner"] or_filtered = await client.query_spans( attempted.rollout_id, attempt_id=attempt_id, span_id=spans[0].span_id, trace_id_contains=spans[2].trace_id[-4:], filter_logic="or", ) assert {span.span_id for span in or_filtered} == {spans[0].span_id, spans[2].span_id} paged = await client.query_spans( attempted.rollout_id, attempt_id=attempt_id, sort_by="sequence_id", sort_order="desc", limit=1, offset=1, ) assert [span.span_id for span in paged] == [spans[1].span_id] @pytest.mark.asyncio async def test_server_get_many_span_sequence_ids_and_add_many_spans_mixed_batches( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: server, _ = server_client first = await server.start_rollout(input={"origin": "batch-server"}) second = await server.start_rollout(input={"origin": "batch-server-2"}) await server.update_rollout(first.rollout_id, status="requeuing") retried = await server.start_attempt(first.rollout_id) sequence_pairs = [ (first.rollout_id, first.attempt.attempt_id), (second.rollout_id, second.attempt.attempt_id), (first.rollout_id, retried.attempt.attempt_id), (first.rollout_id, first.attempt.attempt_id), ] sequence_ids = await server.get_many_span_sequence_ids(sequence_pairs) assert sequence_ids == [1, 1, 2, 3] next_single = await server.get_next_span_sequence_id(first.rollout_id, first.attempt.attempt_id) assert next_single == 4 batch_spans = [ _make_span(first.rollout_id, first.attempt.attempt_id, 10, "server-batch-1"), _make_span(second.rollout_id, second.attempt.attempt_id, 11, "server-batch-2"), _make_span(retried.rollout_id, retried.attempt.attempt_id, 12, "server-batch-retry"), ] stored_spans = await server.add_many_spans(batch_spans) assert {span.name for span in stored_spans} == { "server-batch-1", "server-batch-2", "server-batch-retry", } spans_first = await server.query_spans(first.rollout_id) assert any(span.name == "server-batch-1" for span in spans_first) assert any(span.name == "server-batch-retry" for span in spans_first) spans_second = await server.query_spans(second.rollout_id) assert any(span.name == "server-batch-2" for span in spans_second) @pytest.mark.asyncio async def test_client_handles_optional_span_results_and_batch_insert( server_client: Tuple[LightningStoreServer, LightningStoreClient], mock_readable_span: ReadableSpan, ) -> None: _, client = server_client first = await client.start_rollout(input={"origin": "client-span"}) second = await client.start_rollout(input={"origin": "client-span-2"}) first_attempt_id = first.attempt.attempt_id second_attempt_id = second.attempt.attempt_id base_span = _make_span(first.rollout_id, first_attempt_id, 1, "client-span-1") stored = await client.add_span(base_span) assert stored is not None assert await client.add_span(base_span) is None batch_spans = [ _make_span(first.rollout_id, first_attempt_id, 2, "client-span-2"), _make_span(second.rollout_id, second_attempt_id, 1, "client-span-other"), base_span, ] inserted = await client.add_many_spans(batch_spans) assert [span.name for span in inserted] == ["client-span-2", "client-span-other"] sequence_ids = await client.get_many_span_sequence_ids( [ (first.rollout_id, first_attempt_id), (second.rollout_id, second_attempt_id), (first.rollout_id, "latest"), ] ) assert sequence_ids == [3, 2, 4] with patch("agentlightning.store.client_server.Span.from_opentelemetry", autospec=True) as mocked_span_factory: mocked_span_factory.return_value = base_span assert await client.add_otel_span(first.rollout_id, first_attempt_id, mock_readable_span) is None @pytest.mark.asyncio async def test_concurrent_add_otel_span_sequence_ids_unique( server_client: Tuple[LightningStoreServer, LightningStoreClient], mock_readable_span: ReadableSpan ) -> None: _, client = server_client attempted = await client.start_rollout(input={"payload": True}) rollout_id = attempted.rollout_id attempt_id = attempted.attempt.attempt_id def _build_concurrent_span(readable: ReadableSpan, rollout_id: str, attempt_id: str, sequence_id: int) -> Span: return _make_span(rollout_id, attempt_id, sequence_id, f"concurrent-{sequence_id}") with patch("agentlightning.store.client_server.Span.from_opentelemetry", autospec=True) as mocked: mocked.side_effect = _build_concurrent_span spans = await asyncio.gather( *[client.add_otel_span(rollout_id, attempt_id, mock_readable_span) for _ in range(20)] ) sequence_ids = [span.sequence_id for span in spans] # type: ignore assert len(set(sequence_ids)) == 20 assert set(sequence_ids) == set(range(1, 21)) stored_spans = await client.query_spans(rollout_id, attempt_id="latest") assert len(stored_spans) >= 2 @pytest.mark.asyncio async def test_subprocess_operations_sync_via_http_automatically() -> None: """ Test that LightningStoreServer automatically uses HTTP client in subprocesses. When LightningStoreServer is passed to a subprocess, it detects it's in a different process (via PID tracking) and automatically delegates to an HTTP client instead of the local store. This ensures operations in the subprocess are reflected in the main process via the HTTP server. """ store = InMemoryLightningStore() port = pick_unused_port() server = LightningStoreServer(store, "127.0.0.1", port) await server.start() try: # Record initial state initial_rollouts = await store.query_rollouts() initial_count = len(initial_rollouts) def subprocess_work(server_obj: LightningStoreServer) -> None: """Subprocess that performs operations via the server object.""" async def do_work() -> None: # The server detects we're in a different process and automatically # uses HTTP client to communicate with the main process server await server_obj.enqueue_rollout(input={"origin": "subprocess"}) asyncio.run(do_work()) # Spawn a subprocess to perform operations ctx = multiprocessing.get_context() process = ctx.Process(target=subprocess_work, args=(server,)) process.start() await asyncio.to_thread(process.join, timeout=5.0) assert process.exitcode == 0 # Allow time for HTTP request to complete await asyncio.sleep(0.2) # Subprocess operations ARE reflected in main process store # because the server automatically used HTTP client in the subprocess main_process_rollouts = await store.query_rollouts() assert len(main_process_rollouts) == initial_count + 1, ( "Subprocess operations should be reflected in main process store " "via automatic HTTP client delegation" ) finally: await server.stop() @pytest.mark.asyncio async def test_subprocess_client_operations_work_but_direct_store_access_fails() -> None: """ Demonstrate that: 1. Client operations via HTTP work correctly (data persists in main process) 2. Direct store access in subprocess does NOT work (data isolated to subprocess) """ store = InMemoryLightningStore() port = pick_unused_port() server = LightningStoreServer(store, "127.0.0.1", port) await server.start() try: initial_rollouts = await store.query_rollouts() initial_count = len(initial_rollouts) def subprocess_client_work(endpoint: str) -> None: """Subprocess using HTTP client - this WORKS.""" async def do_work() -> None: client = LightningStoreClient(endpoint) try: await client.enqueue_rollout(input={"origin": "subprocess-client"}) except Exception as e: print(f"Client subprocess error: {e}", file=sys.stderr) raise finally: await client.close() asyncio.run(do_work()) def subprocess_direct_store_work(server_obj: LightningStoreServer) -> None: """Subprocess using direct store access - this does NOT work.""" async def do_work() -> None: # This operates on the subprocess's copy of the store await server_obj.enqueue_rollout(input={"origin": "subprocess-direct"}) asyncio.run(do_work()) # Test 1: Client operations via HTTP - should work ctx = multiprocessing.get_context() client_process = ctx.Process(target=subprocess_client_work, args=(server.endpoint,)) client_process.start() await asyncio.to_thread(client_process.join, timeout=5.0) # Add timeout # Handle timeout case if client_process.is_alive(): client_process.terminate() client_process.join(timeout=1.0) pytest.fail("Client subprocess hung and had to be terminated") assert client_process.exitcode == 0, f"Client subprocess failed with exit code {client_process.exitcode}" await asyncio.sleep(0.2) after_client = await store.query_rollouts() # Client operations WORK - the rollout is in the main process store assert len(after_client) == initial_count + 1 # Test 2: Server object in subprocess - ALSO works now (auto-delegates to HTTP) direct_process = ctx.Process(target=subprocess_direct_store_work, args=(server,)) direct_process.start() await asyncio.to_thread(direct_process.join, timeout=5.0) # Handle timeout case if direct_process.is_alive(): direct_process.terminate() direct_process.join(timeout=1.0) pytest.fail("Server subprocess hung and had to be terminated") assert direct_process.exitcode == 0, f"Server subprocess failed with exit code {direct_process.exitcode}" await asyncio.sleep(0.2) after_direct = await store.query_rollouts() # With the fix: server object in subprocess ALSO works via auto HTTP delegation # Both rollouts (client + server) should be in the store assert ( len(after_direct) == initial_count + 2 ), "Both explicit client and server object operations should work via HTTP" finally: await server.stop() @pytest.mark.asyncio @pytest.mark.flaky(reruns=3, reruns_delay=2) async def test_retry_on_400_application_error( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch, ) -> None: """Test that client retries on app-side 400 that becomes a 500 due to server exception handling.""" server, client = server_client # Force app-side exception so server returns 400 via exception handler. call_count = {"n": 0} original = server.store.enqueue_rollout # type: ignore async def boom(*args: Any, **kwargs: Any) -> Any: call_count["n"] += 1 raise RuntimeError("synthetic app error") monkeypatch.setattr(server.store, "enqueue_rollout", boom, raising=True) with pytest.raises(ClientResponseError) as ei: await client.enqueue_rollout(input={"origin": "should-fail"}) assert ei.value.status == 500 assert call_count["n"] == 4 # Restore original method monkeypatch.setattr(server.store, "enqueue_rollout", original, raising=True) @pytest.mark.asyncio async def test_no_retry_on_non408_4xx( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch, ) -> None: """Test that client does not retry on non-408 4xx errors such as 404.""" _, client = server_client original_post = aiohttp.ClientSession.post calls = {"n": 0} def post_404(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any): if str(url).endswith("/rollouts/nonexistent"): calls["n"] += 1 req_info = aiohttp.RequestInfo( url=URL(str(url)), method="POST", headers=cast(Any, {}), real_url=URL(str(url)), ) raise ClientResponseError( request_info=req_info, history=(), status=404, message="not found", ) return MockResponse(original_post(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", post_404, raising=True) with pytest.raises(ClientResponseError) as ei: await client.update_rollout("nonexistent", status="running") assert ei.value.status == 404 assert calls["n"] == 1 @pytest.mark.asyncio @pytest.mark.parametrize("exc_cls", [ServerDisconnectedError, asyncio.TimeoutError]) async def test_retry_on_transient_network_errors_then_success( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch, exc_cls: type[Exception] ) -> None: _server, client = server_client original_post = aiohttp.ClientSession.post counters = {"post_calls": 0} def flaky_post(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): if counters["post_calls"] == 0: counters["post_calls"] += 1 # raise chosen transient network exception raise exc_cls() return MockResponse(original_post(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", flaky_post, raising=True) attempted = await client.start_rollout(input={"origin": f"retry-ok-{exc_cls.__name__}"}) assert attempted.rollout_id assert counters["post_calls"] == 1 @pytest.mark.asyncio @pytest.mark.parametrize("status", [500, 408]) async def test_retry_on_transient_http_status_then_success( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch, status: int ) -> None: _server, client = server_client original_post = aiohttp.ClientSession.post fired = {"once": False} def post_then_ok(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/queues/rollouts/enqueue") and not fired["once"]: fired["once"] = True req_info = aiohttp.RequestInfo( url=URL(str(url)), method="POST", headers=cast(Any, {}), real_url=URL(str(url)) ) raise ClientResponseError(request_info=req_info, history=(), status=status, message="transient") return MockResponse(original_post(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", post_then_ok, raising=True) res = await client.enqueue_rollout(input={"origin": f"after-{status}"}) assert res.rollout_id assert fired["once"] is True @pytest.mark.asyncio async def test_unhealthy_health_probe_stops_retries( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch ) -> None: _server, client = server_client original_post = aiohttp.ClientSession.post original_get = aiohttp.ClientSession.get post_calls = {"n": 0} def failing_post(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): post_calls["n"] += 1 raise ServerDisconnectedError("synthetic disconnect") return MockResponse(original_post(self, url, *args, **kwargs)) def failing_health_get(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/health"): # health never becomes available raise ClientConnectorError(connection_key=None, os_error=None) # type: ignore return MockResponse(original_get(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", failing_post, raising=True) monkeypatch.setattr(aiohttp.ClientSession, "get", failing_health_get, raising=True) with pytest.raises(ServerDisconnectedError): await client.start_rollout(input={"origin": "unhealthy"}) # Only the initial attempt, since health checks fail assert post_calls["n"] == 1 @pytest.mark.asyncio async def test_wait_for_rollouts_timeout_guard_raises( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: _, client = server_client with pytest.raises(ValueError): await client.wait_for_rollouts(rollout_ids=["dummy"], timeout=0.2) @pytest.mark.asyncio async def test_retry_mechanism_with_custom_delays_and_health_recovery( monkeypatch: MonkeyPatch, ) -> None: """ Test the complete retry mechanism including: - Custom retry delays are respected - Health checks are performed between retries - Multiple failures are recovered from - Final success after health recovery """ store = InMemoryLightningStore() port = pick_unused_port() server = LightningStoreServer(store, "127.0.0.1", port) await server.start() # Client with custom short delays for faster testing client = LightningStoreClient( server.endpoint, retry_delays=(0.01, 0.02), health_retry_delays=(0.01,), ) try: original_post = aiohttp.ClientSession.post original_get = aiohttp.ClientSession.get counters = {"post_attempts": 0, "health_checks": 0} timestamps: list[float] = [] def monitored_post(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): import time counters["post_attempts"] += 1 timestamps.append(time.time()) # Fail first 2 attempts with network error if counters["post_attempts"] <= 2: raise ServerDisconnectedError(f"synthetic disconnect #{counters['post_attempts']}") return MockResponse(original_post(self, url, *args, **kwargs)) def monitored_get(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/health"): counters["health_checks"] += 1 return MockResponse(original_get(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", monitored_post, raising=True) monkeypatch.setattr(aiohttp.ClientSession, "get", monitored_get, raising=True) # Execute request that will fail twice then succeed attempted = await client.start_rollout(input={"origin": "retry-test"}) # Verify success assert attempted.rollout_id is not None # Verify retry behavior assert counters["post_attempts"] == 3, "Should make initial attempt + 2 retries" assert counters["health_checks"] >= 2, "Should check health after each network failure" # Verify delays were respected (timestamps should be spaced) if len(timestamps) >= 3: delay1 = timestamps[1] - timestamps[0] delay2 = timestamps[2] - timestamps[1] # First delay should be ~0.01s (first retry_delay) assert delay1 >= 0.01 and delay1 < 0.05, "First retry delay not respected" # Second delay should be ~0.02s (second retry_delay) assert delay2 >= 0.02 and delay2 < 0.06, "Second retry delay not respected" finally: await client.close() await server.stop() @pytest.mark.asyncio async def test_client_response_error_with_different_status_codes( server_client: Tuple[LightningStoreServer, LightningStoreClient], monkeypatch: MonkeyPatch, ) -> None: """ Test that client handles different HTTP status codes correctly: - 400-499 (except 408): no retry, immediate failure - 408: retry (request timeout is transient) - 500-599: retry (server errors are transient) """ _server, client = server_client original_post = aiohttp.ClientSession.post # Test 403 Forbidden - should NOT retry def post_403(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): req_info = aiohttp.RequestInfo( url=URL(str(url)), method="POST", headers=cast(Any, {}), real_url=URL(str(url)) ) raise ClientResponseError(request_info=req_info, history=(), status=403, message="forbidden") return MockResponse(original_post(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", post_403, raising=True) with pytest.raises(ClientResponseError) as exc_info: await client.start_rollout(input={"test": "403"}) assert exc_info.value.status == 403 # Test 503 Service Unavailable - should retry and succeed call_count = {"n": 0} def post_503_then_ok(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/queues/rollouts/enqueue"): call_count["n"] += 1 if call_count["n"] == 1: req_info = aiohttp.RequestInfo( url=URL(str(url)), method="POST", headers=cast(Any, {}), real_url=URL(str(url)) ) raise ClientResponseError(request_info=req_info, history=(), status=503, message="service unavailable") return MockResponse(original_post(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", post_503_then_ok, raising=True) result = await client.enqueue_rollout(input={"test": "503"}) assert result.rollout_id is not None assert call_count["n"] == 2 # Failed once, then succeeded @pytest.mark.asyncio async def test_get_next_span_sequence_id_returns_proper_int( server_client: Tuple[LightningStoreServer, LightningStoreClient], ) -> None: """Test that get_next_span_sequence_id correctly converts JSON number to int.""" _, client = server_client attempted = await client.start_rollout(input={"test": True}) # First call should return 1 seq_id_1 = await client.get_next_span_sequence_id(attempted.rollout_id, attempted.attempt.attempt_id) assert isinstance(seq_id_1, int) assert seq_id_1 == 1 # Second call should return 2 seq_id_2 = await client.get_next_span_sequence_id(attempted.rollout_id, attempted.attempt.attempt_id) assert isinstance(seq_id_2, int) assert seq_id_2 == 2 # Verify monotonic increment assert seq_id_2 == seq_id_1 + 1 @pytest.mark.asyncio async def test_empty_retry_delays_disable_retries(monkeypatch: MonkeyPatch) -> None: """ When retry_delays is empty, the client should perform only the initial attempt and not retry on transient network errors. """ store = InMemoryLightningStore() port = pick_unused_port() server = LightningStoreServer( store, launcher_args=PythonServerLauncherArgs( port=port, host="127.0.0.1", healthcheck_url=None, launch_mode="thread", ), ) await server.start() # retry_delays=() disables retries; health checks still enabled client = LightningStoreClient( server.endpoint, retry_delays=(), health_retry_delays=(0.01,), ) try: original_post = aiohttp.ClientSession.post original_get = aiohttp.ClientSession.get calls = {"post": 0, "health": 0} def failing_post(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): calls["post"] += 1 # Always raise a transient error raise ServerDisconnectedError("synthetic disconnect for empty retry_delays") return MockResponse(original_post(self, url, *args, **kwargs)) def ok_health_get(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/health"): calls["health"] += 1 # delegate to the real get() and wrap in MockResponse so it stays an async CM return MockResponse(original_get(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", failing_post, raising=True) monkeypatch.setattr(aiohttp.ClientSession, "get", ok_health_get, raising=True) with pytest.raises(ServerDisconnectedError): await client.start_rollout(input={"origin": "empty-retry-delays"}) # Only the initial attempt should be made assert calls["post"] == 1 # Health should be probed at least once assert calls["health"] >= 1 finally: await client.close() await server.stop() @pytest.mark.asyncio async def test_empty_health_retry_delays_skip_health_checks(monkeypatch: MonkeyPatch) -> None: """ When health_retry_delays is empty, _wait_until_healthy should not perform any /health probes, but retries governed by retry_delays should still occur. """ store = InMemoryLightningStore() port = pick_unused_port() server = LightningStoreServer( store, launcher_args=PythonServerLauncherArgs( port=port, host="127.0.0.1", healthcheck_url=None, launch_mode="thread", ), ) await server.start() # health_retry_delays=() disables health probes; still allow one retry client = LightningStoreClient( server.endpoint, retry_delays=(0.01,), health_retry_delays=(), ) try: original_post = aiohttp.ClientSession.post original_get = aiohttp.ClientSession.get calls = {"post": 0, "health": 0} def flaky_post(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/rollouts"): calls["post"] += 1 # First call fails, second succeeds if calls["post"] == 1: raise ServerDisconnectedError("synthetic disconnect for empty health_retry_delays") return MockResponse(original_post(self, url, *args, **kwargs)) def counting_health_get(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse: if str(url).endswith("/health"): calls["health"] += 1 return MockResponse(original_get(self, url, *args, **kwargs)) monkeypatch.setattr(aiohttp.ClientSession, "post", flaky_post, raising=True) monkeypatch.setattr(aiohttp.ClientSession, "get", counting_health_get, raising=True) # Should succeed after one retry, without ever calling /health attempted = await client.start_rollout(input={"origin": "empty-health-delays"}) assert attempted.rollout_id # One failure + one success assert calls["post"] == 2 # No health checks should have been performed assert calls["health"] == 0 finally: await client.close() await server.stop()