Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

1696 lines
68 KiB
Python

# 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()