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

432 lines
15 KiB
Python

# 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