# Copyright (c) Microsoft. All rights reserved. import asyncio import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, cast from unittest.mock import MagicMock import pytest from opentelemetry.sdk.trace import ReadableSpan from agentlightning.store.base import UNSET, LightningStore from agentlightning.store.memory import InMemoryLightningStore from agentlightning.store.threading import LightningStoreThreaded from agentlightning.types import ( LLM, Attempt, AttemptedRollout, AttemptStatus, EnqueueRolloutRequest, NamedResources, OtelResource, ResourcesUpdate, Rollout, Span, SpanContext, TaskInput, TraceStatus, Worker, ) from .dummy_store import DummyLightningStore pytestmark = [pytest.mark.store] class SlowAttemptStore(LightningStore): def __init__(self) -> None: super().__init__() self.active_calls = 0 self.max_active_calls = 0 self.sequence = 0 async def update_attempt( self, rollout_id: str, attempt_id: str, status: AttemptStatus | Any = UNSET, worker_id: str | Any = UNSET, last_heartbeat_time: float | Any = UNSET, metadata: Dict[str, Any] | Any = UNSET, ) -> Attempt: self.active_calls += 1 self.max_active_calls = max(self.max_active_calls, self.active_calls) await asyncio.sleep(0.01) self.active_calls -= 1 self.sequence += 1 status_value = status if status is not UNSET else "preparing" worker_value = worker_id if worker_id is not UNSET else None heartbeat_value = last_heartbeat_time if last_heartbeat_time is not UNSET else None metadata_value = metadata if metadata is not UNSET else {} return Attempt( rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=self.sequence, start_time=float(self.sequence), status=status_value, worker_id=worker_value, last_heartbeat_time=heartbeat_value, metadata=metadata_value, ) class IncrementingResourceStore(LightningStore): def __init__(self) -> None: super().__init__() self.counter = 0 async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate: snapshot = self.counter await asyncio.sleep(0.01) self.counter = snapshot + 1 return ResourcesUpdate( resources_id=f"res-{self.counter}", resources=resources, create_time=time.time(), update_time=time.time(), version=1, ) def make_span(rollout_id: str, attempt_id: str, sequence_id: int = 1) -> Span: return Span( rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=sequence_id, trace_id="0" * 32, span_id="0" * 16, parent_id=None, name="test-span", status=TraceStatus(status_code="OK"), attributes={}, events=[], links=[], start_time=None, end_time=None, context=SpanContext(trace_id="0" * 32, span_id="0" * 16, is_remote=False, trace_state={}), parent=None, resource=OtelResource(attributes={}, schema_url=""), ) @pytest.mark.asyncio async def test_threaded_store_delegates_all_methods() -> None: task_input: TaskInput = {"foo": "bar"} rollout_id = "rollout-1" attempt_id = "attempt-1" base_attempt = Attempt( rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=1, start_time=0.0, ) base_rollout = Rollout( rollout_id=rollout_id, input=task_input, start_time=0.0, mode="train", ) attempted_rollout = AttemptedRollout( rollout_id=rollout_id, input=task_input, start_time=0.0, mode="train", status="preparing", metadata={}, attempt=base_attempt, ) resources_update = ResourcesUpdate( resources_id="resources-1", resources={}, create_time=time.time(), update_time=time.time(), version=1 ) span = make_span(rollout_id, attempt_id) readable_span = MagicMock(spec=ReadableSpan) updated_rollout = Rollout( rollout_id=rollout_id, input=task_input, start_time=1.0, mode="val", status="succeeded", resources_id="resources-2", metadata={"note": "done"}, ) updated_attempt = Attempt( rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=2, start_time=1.0, status="running", worker_id="worker-1", last_heartbeat_time=1.5, metadata={"idx": 0}, ) worker_list = [Worker(worker_id="worker-1", status="busy")] updated_worker = Worker(worker_id="worker-1", status="idle") return_values = { "start_rollout": attempted_rollout, "enqueue_rollout": base_rollout, "dequeue_rollout": attempted_rollout, "start_attempt": attempted_rollout, "query_rollouts": [base_rollout], "query_attempts": [base_attempt], "get_rollout_by_id": base_rollout, "get_latest_attempt": base_attempt, "update_resources": resources_update, "get_resources_by_id": resources_update, "get_latest_resources": resources_update, "add_span": span, "add_many_spans": [span], "add_otel_span": span, "wait_for_rollouts": [base_rollout], "get_next_span_sequence_id": 42, "get_many_span_sequence_ids": [101], "query_spans": [span], "update_rollout": updated_rollout, "update_attempt": updated_attempt, "query_workers": worker_list, "get_worker_by_id": worker_list[0], "update_worker": updated_worker, } dummy_store = DummyLightningStore(return_values) threaded_store = LightningStoreThreaded(dummy_store) assert ( await threaded_store.start_rollout(task_input, mode="train", resources_id="resources-1", metadata={"a": 1}) == attempted_rollout ) assert ( await threaded_store.enqueue_rollout(task_input, mode="train", resources_id="resources-1", metadata={"b": 2}) == base_rollout ) assert await threaded_store.dequeue_rollout() == attempted_rollout assert await threaded_store.start_attempt(rollout_id) == attempted_rollout assert await threaded_store.query_rollouts(status=["preparing", "running"], rollout_ids=[rollout_id]) == [ base_rollout ] assert await threaded_store.query_attempts(rollout_id) == [base_attempt] assert await threaded_store.get_rollout_by_id(rollout_id) == base_rollout assert await threaded_store.get_latest_attempt(rollout_id) == base_attempt assert await threaded_store.update_resources("resources-1", {}) == resources_update assert await threaded_store.get_resources_by_id("resources-1") == resources_update assert await threaded_store.get_latest_resources() == resources_update assert await threaded_store.add_span(span) == span assert await threaded_store.add_many_spans([span]) == [span] assert await threaded_store.add_otel_span(rollout_id, attempt_id, readable_span, sequence_id=5) == span assert await threaded_store.wait_for_rollouts(rollout_ids=[rollout_id], timeout=1.0) == [base_rollout] assert await threaded_store.get_next_span_sequence_id(rollout_id, attempt_id) == 42 assert await threaded_store.get_many_span_sequence_ids([(rollout_id, attempt_id)]) == [101] assert await threaded_store.query_spans(rollout_id, attempt_id="latest") == [span] assert ( await threaded_store.update_rollout( rollout_id, input=task_input, mode="val", resources_id="resources-2", status="succeeded", metadata={"note": "done"}, ) == updated_rollout ) assert ( await threaded_store.update_attempt( rollout_id, attempt_id, status="running", worker_id="worker-1", last_heartbeat_time=1.5, metadata={"idx": 0}, ) == updated_attempt ) assert await threaded_store.query_workers() == worker_list assert await threaded_store.get_worker_by_id("worker-1") == worker_list[0] assert await threaded_store.update_worker("worker-1", heartbeat_stats={"cpu": 0.5}) == updated_worker expected_order = [ "start_rollout", "enqueue_rollout", "dequeue_rollout", "start_attempt", "query_rollouts", "query_attempts", "get_rollout_by_id", "get_latest_attempt", "update_resources", "get_resources_by_id", "get_latest_resources", "add_span", "add_many_spans", "add_otel_span", "wait_for_rollouts", "get_next_span_sequence_id", "get_many_span_sequence_ids", "query_spans", "update_rollout", "update_attempt", "query_workers", "get_worker_by_id", "update_worker", ] assert [name for name, *_ in dummy_store.calls] == expected_order @pytest.mark.asyncio async def test_threaded_store_enqueue_many_rollouts_delegates() -> None: requests = [ EnqueueRolloutRequest(input={"idx": 0}, mode="train", metadata={"batch": "left"}), EnqueueRolloutRequest(input={"idx": 1}, mode=None, resources_id="resources-1"), ] rollouts = [ Rollout(rollout_id="bulk-0", input={"idx": 0}, start_time=0.0), Rollout(rollout_id="bulk-1", input={"idx": 1}, start_time=1.0), ] dummy_store = DummyLightningStore({"enqueue_many_rollouts": rollouts}) threaded_store = LightningStoreThreaded(dummy_store) result = await threaded_store.enqueue_many_rollouts(requests) assert result == rollouts assert dummy_store.calls[-1][0] == "enqueue_many_rollouts" assert dummy_store.calls[-1][1][0] == requests @pytest.mark.asyncio async def test_threaded_store_dequeue_many_rollouts_delegates() -> None: attempt_a = Attempt(rollout_id="bulk-0", attempt_id="attempt-0", sequence_id=1, start_time=0.0) attempt_b = Attempt(rollout_id="bulk-1", attempt_id="attempt-1", sequence_id=1, start_time=0.0) attempts = [ AttemptedRollout(rollout_id="bulk-0", input={"idx": 0}, start_time=0.0, attempt=attempt_a), AttemptedRollout(rollout_id="bulk-1", input={"idx": 1}, start_time=0.0, attempt=attempt_b), ] dummy_store = DummyLightningStore({"dequeue_many_rollouts": attempts}) threaded_store = LightningStoreThreaded(dummy_store) result = await threaded_store.dequeue_many_rollouts(limit=2, worker_id="thread-worker") assert result == attempts assert dummy_store.calls[-1][0] == "dequeue_many_rollouts" assert dummy_store.calls[-1][2] == {"limit": 2, "worker_id": "thread-worker"} def test_threaded_store_serializes_update_attempt_calls() -> None: store = SlowAttemptStore() threaded_store = LightningStoreThreaded(store) rollout_id = "rollout-race" num_calls = 5 def invoke(idx: int) -> Attempt: return asyncio.run( threaded_store.update_attempt( rollout_id, f"attempt-{idx}", status="running", worker_id=f"worker-{idx}", last_heartbeat_time=float(idx), metadata={"idx": idx}, ) ) with ThreadPoolExecutor(max_workers=num_calls) as executor: results = list(executor.map(invoke, range(num_calls))) assert store.max_active_calls == 1 assert len(results) == num_calls assert sorted(result.attempt_id for result in results) == [f"attempt-{i}" for i in range(num_calls)] def test_threaded_store_prevents_race_conditions_on_resource_updates() -> None: store = IncrementingResourceStore() threaded_store = LightningStoreThreaded(store) num_updates = 20 def invoke(idx: int) -> ResourcesUpdate: return asyncio.run(threaded_store.update_resources(f"resources-{idx}", {})) with ThreadPoolExecutor(max_workers=num_updates) as executor: updates = list(executor.map(invoke, range(num_updates))) assert store.counter == num_updates assert len(updates) == num_updates assert {update.resources_id for update in updates} == {f"res-{i + 1}" for i in range(num_updates)} @pytest.mark.asyncio async def test_threaded_store_add_resources_delegates() -> None: """Test that threaded store delegates add_resources calls correctly.""" llm = LLM( resource_type="llm", endpoint="http://localhost:8080/v1", model="test-model", sampling_parameters={"temperature": 0.7}, ) resources: NamedResources = cast(NamedResources, {"main_llm": llm}) resources_update = ResourcesUpdate( resources_id="resources-1", resources=resources, create_time=time.time(), update_time=time.time(), version=1 ) return_values = { "add_resources": resources_update, } dummy_store = DummyLightningStore(return_values) threaded_store = LightningStoreThreaded(dummy_store) result = await threaded_store.add_resources(resources) assert result == resources_update assert len(dummy_store.calls) == 1 assert dummy_store.calls[0][0] == "add_resources" assert dummy_store.calls[0][1] == (resources,) def test_threaded_store_serializes_add_resources_calls() -> None: """Test that concurrent add_resources calls are serialized by the thread lock.""" store = InMemoryLightningStore() threaded_store = LightningStoreThreaded(store) num_adds = 10 def invoke(idx: int) -> ResourcesUpdate: llm = LLM( resource_type="llm", endpoint=f"http://localhost:808{idx % 10}", model=f"model-{idx}", ) resources: NamedResources = cast(NamedResources, {"llm": llm}) return asyncio.run(threaded_store.add_resources(resources)) with ThreadPoolExecutor(max_workers=num_adds) as executor: updates = list(executor.map(invoke, range(num_adds))) # Verify all calls were made and resources_ids are unique assert len(updates) == num_adds resource_ids = {update.resources_id for update in updates} assert len(resource_ids) == num_adds # All IDs should be unique @pytest.mark.asyncio async def test_threaded_store_statistics_match_underlying(inmemory_store: InMemoryLightningStore) -> None: """Threaded store should proxy statistics from wrapped store.""" rollout = await inmemory_store.start_rollout(input={"source": "threaded-stats"}) await inmemory_store.add_span(make_span(rollout.rollout_id, rollout.attempt.attempt_id)) await inmemory_store.add_resources( { "threaded_llm": LLM( resource_type="llm", endpoint="http://localhost:9000/v1", model="threaded-model", ) } ) await inmemory_store.update_worker("threaded-worker", heartbeat_stats={"cpu": 0.1}) threaded_store = LightningStoreThreaded(inmemory_store) threaded_stats = await threaded_store.statistics() inmemory_stats = await inmemory_store.statistics() assert {k: v for k, v in threaded_stats.items() if k != "uptime"} == { k: v for k, v in inmemory_stats.items() if k != "uptime" } assert threaded_stats["uptime"] < inmemory_stats["uptime"] # type: ignore