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
1696 lines
68 KiB
Python
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()
|