Add LightningStore interface and implementation (#118)

This commit is contained in:
Yuge Zhang
2025-09-30 02:29:14 -07:00
committed by GitHub
parent 138ad0e487
commit 2316a8451e
14 changed files with 4386 additions and 3 deletions
+258
View File
@@ -0,0 +1,258 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional, Sequence
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.tracer import Span
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
NamedResources,
ResourcesUpdate,
RolloutConfig,
RolloutStatus,
RolloutV2,
TaskInput,
)
def is_queuing(rollout: RolloutV2) -> bool:
return rollout.status == "queuing" or rollout.status == "requeuing"
def is_running(rollout: RolloutV2) -> bool:
return rollout.status == "preparing" or rollout.status == "running"
def is_finished(rollout: RolloutV2) -> bool:
return rollout.status == "failed" or rollout.status == "succeeded" or rollout.status == "cancelled"
class _UnsetType:
"""A sentinel type to indicate an unset value."""
__slots__ = ()
def __repr__(self) -> str:
return "UNSET"
def __reduce__(self):
return (_get_unset, ())
def _get_unset() -> _UnsetType:
return UNSET
UNSET = _UnsetType()
Unset = _UnsetType # Alias for convenience
class LightningStore:
"""
A centralized, thread-safe, async, data store for the lightning's state.
This holds the task queue, versioned resources, and completed rollouts.
The store has a built-in clock and it should be responsible for tracking the times.
All the time-based operations like retry, timeout, etc. should be handled by the store.
"""
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> AttemptedRollout:
"""
Add one incomplete rollout to the store, and get an attempt created for it.
This will immediately sets the rollout to a preparing state, and should be
used by whoever is going to execute the rollout.
Return a special rollout with attempt object. Do not update it directly.
But if the rollout fails or timeouts, it's still possible that the watchdog
sends it back to the queue for retry.
To enqueue a rollout to the task queue, use `enqueue_rollout` instead.
"""
raise NotImplementedError()
async def enqueue_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> RolloutV2:
"""
Adds a new task to the queue with specific metadata and
returns the rollout object with its unique ID.
"""
raise NotImplementedError()
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
"""
Retrieves the next task from the queue without blocking.
Returns None if the queue is empty.
Will set the rollout status to preparing.
"""
raise NotImplementedError()
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
"""
Create a new attempt for a given rollout ID and return the attempt details.
"""
raise NotImplementedError()
async def add_span(self, span: Span) -> Span:
"""
Add a span to the store.
This method is responsible for updating the rollout/attempt status to "running" if needed.
"""
raise NotImplementedError()
async def add_otel_span(
self,
rollout_id: str,
attempt_id: str,
readable_span: ReadableSpan,
sequence_id: int | None = None,
) -> Span:
"""
Add an opentelemetry span to the store.
If sequence_id is not provided, it will be fetched from `get_next_span_sequence_id` and assigned automatically.
"""
raise NotImplementedError()
async def query_rollouts(
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
) -> List[RolloutV2]:
"""
Query and retrieve rollouts filtered by their status.
If no status is provided, returns all rollouts.
"""
raise NotImplementedError()
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
"""
Query and retrieve all attempts associated with a specific rollout ID.
Returns an empty list if no attempts are found.
"""
raise NotImplementedError()
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
"""
Safely retrieves a specific rollout by its ID.
"""
raise NotImplementedError()
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
"""
Safely retrieves the latest attempt for a given rollout ID.
"""
raise NotImplementedError()
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
"""
Safely retrieves a specific version of named resources by its ID.
"""
raise NotImplementedError()
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
"""
Safely retrieves the latest version of named resources.
"""
raise NotImplementedError()
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
"""
Get the next span sequence ID for a given rollout and attempt.
This should be used to assign a unique sequence ID to each span within an attempt.
Recommend getting the ID before the operation even begins to avoid racing conditions.
"""
raise NotImplementedError()
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
"""
Wait for specified rollouts to complete with a timeout.
Returns the completed rollouts, potentially incomplete if timeout is reached.
TODO: Add support for waiting for 20 new rollouts, or wait until 80% of the pending ids are completed.
"""
raise NotImplementedError()
async def query_spans(self, rollout_id: str, attempt_id: str | Literal["latest"] | None = None) -> List[Span]:
"""
Query and retrieve all spans associated with a specific rollout ID.
Returns an empty list if no spans are found.
"""
raise NotImplementedError()
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
"""
Safely stores a new version of named resources and sets it as the latest.
"""
raise NotImplementedError()
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
"""
Update the rollout status and related metadata.
Not-listed fields here either cannot be updated, or should be auto-updated (e.g., end_time).
When status is updated to a finished / problematic state, other states like task
queues will be updated accordingly.
Args:
rollout_id: Unique identifier for the rollout to update
input: New input data for the rollout. If set, will be updated. Can be updated to None
mode: New mode for the rollout. If set, will be updated. Can be updated to None
resources_id: New resources ID for the rollout. If set, will be updated. Can be updated to None
status: New status for the rollout. If set, will be updated
config: New config for the rollout. If set, will be updated
metadata: Dictionary of additional metadata to update. If set, will replace the existing metadata
"""
raise NotImplementedError()
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
"""
Update a specific or latest attempt for a given rollout.
Update the latest attempt will NOT affect the corresponding rollout status.
Args:
rollout_id: Unique identifier for the rollout
attempt_id: Unique identifier for the attempt
status: Status to set for the attempt, update if provided
worker_id: Worker identifier, update if provided
last_heartbeat_time: Timestamp of the last heartbeat from the worker
metadata: Dictionary of additional metadata to update, will replace the existing metadata
"""
raise NotImplementedError()
+575
View File
@@ -0,0 +1,575 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, Literal, Optional, Sequence, Union
import aiohttp
import uvicorn
from fastapi import FastAPI
from opentelemetry.sdk.trace import ReadableSpan
from pydantic import BaseModel, Field
from agentlightning.tracer import Span
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
NamedResources,
ResourcesUpdate,
RolloutConfig,
RolloutStatus,
RolloutV2,
TaskInput,
)
from .base import UNSET, LightningStore, Unset
logger = logging.getLogger(__name__)
class PydanticUnset(BaseModel):
_type: Literal["UNSET"] = "UNSET"
class RolloutRequest(BaseModel):
input: TaskInput
mode: Optional[Literal["train", "val", "test"]] = None
resources_id: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class QueryRolloutsRequest(BaseModel):
status: Optional[List[RolloutStatus]] = None
rollout_ids: Optional[List[str]] = None
class WaitForRolloutsRequest(BaseModel):
rollout_ids: List[str]
timeout: Optional[float] = None
class RolloutId(BaseModel):
rollout_id: str
class UpdateRolloutRequest(BaseModel):
rollout_id: str
input: Union[TaskInput, PydanticUnset] = Field(default_factory=PydanticUnset)
mode: Union[Optional[Literal["train", "val", "test"]], PydanticUnset] = Field(default_factory=PydanticUnset)
resources_id: Union[Optional[str], PydanticUnset] = Field(default_factory=PydanticUnset)
status: Union[RolloutStatus, PydanticUnset] = Field(default_factory=PydanticUnset)
config: Union[RolloutConfig, PydanticUnset] = Field(default_factory=PydanticUnset)
metadata: Union[Dict[str, Any], PydanticUnset] = Field(default_factory=PydanticUnset)
class UpdateAttemptRequest(BaseModel):
rollout_id: str
attempt_id: Union[str, Literal["latest"]]
status: Union[AttemptStatus, PydanticUnset] = Field(default_factory=PydanticUnset)
worker_id: Union[str, PydanticUnset] = Field(default_factory=PydanticUnset)
last_heartbeat_time: Union[float, PydanticUnset] = Field(default_factory=PydanticUnset)
metadata: Union[Dict[str, Any], PydanticUnset] = Field(default_factory=PydanticUnset)
class LightningStoreServer(LightningStore):
"""
Server wrapper that exposes a LightningStore via HTTP API.
Delegates all operations to an underlying store implementation.
Healthcheck and watchdog relies on the underlying store.
"""
def __init__(self, store: LightningStore, host: str, port: int):
super().__init__()
self.store = store
self.host = host
self.port = port
self.app = FastAPI(title="LightningStore Server")
self._setup_routes()
self._uvicorn_config = uvicorn.Config(self.app, host=self.host, port=self.port, log_level="info")
self._uvicorn_server = uvicorn.Server(self._uvicorn_config)
@property
def endpoint(self) -> str:
return f"http://{self.host}:{self.port}"
async def start(self):
"""Starts the FastAPI server in the background."""
logger.info(f"Starting server at {self.endpoint}")
asyncio.create_task(self._uvicorn_server.serve())
await asyncio.sleep(1) # Allow time for server to start up.
async def stop(self):
"""Gracefully stops the running FastAPI server."""
if self._uvicorn_server.started:
logger.info("Stopping server...")
self._uvicorn_server.should_exit = True
await asyncio.sleep(1) # Allow time for graceful shutdown.
logger.info("Server stopped.")
def _setup_routes(self):
"""Set up FastAPI routes for all store operations."""
@self.app.post("/start_rollout", response_model=AttemptedRollout)
async def start_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.start_rollout(
input=request.input,
mode=request.mode,
resources_id=request.resources_id,
metadata=request.metadata,
)
@self.app.post("/enqueue_rollout", response_model=RolloutV2)
async def enqueue_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.enqueue_rollout(
input=request.input,
mode=request.mode,
resources_id=request.resources_id,
metadata=request.metadata,
)
@self.app.get("/dequeue_rollout", response_model=Optional[AttemptedRollout])
async def dequeue_rollout(): # pyright: ignore[reportUnusedFunction]
return await self.store.dequeue_rollout()
@self.app.post("/start_attempt", response_model=AttemptedRollout)
async def start_attempt(request: RolloutId): # pyright: ignore[reportUnusedFunction]
return await self.store.start_attempt(request.rollout_id)
@self.app.post("/query_rollouts", response_model=List[RolloutV2])
async def query_rollouts(request: QueryRolloutsRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.query_rollouts(status=request.status)
@self.app.get("/query_attempts/{rollout_id}", response_model=List[Attempt])
async def query_attempts(rollout_id: str): # pyright: ignore[reportUnusedFunction]
return await self.store.query_attempts(rollout_id)
@self.app.get("/get_latest_attempt/{rollout_id}", response_model=Optional[Attempt])
async def get_latest_attempt(rollout_id: str): # pyright: ignore[reportUnusedFunction]
return await self.store.get_latest_attempt(rollout_id)
@self.app.get("/get_rollout_by_id/{rollout_id}", response_model=Optional[RolloutV2])
async def get_rollout_by_id(rollout_id: str): # pyright: ignore[reportUnusedFunction]
return await self.store.get_rollout_by_id(rollout_id)
@self.app.post("/update_resources", response_model=ResourcesUpdate)
async def update_resources(update: ResourcesUpdate): # pyright: ignore[reportUnusedFunction]
return await self.store.update_resources(update.resources_id, update.resources)
@self.app.get("/get_resources_by_id/{resources_id}", response_model=Optional[ResourcesUpdate])
async def get_resources_by_id(resources_id: str): # pyright: ignore[reportUnusedFunction]
return await self.store.get_resources_by_id(resources_id)
@self.app.get("/get_latest_resources", response_model=Optional[ResourcesUpdate])
async def get_latest_resources(): # pyright: ignore[reportUnusedFunction]
return await self.store.get_latest_resources()
@self.app.post("/add_span", response_model=Span)
async def add_span(span: Span): # pyright: ignore[reportUnusedFunction]
return await self.store.add_span(span)
@self.app.get("/get_next_span_sequence_id/{rollout_id}/{attempt_id}", response_model=int)
async def get_next_span_sequence_id(rollout_id: str, attempt_id: str): # pyright: ignore[reportUnusedFunction]
return await self.store.get_next_span_sequence_id(rollout_id, attempt_id)
@self.app.post("/wait_for_rollouts", response_model=List[RolloutV2])
async def wait_for_rollouts(request: WaitForRolloutsRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.wait_for_rollouts(rollout_ids=request.rollout_ids, timeout=request.timeout)
@self.app.get("/query_spans/{rollout_id}", response_model=List[Span])
async def query_spans( # pyright: ignore[reportUnusedFunction]
rollout_id: str, attempt_id: Optional[str] = None
):
return await self.store.query_spans(rollout_id, attempt_id)
@self.app.post("/update_rollout", response_model=RolloutV2)
async def update_rollout(request: UpdateRolloutRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.update_rollout(
rollout_id=request.rollout_id,
input=request.input if not isinstance(request.input, PydanticUnset) else UNSET,
mode=request.mode if not isinstance(request.mode, PydanticUnset) else UNSET,
resources_id=request.resources_id if not isinstance(request.resources_id, PydanticUnset) else UNSET,
status=request.status if not isinstance(request.status, PydanticUnset) else UNSET,
config=request.config if not isinstance(request.config, PydanticUnset) else UNSET,
metadata=request.metadata if not isinstance(request.metadata, PydanticUnset) else UNSET,
)
@self.app.post("/update_attempt", response_model=Attempt)
async def update_attempt(request: UpdateAttemptRequest): # pyright: ignore[reportUnusedFunction]
return await self.store.update_attempt(
rollout_id=request.rollout_id,
attempt_id=request.attempt_id,
status=request.status if not isinstance(request.status, PydanticUnset) else UNSET,
worker_id=request.worker_id if not isinstance(request.worker_id, PydanticUnset) else UNSET,
last_heartbeat_time=(
request.last_heartbeat_time if not isinstance(request.last_heartbeat_time, PydanticUnset) else UNSET
),
metadata=request.metadata if not isinstance(request.metadata, PydanticUnset) else UNSET,
)
# Delegate methods -------------------------------------------------
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> AttemptedRollout:
return await self.store.start_rollout(input, mode, resources_id, metadata)
async def enqueue_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> RolloutV2:
return await self.store.enqueue_rollout(input, mode, resources_id, metadata)
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
return await self.store.dequeue_rollout()
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
return await self.store.start_attempt(rollout_id)
async def query_rollouts(
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
) -> List[RolloutV2]:
return await self.store.query_rollouts(status=status, rollout_ids=rollout_ids)
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
return await self.store.query_attempts(rollout_id)
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
return await self.store.get_latest_attempt(rollout_id)
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
return await self.store.get_rollout_by_id(rollout_id)
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
return await self.store.update_resources(resources_id, resources)
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
return await self.store.get_resources_by_id(resources_id)
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
return await self.store.get_latest_resources()
async def add_span(self, span: Span) -> Span:
return await self.store.add_span(span)
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
return await self.store.get_next_span_sequence_id(rollout_id, attempt_id)
async def add_otel_span(
self,
rollout_id: str,
attempt_id: str,
readable_span: ReadableSpan,
sequence_id: int | None = None,
) -> Span:
return await self.store.add_otel_span(rollout_id, attempt_id, readable_span, sequence_id)
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
return await self.store.wait_for_rollouts(rollout_ids=rollout_ids, timeout=timeout)
async def query_spans(
self,
rollout_id: str,
attempt_id: str | Literal["latest"] | None = None,
) -> List[Span]:
return await self.store.query_spans(rollout_id, attempt_id)
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
return await self.store.update_rollout(
rollout_id=rollout_id,
input=input,
mode=mode,
resources_id=resources_id,
status=status,
config=config,
metadata=metadata,
)
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
return await self.store.update_attempt(
rollout_id=rollout_id,
attempt_id=attempt_id,
status=status,
worker_id=worker_id,
last_heartbeat_time=last_heartbeat_time,
metadata=metadata,
)
class LightningStoreClient(LightningStore):
"""HTTP client that talks to a remote LightningStoreServer."""
def __init__(self, server_address: str):
self.server_address = server_address.rstrip("/")
self.session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
"""Close the HTTP session."""
if self.session and not self.session.closed:
await self.session.close()
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> AttemptedRollout:
session = await self._get_session()
request_data = RolloutRequest(input=input, mode=mode, resources_id=resources_id, metadata=metadata)
async with session.post(f"{self.server_address}/start_rollout", json=request_data.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return AttemptedRollout.model_validate(data)
async def enqueue_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> RolloutV2:
session = await self._get_session()
request_data = RolloutRequest(input=input, mode=mode, resources_id=resources_id, metadata=metadata)
async with session.post(f"{self.server_address}/enqueue_rollout", json=request_data.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return RolloutV2.model_validate(data)
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
session = await self._get_session()
async with session.get(f"{self.server_address}/dequeue_rollout") as response:
response.raise_for_status()
data = await response.json()
return AttemptedRollout.model_validate(data) if data else None
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
session = await self._get_session()
request_data = RolloutId(rollout_id=rollout_id)
async with session.post(f"{self.server_address}/start_attempt", json=request_data.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return AttemptedRollout.model_validate(data)
async def query_rollouts(
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
) -> List[RolloutV2]:
session = await self._get_session()
request_data = QueryRolloutsRequest(
status=list(status) if status else None, rollout_ids=list(rollout_ids) if rollout_ids else None
)
async with session.post(f"{self.server_address}/query_rollouts", json=request_data.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return [RolloutV2.model_validate(item) for item in data]
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
session = await self._get_session()
async with session.get(f"{self.server_address}/query_attempts/{rollout_id}") as response:
response.raise_for_status()
data = await response.json()
return [Attempt.model_validate(item) for item in data]
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
session = await self._get_session()
async with session.get(f"{self.server_address}/get_latest_attempt/{rollout_id}") as response:
response.raise_for_status()
data = await response.json()
return Attempt.model_validate(data) if data else None
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
session = await self._get_session()
async with session.get(f"{self.server_address}/get_rollout_by_id/{rollout_id}") as response:
response.raise_for_status()
data = await response.json()
return RolloutV2.model_validate(data) if data else None
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
session = await self._get_session()
update = ResourcesUpdate(resources_id=resources_id, resources=resources)
async with session.post(f"{self.server_address}/update_resources", json=update.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return ResourcesUpdate.model_validate(data)
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
session = await self._get_session()
async with session.get(f"{self.server_address}/get_resources_by_id/{resources_id}") as response:
response.raise_for_status()
data = await response.json()
return ResourcesUpdate.model_validate(data) if data else None
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
session = await self._get_session()
async with session.get(f"{self.server_address}/get_latest_resources") as response:
response.raise_for_status()
data = await response.json()
return ResourcesUpdate.model_validate(data) if data else None
async def add_span(self, span: Span) -> Span:
session = await self._get_session()
async with session.post(f"{self.server_address}/add_span", json=span.model_dump(mode="json")) as response:
response.raise_for_status()
data = await response.json()
return Span.model_validate(data)
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
session = await self._get_session()
async with session.get(
f"{self.server_address}/get_next_span_sequence_id/{rollout_id}/{attempt_id}"
) as response:
response.raise_for_status()
data = await response.json()
return data
async def add_otel_span(
self,
rollout_id: str,
attempt_id: str,
readable_span: ReadableSpan,
sequence_id: int | None = None,
) -> Span:
if sequence_id is None:
sequence_id = await self.get_next_span_sequence_id(rollout_id, attempt_id)
span = Span.from_opentelemetry(
readable_span,
rollout_id=rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
)
await self.add_span(span)
return span
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
session = await self._get_session()
if timeout is not None and timeout > 0.1:
raise ValueError(
"Timeout must be less than 0.1 seconds in LightningStoreClient to avoid blocking the event loop"
)
request_data = WaitForRolloutsRequest(rollout_ids=rollout_ids, timeout=timeout)
async with session.post(f"{self.server_address}/wait_for_rollouts", json=request_data.model_dump()) as response:
response.raise_for_status()
data = await response.json()
return [RolloutV2.model_validate(item) for item in data]
async def query_spans(
self,
rollout_id: str,
attempt_id: str | Literal["latest"] | None = None,
) -> List[Span]:
session = await self._get_session()
url = f"{self.server_address}/query_spans/{rollout_id}"
if attempt_id is not None:
url += f"?attempt_id={attempt_id}"
async with session.get(url) as response:
response.raise_for_status()
data = await response.json()
return [Span.model_validate(item) for item in data]
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
session = await self._get_session()
payload: Dict[str, Any] = {"rollout_id": rollout_id}
if not isinstance(input, Unset):
payload["input"] = input
if not isinstance(mode, Unset):
payload["mode"] = mode
if not isinstance(resources_id, Unset):
payload["resources_id"] = resources_id
if not isinstance(status, Unset):
payload["status"] = status
if not isinstance(config, Unset):
payload["config"] = config.model_dump()
if not isinstance(metadata, Unset):
payload["metadata"] = metadata
async with session.post(f"{self.server_address}/update_rollout", json=payload) as response:
response.raise_for_status()
data = await response.json()
return RolloutV2.model_validate(data)
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
session = await self._get_session()
payload: Dict[str, Any] = {
"rollout_id": rollout_id,
"attempt_id": attempt_id,
}
if not isinstance(status, Unset):
payload["status"] = status
if not isinstance(worker_id, Unset):
payload["worker_id"] = worker_id
if not isinstance(last_heartbeat_time, Unset):
payload["last_heartbeat_time"] = last_heartbeat_time
if not isinstance(metadata, Unset):
payload["metadata"] = metadata
async with session.post(f"{self.server_address}/update_attempt", json=payload) as response:
response.raise_for_status()
data = await response.json()
return Attempt.model_validate(data)
+634
View File
@@ -0,0 +1,634 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import functools
import logging
import time
import uuid
from collections import deque
from typing import Any, Callable, Counter, Dict, List, Literal, Optional, Sequence, TypeVar, cast
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.tracer import Span
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
NamedResources,
ResourcesUpdate,
RolloutConfig,
RolloutStatus,
RolloutV2,
TaskInput,
)
from .base import UNSET, LightningStore, Unset, is_finished, is_queuing
from .utils import healthcheck, propagate_status
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
def _healthcheck_wrapper(func: T_callable) -> T_callable:
"""
Decorator to run the watchdog healthcheck **before** executing the decorated method.
Only runs if the store has a watchdog configured.
Prevents recursive healthcheck execution using a flag on the store instance.
"""
@functools.wraps(func)
async def wrapper(self: InMemoryLightningStore, *args: Any, **kwargs: Any) -> Any:
# Check if healthcheck is already running to prevent recursion
if getattr(self, "_healthcheck_running", False):
# Skip healthcheck if already running
return await func(self, *args, **kwargs)
# Set flag to prevent recursive healthcheck calls
# This flag is not asyncio/thread-safe, but it doesn't matter
self._healthcheck_running = True # type: ignore
try:
# The following methods should live inside one lock.
await self._healthcheck() # pyright: ignore[reportPrivateUsage]
finally:
# Always clear the flag, even if healthcheck fails
self._healthcheck_running = False # type: ignore
# Execute the original method
# This should be outside the lock.
return await func(self, *args, **kwargs)
return cast(T_callable, wrapper)
class InMemoryLightningStore(LightningStore):
"""
In-memory implementation of LightningStore using Python data structures.
Thread-safe and async-compatible but data is not persistent.
The methods in this class should generally not call each other,
especially those that are locked.
"""
def __init__(self):
self._lock = asyncio.Lock()
# Task queue and rollouts storage
self._task_queue: deque[RolloutV2] = deque()
self._rollouts: Dict[str, RolloutV2] = {}
# Resources storage (similar to legacy server.py)
self._resources: Dict[str, ResourcesUpdate] = {}
self._latest_resources_id: Optional[str] = None
# Spans storage
self._spans: Dict[str, List[Span]] = {} # rollout_id -> list of spans
self._span_sequence_ids: Dict[str, int] = Counter() # rollout_id -> sequence_id
# Attempt tracking
self._attempts: Dict[str, List[Attempt]] = {} # rollout_id -> list of attempts
# Completion tracking for wait_for_rollouts
self._completion_events: Dict[str, asyncio.Event] = {}
@_healthcheck_wrapper
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> AttemptedRollout:
"""
Notify the store that I'm about to run a rollout.
"""
async with self._lock:
rollout_id = f"rollout-{uuid.uuid4()}"
current_time = time.time()
rollout = RolloutV2(
rollout_id=rollout_id,
input=input,
mode=mode,
resources_id=resources_id or self._latest_resources_id,
start_time=current_time,
status="preparing",
metadata=metadata or {},
)
# Create the initial attempt
attempt_id = f"attempt-{uuid.uuid4()}"
attempt = Attempt(
rollout_id=rollout.rollout_id,
attempt_id=attempt_id,
sequence_id=1,
start_time=current_time,
status="preparing",
)
self._attempts[rollout.rollout_id] = [attempt]
self._rollouts[rollout.rollout_id] = rollout
# Manully added rollout is not added to task queue. It's already preparing
self._completion_events.setdefault(rollout.rollout_id, asyncio.Event())
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
@_healthcheck_wrapper
async def enqueue_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> RolloutV2:
"""
Adds a new task to the queue with specific metadata and returns its unique ID.
"""
async with self._lock:
rollout_id = f"rollout-{uuid.uuid4()}"
current_time = time.time()
rollout = RolloutV2(
rollout_id=rollout_id,
input=input,
mode=mode,
resources_id=resources_id or self._latest_resources_id,
start_time=current_time,
status="queuing", # should be queuing
metadata=metadata or {},
)
self._rollouts[rollout.rollout_id] = rollout
self._task_queue.append(rollout) # add it to the end of the queue
self._completion_events.setdefault(rollout.rollout_id, asyncio.Event())
return rollout
@_healthcheck_wrapper
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
"""
Retrieves the next task from the queue without blocking.
Returns None if the queue is empty.
Will set the rollout status to preparing and create a new attempt.
"""
async with self._lock:
# Keep looking until we find a rollout that's still in queuing status
# or the queue is empty
while self._task_queue:
rollout = self._task_queue.popleft()
# Check if rollout is still in a queuing state
# (it might have been updated to a different status while in queue)
if is_queuing(rollout):
# Update status to preparing
rollout.status = "preparing"
# Create a new attempt (could be first attempt or retry)
attempt_id = f"attempt-{uuid.uuid4()}"
current_time = time.time()
# Get existing attempts to determine sequence number
existing_attempts = self._attempts.get(rollout.rollout_id, [])
sequence_id = len(existing_attempts) + 1
attempt = Attempt(
rollout_id=rollout.rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
start_time=current_time,
status="preparing",
)
if rollout.rollout_id not in self._attempts:
self._attempts[rollout.rollout_id] = []
self._attempts[rollout.rollout_id].append(attempt)
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
# If not in queuing state, skip this rollout and continue
# (it was updated externally and should not be processed)
# No valid rollouts found
return None
@_healthcheck_wrapper
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
"""
Create a new attempt for a given rollout ID and return the attempt details.
"""
async with self._lock:
# Get the rollout
rollout = self._rollouts.get(rollout_id)
if not rollout:
raise ValueError(f"Rollout {rollout_id} not found")
# Get existing attempts to determine sequence number
existing_attempts = self._attempts.get(rollout_id, [])
sequence_id = len(existing_attempts) + 1
# We don't care whether the max attempts have reached or not
# This attempt is from user trigger
# Create new attempt
attempt_id = f"attempt-{uuid.uuid4()}"
current_time = time.time()
attempt = Attempt(
rollout_id=rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
start_time=current_time,
status="preparing",
)
# Add attempt to storage
if rollout_id not in self._attempts:
self._attempts[rollout_id] = []
self._attempts[rollout_id].append(attempt)
self._completion_events.setdefault(rollout.rollout_id, asyncio.Event())
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
@_healthcheck_wrapper
async def query_rollouts(
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
) -> List[RolloutV2]:
"""
Query and retrieve rollouts filtered by their status and rollout ids.
If no status is provided, returns all rollouts.
"""
async with self._lock:
rollouts = list(self._rollouts.values())
# Filter by rollout_ids if provided
if rollout_ids is not None:
rollout_ids_set = set(rollout_ids)
rollouts = [rollout for rollout in rollouts if rollout.rollout_id in rollout_ids_set]
# Filter by status if provided
if status is not None:
status_set = set(status)
rollouts = [rollout for rollout in rollouts if rollout.status in status_set]
return rollouts
@_healthcheck_wrapper
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
"""
Safely retrieves a specific rollout by its ID.
"""
async with self._lock:
return self._rollouts.get(rollout_id)
@_healthcheck_wrapper
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
"""
Query and retrieve all attempts associated with a specific rollout ID.
Returns an empty list if no attempts are found.
"""
async with self._lock:
return self._attempts.get(rollout_id, [])
@_healthcheck_wrapper
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
"""
Safely retrieves the latest attempt for a given rollout ID.
"""
async with self._lock:
attempts = self._attempts.get(rollout_id, [])
if not attempts:
return None
return max(attempts, key=lambda a: a.sequence_id)
@_healthcheck_wrapper
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
"""
Safely stores a new version of named resources and sets it as the latest.
"""
async with self._lock:
update = ResourcesUpdate(resources_id=resources_id, resources=resources)
self._resources[resources_id] = update
self._latest_resources_id = resources_id
return update
@_healthcheck_wrapper
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
"""
Safely retrieves a specific version of named resources by its ID.
"""
async with self._lock:
return self._resources.get(resources_id)
@_healthcheck_wrapper
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
"""
Safely retrieves the latest version of named resources.
"""
async with self._lock:
if self._latest_resources_id:
return self._resources.get(self._latest_resources_id)
return None
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
"""
Get the next span sequence ID for a given rollout and attempt.
The number is strictly increasing for each rollout.
The store will not issue the same sequence ID twice.
"""
async with self._lock:
self._span_sequence_ids[rollout_id] += 1
return self._span_sequence_ids[rollout_id]
async def add_span(self, span: Span) -> Span:
"""Persist a pre-converted span."""
async with self._lock:
self._span_sequence_ids[span.rollout_id] = max(self._span_sequence_ids[span.rollout_id], span.sequence_id)
return await self._add_span_unlocked(span)
async def add_otel_span(
self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
) -> Span:
"""Add an opentelemetry span to the store."""
async with self._lock:
if sequence_id is None:
# Issue a new sequence ID for the rollout
self._span_sequence_ids[rollout_id] += 1
sequence_id = self._span_sequence_ids[rollout_id]
else:
# Comes from a provided sequence ID
# Make sure our counter is strictly increasing
self._span_sequence_ids[rollout_id] = max(self._span_sequence_ids[rollout_id], sequence_id)
span = Span.from_opentelemetry(
readable_span, rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=sequence_id
)
await self._add_span_unlocked(span)
return span
async def _add_span_unlocked(self, span: Span) -> Span:
rollout = self._rollouts.get(span.rollout_id)
if not rollout:
raise ValueError(f"Rollout {span.rollout_id} not found")
attempts = self._attempts.get(span.rollout_id, [])
current_attempt = next((a for a in attempts if a.attempt_id == span.attempt_id), None)
latest_attempt = max(attempts, key=lambda a: a.sequence_id) if attempts else None
if not current_attempt:
raise ValueError(f"Attempt {span.attempt_id} not found for rollout {span.rollout_id}")
if not latest_attempt:
raise ValueError(f"No attempts found for rollout {span.rollout_id}")
if span.rollout_id not in self._spans:
self._spans[span.rollout_id] = []
self._spans[span.rollout_id].append(span)
# Update attempt heartbeat
current_attempt.last_heartbeat_time = time.time()
if current_attempt.status == "preparing":
current_attempt.status = "running"
# If the status has already timed out or failed, do not change it
# Update rollout status if it's the latest attempt
if rollout.status == "preparing" and current_attempt == latest_attempt:
rollout.status = "running"
return span
@_healthcheck_wrapper
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
"""
Wait for specified rollouts to complete with a timeout.
Returns the completed rollouts, potentially incomplete if timeout is reached.
This method does not change the state of the store.
"""
completed_rollouts: List[RolloutV2] = []
async def wait_for_rollout(rollout_id: str):
# First check if already completed
async with self._lock:
rollout = self._rollouts.get(rollout_id)
if rollout and is_finished(rollout):
completed_rollouts.append(rollout)
return
# If not completed and we have an event, wait for completion
if rollout_id in self._completion_events:
try:
await asyncio.wait_for(self._completion_events[rollout_id].wait(), timeout=timeout)
async with self._lock:
rollout = self._rollouts.get(rollout_id)
if rollout and is_finished(rollout):
completed_rollouts.append(rollout)
except asyncio.TimeoutError:
pass
# Wait for all rollouts concurrently
await asyncio.gather(*[wait_for_rollout(rid) for rid in rollout_ids], return_exceptions=True)
return completed_rollouts
@_healthcheck_wrapper
async def query_spans(self, rollout_id: str, attempt_id: str | Literal["latest"] | None = None) -> List[Span]:
"""
Query and retrieve all spans associated with a specific rollout ID.
Returns an empty list if no spans are found.
"""
async with self._lock:
spans = self._spans.get(rollout_id, [])
if attempt_id is None:
return spans
elif attempt_id == "latest":
# Find the latest attempt_id
if not spans:
return []
latest_attempt = max(spans, key=lambda s: s.sequence_id if s.attempt_id else "").attempt_id
return [s for s in spans if s.attempt_id == latest_attempt]
else:
return [s for s in spans if s.attempt_id == attempt_id]
@_healthcheck_wrapper
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
"""
Update the rollout status and related metadata.
"""
async with self._lock:
return await self._update_rollout_unlocked(
rollout_id=rollout_id,
input=input,
mode=mode,
resources_id=resources_id,
status=status,
config=config,
metadata=metadata,
)
@_healthcheck_wrapper
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
"""
Update a specific or latest attempt for a given rollout.
"""
async with self._lock:
attempt = await self._update_attempt_unlocked(
rollout_id=rollout_id,
attempt_id=attempt_id,
status=status,
worker_id=worker_id,
last_heartbeat_time=last_heartbeat_time,
metadata=metadata,
)
return attempt
async def _update_rollout_unlocked(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
# No lock inside this one.
rollout = self._rollouts.get(rollout_id)
if not rollout:
raise ValueError(f"Rollout {rollout_id} not found")
# Update fields if they are not UNSET
if not isinstance(input, Unset):
rollout.input = input
if not isinstance(mode, Unset):
rollout.mode = mode
if not isinstance(resources_id, Unset):
rollout.resources_id = resources_id
if not isinstance(status, Unset):
rollout.status = status
if not isinstance(config, Unset):
rollout.config = config
if not isinstance(metadata, Unset):
rollout.metadata = metadata
# Set end time for finished rollouts
# Rollout is only finished when it succeeded or fail with no more retries.
if status is not UNSET and is_finished(rollout):
rollout.end_time = time.time()
# Signal completion
if rollout_id in self._completion_events:
self._completion_events[rollout_id].set()
# If requeuing, add back to queue
elif is_queuing(rollout) and rollout not in self._task_queue:
self._task_queue.append(rollout)
# Re-validate the rollout to ensure legality
RolloutV2.model_validate(rollout.model_dump())
return rollout
async def _update_attempt_unlocked(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
# No lock, but with status propagation.
rollout = self._rollouts.get(rollout_id)
if not rollout:
raise ValueError(f"Rollout {rollout_id} not found")
attempts = self._attempts.get(rollout_id, [])
if not attempts:
raise ValueError(f"No attempts found for rollout {rollout_id}")
latest_attempt = max(attempts, key=lambda a: a.sequence_id)
# Find the attempt to update
if attempt_id == "latest":
attempt = latest_attempt
else:
attempt = next((a for a in attempts if a.attempt_id == attempt_id), None)
if not attempt:
raise ValueError(f"Attempt {attempt_id} not found for rollout {rollout_id}")
# Update fields if they are not UNSET
if not isinstance(status, Unset):
attempt.status = status
# Also update end_time if the status indicates completion
if status in ["failed", "succeeded"]:
attempt.end_time = time.time()
if not isinstance(worker_id, Unset):
attempt.worker_id = worker_id
if not isinstance(last_heartbeat_time, Unset):
attempt.last_heartbeat_time = last_heartbeat_time
if not isinstance(metadata, Unset):
attempt.metadata = metadata
# Re-validate the attempt to ensure legality
Attempt.model_validate(attempt.model_dump())
if attempt == latest_attempt:
async def _update_status(rollout_id: str, status: RolloutStatus) -> RolloutV2:
return await self._update_rollout_unlocked(rollout_id, status=status)
# Propagate the status to the rollout
await propagate_status(
_update_status,
attempt,
rollout.config,
)
return attempt
async def _healthcheck(self) -> None:
"""Perform healthcheck against all running rollouts in the store."""
async with self._lock:
running_rollouts: List[AttemptedRollout] = []
for rollout in self._rollouts.values():
if rollout.status in ["preparing", "running"]:
all_attempts = self._attempts.get(rollout.rollout_id, [])
if not all_attempts:
# The rollout is running but has no attempts, this should not happen
logger.error(f"Rollout {rollout.rollout_id} is running but has no attempts")
continue
latest_attempt = max(all_attempts, key=lambda a: a.sequence_id)
running_rollouts.append(AttemptedRollout(**rollout.model_dump(), attempt=latest_attempt))
async def _update_attempt_status(rollout_id: str, attempt_id: str, status: AttemptStatus) -> Attempt:
return await self._update_attempt_unlocked(rollout_id, attempt_id, status=status)
async def _update_rollout_status(rollout_id: str, status: RolloutStatus) -> RolloutV2:
return await self._update_rollout_unlocked(rollout_id, status=status)
await healthcheck(
running_rollouts,
_update_rollout_status,
_update_attempt_status,
)
+1
View File
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
+167
View File
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import threading
from typing import Any, Dict, List, Literal, Optional, Sequence
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.tracer import Span
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
NamedResources,
ResourcesUpdate,
RolloutConfig,
RolloutStatus,
RolloutV2,
TaskInput,
)
from .base import UNSET, LightningStore, Unset
class LightningStoreThreaded(LightningStore):
"""Facade that delegates all store operations to a underlying store instance.
The operations are guaranteed to be thread-safe.
Make sure the threaded stores are instantiated before initializing the threads.
"""
def __init__(self, store: LightningStore) -> None:
super().__init__() # watchdog relies on the underlying store
self.store = store
self._lock = threading.Lock()
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> AttemptedRollout:
with self._lock:
return await self.store.start_rollout(input, mode, resources_id, metadata)
async def enqueue_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> RolloutV2:
with self._lock:
return await self.store.enqueue_rollout(input, mode, resources_id, metadata)
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
with self._lock:
return await self.store.dequeue_rollout()
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
with self._lock:
return await self.store.start_attempt(rollout_id)
async def query_rollouts(
self,
*,
status: Optional[Sequence[RolloutStatus]] = None,
rollout_ids: Optional[Sequence[str]] = None,
) -> List[RolloutV2]:
with self._lock:
return await self.store.query_rollouts(status=status, rollout_ids=rollout_ids)
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
with self._lock:
return await self.store.query_attempts(rollout_id)
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
with self._lock:
return await self.store.get_rollout_by_id(rollout_id)
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
with self._lock:
return await self.store.get_latest_attempt(rollout_id)
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
with self._lock:
return await self.store.update_resources(resources_id, resources)
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
with self._lock:
return await self.store.get_resources_by_id(resources_id)
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
with self._lock:
return await self.store.get_latest_resources()
async def add_span(self, span: Span) -> Span:
with self._lock:
return await self.store.add_span(span)
async def add_otel_span(
self,
rollout_id: str,
attempt_id: str,
readable_span: ReadableSpan,
sequence_id: int | None = None,
) -> Span:
with self._lock:
return await self.store.add_otel_span(rollout_id, attempt_id, readable_span, sequence_id)
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
# This method does not change the state of the store, and it's not thread-safe.
return await self.store.wait_for_rollouts(rollout_ids=rollout_ids, timeout=timeout)
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
with self._lock:
return await self.store.get_next_span_sequence_id(rollout_id, attempt_id)
async def query_spans(
self,
rollout_id: str,
attempt_id: str | Literal["latest"] | None = None,
) -> List[Span]:
with self._lock:
return await self.store.query_spans(rollout_id, attempt_id)
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Unset = UNSET,
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
resources_id: Optional[str] | Unset = UNSET,
status: RolloutStatus | Unset = UNSET,
config: RolloutConfig | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> RolloutV2:
with self._lock:
return await self.store.update_rollout(
rollout_id=rollout_id,
input=input,
mode=mode,
resources_id=resources_id,
status=status,
config=config,
metadata=metadata,
)
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Unset = UNSET,
worker_id: str | Unset = UNSET,
last_heartbeat_time: float | Unset = UNSET,
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
) -> Attempt:
with self._lock:
return await self.store.update_attempt(
rollout_id=rollout_id,
attempt_id=attempt_id,
status=status,
worker_id=worker_id,
last_heartbeat_time=last_heartbeat_time,
metadata=metadata,
)
+126
View File
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
import time
from typing import Awaitable, Callable, List, cast
from agentlightning.types import Attempt, AttemptedRollout, AttemptStatus, RolloutConfig, RolloutStatus, RolloutV2
UpdateRolloutStatus = Callable[[str, RolloutStatus], Awaitable[RolloutV2]]
UpdateAttemptStatus = Callable[[str, str, AttemptStatus], Awaitable[Attempt]]
async def propagate_status(
update_rollout_status: UpdateRolloutStatus, # this should be unlocked
attempt: Attempt,
config: RolloutConfig,
) -> RolloutV2:
"""
Propagate the status of an attempt to the rollout.
The rollout should be made sure in a state to be outdated.
Requeue the rollout if it should be retried.
This operation is completely unlocked. The caller is responsible for locking the store.
"""
# Propagate the status directly to the rollout
if attempt.status == "preparing" or attempt.status == "running" or attempt.status == "succeeded":
return await update_rollout_status(
attempt.rollout_id,
attempt.status,
)
if attempt.status == "failed" or attempt.status == "timeout" or attempt.status == "unresponsive":
# Check if this status should trigger a retry
if attempt.status in config.retry_condition:
# If we haven't exceeded max attempts, retry
if attempt.sequence_id < config.max_attempts:
return await update_rollout_status(
attempt.rollout_id,
"requeuing",
)
# If we can't retry or shouldn't retry, mark as failed
return await update_rollout_status(
attempt.rollout_id,
"failed",
)
raise ValueError(f"Invalid attempt status: {attempt.status}")
async def healthcheck(
rollouts: List[AttemptedRollout],
update_rollout_status: UpdateRolloutStatus,
update_attempt_status: UpdateAttemptStatus,
) -> None:
"""
Perform health check on all running rollouts in the store.
This method should be called periodically to:
1. Update rollout status to failed to succeeded when the attempt is done
2. Check for unresponsive attempts (no heartbeat or spans for a while)
3. Check for timed-out rollouts (running too long since start_time)
4. Update attempt/rollout status accordingly
This operation is completely unlocked. The caller is responsible for locking the store.
Args:
store: The LightningStore instance to check rollouts from
"""
current_time = time.time()
for rollout in rollouts:
config = rollout.config # policy for retry and timeout
# Get the latest attempt for this rollout
latest_attempt = rollout.attempt
if not latest_attempt:
continue
# Check if the attempt has already failed or succeeded
if latest_attempt.status == "failed" or latest_attempt.status == "succeeded":
await propagate_status(update_rollout_status, latest_attempt, config)
continue
# Check for timeout condition (based on attempt start_time, instead of rollout start_time)
if config.timeout_seconds is not None and current_time - latest_attempt.start_time > config.timeout_seconds:
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"timeout",
)
continue
# Check for unresponsive condition (based on last heartbeat)
if latest_attempt.last_heartbeat_time:
if latest_attempt.status == "preparing":
# If still preparing, mark it as running
latest_attempt = await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"running",
)
# Haven't received heartbeat for a while
if (
config.unresponsive_seconds is not None
and current_time - cast(float, latest_attempt.last_heartbeat_time) > config.unresponsive_seconds
):
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"unresponsive",
)
continue
# Check if there's no last heartbeat (no spans) at all
if (
latest_attempt.last_heartbeat_time is None
and config.unresponsive_seconds is not None
and current_time - latest_attempt.start_time > config.unresponsive_seconds
):
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"unresponsive",
)
+2 -1
View File
@@ -2,5 +2,6 @@
from .agentops import AgentOpsTracer
from .base import BaseTracer
from .types import Span
__all__ = ["AgentOpsTracer", "BaseTracer"]
__all__ = ["AgentOpsTracer", "BaseTracer", "Span"]
+231
View File
@@ -0,0 +1,231 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Sequence, Union
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource as OtelResource
from opentelemetry.sdk.trace import Event as OtelEvent
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace.status import Status as OtelStatus
from pydantic import BaseModel
def convert_timestamp(timestamp: Optional[int]) -> Optional[float]:
"""Convert timestamp from nanoseconds to seconds if needed.
Auto-detects format: if > 1e12, assumes nanoseconds; otherwise seconds.
"""
if not timestamp:
return None
return timestamp / 1_000_000_000 if timestamp > 1e12 else timestamp
def extract_extra_fields(src: Any, excluded_fields: List[str]) -> Dict[str, Any]:
"""Extract extra fields from source object, excluding specified fields and private fields."""
excluded_fields_set = set(excluded_fields) | set(["_" + k for k in excluded_fields])
# Exclude the function fields
excluded_fields_set |= set(src.__class__.__dict__.keys())
stripped_dict = {k.lstrip("_"): v for k, v in src.__dict__.items()}
candidates = {k: v for k, v in stripped_dict.items() if k not in excluded_fields_set and not k.startswith("_")}
# This should strip or flatten the unserializable fields
candidates_serialized = json.dumps(candidates, default=str)
return json.loads(candidates_serialized)
AttributeValue = Union[
str,
bool,
int,
float,
Sequence[str],
Sequence[bool],
Sequence[int],
Sequence[float],
]
Attributes = Dict[str, AttributeValue]
TraceState = Dict[str, str]
class SpanContext(BaseModel):
"""Corresponding to opentelemetry.trace.SpanContext"""
trace_id: str
span_id: str
is_remote: bool
trace_state: TraceState
class Config:
allow_extra = True
@classmethod
def from_opentelemetry(cls, src: trace_api.SpanContext) -> "SpanContext":
return cls(
trace_id=trace_api.format_trace_id(src.trace_id),
span_id=trace_api.format_span_id(src.span_id),
is_remote=src.is_remote,
trace_state={k: v for k, v in src.trace_state.items()} if src.trace_state else {},
**extract_extra_fields(src, ["trace_id", "span_id", "is_remote", "trace_state"]),
)
class TraceStatus(BaseModel):
"""Corresponding to opentelemetry.trace.Status"""
status_code: str
description: Optional[str] = None
class Config:
allow_extra = True
@classmethod
def from_opentelemetry(cls, src: OtelStatus) -> "TraceStatus":
return cls(
status_code=src.status_code.name,
description=src.description,
**extract_extra_fields(src, ["status_code", "description"]),
)
class Event(BaseModel):
"""Corresponding to opentelemetry.trace.Event"""
name: str
attributes: Attributes
timestamp: Optional[float] = None
class Config:
allow_extra = True
@classmethod
def from_opentelemetry(cls, src: OtelEvent) -> "Event":
return cls(
name=src.name,
attributes=dict(src.attributes) if src.attributes else {},
timestamp=convert_timestamp(src.timestamp),
**extract_extra_fields(src, ["name", "attributes", "timestamp"]),
)
class Link(BaseModel):
"""Corresponding to opentelemetry.trace.Link"""
context: SpanContext
attributes: Optional[Attributes] = None
class Config:
allow_extra = True
@classmethod
def from_opentelemetry(cls, src: trace_api.Link) -> "Link":
return cls(
context=SpanContext.from_opentelemetry(src.context),
attributes=dict(src.attributes) if src.attributes else None,
**extract_extra_fields(src, ["context", "attributes"]),
)
class Resource(BaseModel):
"""Corresponding to opentelemetry.sdk.resources.Resource"""
attributes: Attributes
schema_url: str
@classmethod
def from_opentelemetry(cls, src: OtelResource) -> "Resource":
return cls(
attributes=dict(src.attributes) if src.attributes else {},
schema_url=src.schema_url if src.schema_url else "",
**extract_extra_fields(src, ["attributes", "schema_url"]),
)
class Span(BaseModel):
class Config:
allow_extra = True # allow extra fields if needed
rollout_id: str
attempt_id: str
# The ID to make spans ordered within a single attempt
sequence_id: int
# Current ID (in hex, formatted via trace_api.format_*)
trace_id: str # one rollout can have traces coming from multiple places
span_id: str
parent_id: Optional[str]
# Core ReadableSpan fields
name: str
status: TraceStatus
attributes: Attributes
events: List[Event]
links: List[Link]
# Timestamps
start_time: Optional[float]
end_time: Optional[float]
# Other parsable fields
context: Optional[SpanContext]
parent: Optional[SpanContext]
resource: Resource
# Preserve other fields in the readable span as extra fields
# Make sure that are json serializable (so no bytes, complex objects, ...)
@classmethod
def from_opentelemetry(
cls,
src: ReadableSpan,
rollout_id: str,
attempt_id: str,
sequence_id: int,
) -> "Span":
context = src.get_span_context()
if context is None:
trace_id = span_id = 0
else:
trace_id = context.trace_id
span_id = context.span_id
return cls(
rollout_id=rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
trace_id=trace_api.format_trace_id(trace_id),
span_id=trace_api.format_span_id(span_id),
parent_id=(trace_api.format_span_id(src.parent.span_id) if src.parent else None),
name=src.name,
status=TraceStatus.from_opentelemetry(src.status),
attributes=dict(src.attributes) if src.attributes else {},
events=[Event.from_opentelemetry(event) for event in src.events] if src.events else [],
links=[Link.from_opentelemetry(link) for link in src.links] if src.links else [],
start_time=convert_timestamp(src.start_time),
end_time=convert_timestamp(src.end_time),
context=SpanContext.from_opentelemetry(context) if context else None,
parent=(SpanContext.from_opentelemetry(src.parent) if src.parent else None),
resource=Resource.from_opentelemetry(src.resource),
**extract_extra_fields(
src,
[
"name",
"context",
"parent",
"resource",
"attributes",
"events",
"links",
"start_time",
"end_time",
"status",
"span_processor",
"rollout_id",
"attempt_id",
"trace_id",
"span_id",
"parent_id",
],
),
)
+95 -2
View File
@@ -2,10 +2,10 @@
from __future__ import annotations
from typing import Annotated, Any, Dict, Generic, List, Literal, Optional, Protocol, TypeVar, Union
from typing import Annotated, Any, Callable, Dict, Generic, List, Literal, Optional, Protocol, TypeVar, Union, cast
from opentelemetry.sdk.trace import ReadableSpan
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
__all__ = [
"Triplet",
@@ -23,6 +23,12 @@ __all__ = [
"GenericResponse",
"ParallelWorkerBase",
"Dataset",
"AttemptStatus",
"RolloutStatus",
"RolloutConfig",
"RolloutV2",
"Attempt",
"AttemptedRollout",
]
T_co = TypeVar("T_co", covariant=True)
@@ -64,6 +70,92 @@ class Rollout(BaseModel):
metadata: Dict[str, Any] = Field(default_factory=dict)
RolloutStatus = Literal[
"queuing", # initial status
"preparing", # after the trace is claimed
"running", # after receiving the first trace
"failed", # crashed
"succeeded", # status OK
"cancelled", # cancelled by user (or watchdog)
"requeuing", # retrying
]
AttemptStatus = Literal[
# A status is essentially a process.
# It should not have scheduling/management statuses like "queuing" or "cancelled".
"preparing",
"running",
"failed",
"succeeded",
"unresponsive", # the worker has not reported results for a while
"timeout", # the worker has been emitting new logs, but have been working on the task for too long
]
class Attempt(BaseModel):
"""An attempt to execute a rollout. A rollout can have multiple attempts if retries are needed."""
rollout_id: str # the rollout this attempt belongs to
attempt_id: str # the universal id for current attempt
sequence_id: int # the sequence number of the attempt, starting from 1
start_time: float # time when the attempt has started
end_time: Optional[float] = None # time when the attempt has ended
status: AttemptStatus = "preparing"
# The rollout worker which is executing this attempt
worker_id: Optional[str] = None
last_heartbeat_time: Optional[float] = None # last time when the worker has reported progress
# A bucket for any other relevant information
metadata: Optional[Dict[str, Any]] = None
class RolloutConfig(BaseModel):
"""Configurations for rollout execution."""
timeout_seconds: Optional[float] = None # none indicates no timeout
unresponsive_seconds: Optional[float] = None # none indicates no unresponsive timeout
max_attempts: int = Field(default=1, ge=1) # including the first attempt
retry_condition: List[AttemptStatus] = Field(
default_factory=cast(Callable[[], List[AttemptStatus]], list)
) # list of statuses that should trigger a retry
class RolloutV2(BaseModel):
rollout_id: str
# Inputs
input: TaskInput
# Time to track the lifecycle of the rollout
start_time: float
end_time: Optional[float] = None
mode: Optional[Literal["train", "val", "test"]] = None
resources_id: Optional[str] = None
# Overall scheduling/running information
status: RolloutStatus = "queuing"
config: RolloutConfig = Field(default_factory=RolloutConfig)
# A bucket for any other relevant information
metadata: Optional[Dict[str, Any]] = None
class AttemptedRollout(RolloutV2):
"""A rollout along with its active attempt."""
attempt: Attempt
@model_validator(mode="after")
def check_consistency(self) -> AttemptedRollout:
if self.attempt.rollout_id != self.rollout_id:
raise ValueError("Inconsistent rollout_id between Rollout and Attempt")
return self
TaskInput = Any
@@ -115,6 +207,7 @@ class LLM(Resource):
resource_type: Literal["llm"] = "llm"
endpoint: str
model: str
api_key: Optional[str] = None
sampling_parameters: Dict[str, Any] = Field(default_factory=dict)
+50
View File
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
import time
from unittest.mock import Mock
import pytest
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.store.memory import InMemoryLightningStore
__all__ = [
"store",
"mock_readable_span",
]
@pytest.fixture
def store() -> InMemoryLightningStore:
"""Create a fresh InMemoryLightningStore instance."""
return InMemoryLightningStore()
@pytest.fixture
def mock_readable_span() -> ReadableSpan:
"""Create a mock ReadableSpan for testing."""
span = Mock()
span.name = "test_span"
# Mock context
context = Mock()
context.trace_id = 111111
context.span_id = 222222
context.is_remote = False
context.trace_state = {} # Make it an empty dict instead of Mock
span.get_span_context = Mock(return_value=context)
# Mock other attributes
span.parent = None
# Fix mock status to return proper string values
status_code_mock = Mock()
status_code_mock.name = "OK"
span.status = Mock(status_code=status_code_mock, description=None)
span.attributes = {"test": "value"}
span.events = []
span.links = []
span.start_time = time.time_ns()
span.end_time = time.time_ns() + 1000000
span.resource = Mock(attributes={}, schema_url="")
return span
+243
View File
@@ -0,0 +1,243 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import socket
from typing import AsyncGenerator, Tuple
from unittest.mock import patch
import pytest
import pytest_asyncio
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.store.base import UNSET
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.tracer import Span
from agentlightning.tracer.types import Resource, TraceStatus
def _get_free_port() -> int:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
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=Resource(attributes={}, schema_url=""),
)
@pytest_asyncio.fixture
async def server_client() -> AsyncGenerator[Tuple[LightningStoreServer, LightningStoreClient], None]:
store = InMemoryLightningStore()
port = _get_free_port()
server = LightningStoreServer(store, "127.0.0.1", port)
await server.start()
client = LightningStoreClient(server.endpoint)
try:
yield server, client
finally:
await client.close()
await server.stop()
@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
await server.start_rollout(input={"origin": "server"})
queued_rollout = await server.enqueue_rollout(input={"origin": "server-queue"})
dequeued = await server.dequeue_rollout()
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
assert await server.get_rollout_by_id(queued_rollout.rollout_id) is not None
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",
metadata={"phase": "warmup"},
)
await server.update_attempt(queued_rollout.rollout_id, "latest", status="succeeded")
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
_attempted = await client.start_rollout(input={"origin": "client"}, mode="train", metadata={"step": 0})
enqueued = await client.enqueue_rollout(input={"origin": "client-queue"})
dequeued_client = await client.dequeue_rollout()
assert dequeued_client is not None
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])
attempts = await client.query_attempts(dequeued_client.rollout_id)
assert attempts
assert await client.get_latest_attempt(dequeued_client.rollout_id) is not None
assert await client.get_rollout_by_id(dequeued_client.rollout_id) is not None
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.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",
metadata={"info": "started"},
)
await client.update_attempt(dequeued_client.rollout_id, "latest", status="succeeded")
await client.update_rollout(dequeued_client.rollout_id, status="succeeded")
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_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
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_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]
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
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Literal, Optional, Sequence
from unittest.mock import MagicMock
import pytest
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.store.base import UNSET, LightningStore
from agentlightning.store.threading import LightningStoreThreaded
from agentlightning.tracer import Span
from agentlightning.tracer.types import Resource, SpanContext, TraceStatus
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
NamedResources,
ResourcesUpdate,
RolloutStatus,
RolloutV2,
TaskInput,
)
class DummyLightningStore(LightningStore):
def __init__(self, return_values: Dict[str, Any]) -> None:
super().__init__()
self.calls: List[tuple[str, tuple[Any, ...], Dict[str, Any]]] = []
self.return_values = return_values
async def start_rollout(
self,
input: TaskInput,
mode: Optional[str] = None,
resources_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> AttemptedRollout:
self.calls.append(("start_rollout", (input, mode, resources_id, metadata), {}))
return self.return_values["start_rollout"]
async def enqueue_rollout(
self,
input: TaskInput,
mode: Optional[str] = None,
resources_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> RolloutV2:
self.calls.append(("enqueue_rollout", (input, mode, resources_id, metadata), {}))
return self.return_values["enqueue_rollout"]
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
self.calls.append(("dequeue_rollout", (), {}))
return self.return_values["dequeue_rollout"]
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
self.calls.append(("start_attempt", (rollout_id,), {}))
return self.return_values["start_attempt"]
async def query_rollouts(
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
) -> List[RolloutV2]:
self.calls.append(("query_rollouts", (), {"status": status, "rollout_ids": rollout_ids}))
return self.return_values["query_rollouts"]
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
self.calls.append(("query_attempts", (rollout_id,), {}))
return self.return_values["query_attempts"]
async def get_rollout_by_id(self, rollout_id: str) -> Optional[RolloutV2]:
self.calls.append(("get_rollout_by_id", (rollout_id,), {}))
return self.return_values["get_rollout_by_id"]
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
self.calls.append(("get_latest_attempt", (rollout_id,), {}))
return self.return_values["get_latest_attempt"]
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
self.calls.append(("update_resources", (resources_id, resources), {}))
return self.return_values["update_resources"]
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
self.calls.append(("get_resources_by_id", (resources_id,), {}))
return self.return_values["get_resources_by_id"]
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
self.calls.append(("get_latest_resources", (), {}))
return self.return_values["get_latest_resources"]
async def add_span(self, span: Span) -> Span:
self.calls.append(("add_span", (span,), {}))
return self.return_values["add_span"]
async def add_otel_span(
self,
rollout_id: str,
attempt_id: str,
readable_span: ReadableSpan,
sequence_id: Optional[int] = None,
) -> Span:
self.calls.append(("add_otel_span", (rollout_id, attempt_id, readable_span, sequence_id), {}))
return self.return_values["add_otel_span"]
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[RolloutV2]:
self.calls.append(("wait_for_rollouts", (), {"rollout_ids": rollout_ids, "timeout": timeout}))
return self.return_values["wait_for_rollouts"]
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
self.calls.append(("get_next_span_sequence_id", (rollout_id, attempt_id), {}))
return self.return_values["get_next_span_sequence_id"]
async def query_spans(
self,
rollout_id: str,
attempt_id: str | Literal["latest"] | None = None,
) -> List[Span]:
self.calls.append(("query_spans", (rollout_id, attempt_id), {}))
return self.return_values["query_spans"]
async def update_rollout(
self,
rollout_id: str,
input: TaskInput | Any = UNSET,
mode: Optional[str] | Any = UNSET,
resources_id: Optional[str] | Any = UNSET,
status: RolloutStatus | Any = UNSET,
config: Any = UNSET,
metadata: Optional[Dict[str, Any]] | Any = UNSET,
) -> RolloutV2:
self.calls.append(
(
"update_rollout",
(rollout_id, input, mode, resources_id, status, config, metadata),
{},
)
)
return self.return_values["update_rollout"]
async def update_attempt(
self,
rollout_id: str,
attempt_id: str | Literal["latest"],
status: AttemptStatus | Any = UNSET,
worker_id: str | Any = UNSET,
last_heartbeat_time: float | Any = UNSET,
metadata: Optional[Dict[str, Any]] | Any = UNSET,
) -> Attempt:
self.calls.append(
(
"update_attempt",
(rollout_id, attempt_id, status, worker_id, last_heartbeat_time, metadata),
{},
)
)
return self.return_values["update_attempt"]
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)
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=Resource(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 = RolloutV2(
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={})
span = make_span(rollout_id, attempt_id)
readable_span = MagicMock(spec=ReadableSpan)
updated_rollout = RolloutV2(
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},
)
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_otel_span": span,
"wait_for_rollouts": [base_rollout],
"get_next_span_sequence_id": 42,
"query_spans": [span],
"update_rollout": updated_rollout,
"update_attempt": updated_attempt,
}
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_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.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
)
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_otel_span",
"wait_for_rollouts",
"get_next_span_sequence_id",
"query_spans",
"update_rollout",
"update_attempt",
]
assert [name for name, *_ in dummy_store.calls] == expected_order
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)}
+325
View File
@@ -0,0 +1,325 @@
# Copyright (c) Microsoft. All rights reserved.
import time
from typing import List, Optional, cast
from unittest.mock import AsyncMock, patch
import pytest
from agentlightning.store.utils import healthcheck, propagate_status
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
RolloutConfig,
)
# Tests for propagate_status function
@pytest.mark.parametrize(
"status,expected_call",
[
("preparing", "preparing"),
("running", "running"),
("succeeded", "succeeded"),
],
)
@pytest.mark.asyncio
async def test_propagate_status_direct_statuses(status: AttemptStatus, expected_call: AttemptStatus) -> None:
"""Test propagate_status directly propagates preparing/running/succeeded statuses."""
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status=status
)
config = RolloutConfig()
update_rollout_mock = AsyncMock()
await propagate_status(update_rollout_mock, attempt, config)
update_rollout_mock.assert_called_once_with("test-rollout", expected_call)
@pytest.mark.parametrize(
"status,in_retry_condition,sequence_id,max_attempts,expected_call",
[
("failed", True, 1, 3, "requeuing"), # Should retry
("failed", True, 3, 3, "failed"), # Max attempts reached
("failed", False, 1, 3, "failed"), # Not in retry condition
("timeout", True, 2, 3, "requeuing"), # Should retry
("timeout", False, 1, 3, "failed"), # Not in retry condition
("unresponsive", True, 1, 2, "requeuing"), # Should retry
],
)
@pytest.mark.asyncio
async def test_propagate_status_retry_logic(
status: AttemptStatus, in_retry_condition: bool, sequence_id: int, max_attempts: int, expected_call: AttemptStatus
) -> None:
"""Test propagate_status retry logic for different combinations."""
attempt = Attempt(
rollout_id="test-rollout",
attempt_id="test-attempt",
sequence_id=sequence_id,
start_time=time.time(),
status=status,
)
retry_condition: List[AttemptStatus] = [status] if in_retry_condition else []
config = RolloutConfig(max_attempts=max_attempts, retry_condition=retry_condition)
update_rollout_mock = AsyncMock()
await propagate_status(update_rollout_mock, attempt, config)
update_rollout_mock.assert_called_once_with("test-rollout", expected_call)
@pytest.mark.asyncio
async def test_propagate_status_invalid_status() -> None:
"""Test propagate_status raises error for invalid status."""
# Create a valid attempt first, then modify its status
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status="failed"
)
# Bypass Pydantic validation by directly setting the attribute
attempt.status = cast(AttemptStatus, "invalid_status") # Invalid status
config = RolloutConfig()
update_rollout_mock = AsyncMock()
with pytest.raises(ValueError, match="Invalid attempt status: invalid_status"):
await propagate_status(update_rollout_mock, attempt, config)
# Tests for healthcheck function
@pytest.mark.asyncio
async def test_healthcheck_empty_rollouts_list() -> None:
"""Test healthcheck handles empty rollouts list gracefully."""
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
await healthcheck([], update_rollout_mock, update_attempt_mock)
# Should not call any updates
update_rollout_mock.assert_not_called()
update_attempt_mock.assert_not_called()
@pytest.mark.asyncio
async def test_healthcheck_multiple_rollouts_different_timeouts() -> None:
"""Test healthcheck handles multiple rollouts with different timeout configs."""
current_time = time.time()
# Rollout 1: Short timeout, should timeout
config1 = RolloutConfig(timeout_seconds=1.0)
attempt1 = Attempt(
rollout_id="rollout-1",
attempt_id="attempt-1",
sequence_id=1,
start_time=current_time - 2.0,
status="running", # 2 seconds ago
)
rollout1 = AttemptedRollout(
rollout_id="rollout-1",
input={"test": 1},
status="running",
start_time=current_time,
config=config1,
attempt=attempt1,
)
# Rollout 2: Long timeout, should not timeout
config2 = RolloutConfig(timeout_seconds=10.0)
attempt2 = Attempt(
rollout_id="rollout-2",
attempt_id="attempt-2",
sequence_id=1,
start_time=current_time - 2.0,
status="running", # 2 seconds ago
)
rollout2 = AttemptedRollout(
rollout_id="rollout-2",
input={"test": 2},
status="running",
start_time=current_time,
config=config2,
attempt=attempt2,
)
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
with patch("time.time", return_value=current_time):
await healthcheck([rollout1, rollout2], update_rollout_mock, update_attempt_mock)
# Only rollout1 should be marked as timeout
update_attempt_mock.assert_called_once_with("rollout-1", "attempt-1", "timeout")
@pytest.mark.parametrize(
"timeout_seconds,unresponsive_seconds,should_timeout,should_unresponsive",
[
(None, None, False, False), # No timeouts configured
(None, 1.0, False, True), # Only unresponsive timeout
(1.0, None, True, False), # Only regular timeout
(0.5, 1.0, True, False), # Timeout triggers first
(2.0, 0.5, False, True), # Unresponsive triggers first
],
)
@pytest.mark.asyncio
async def test_healthcheck_timeout_configurations(
timeout_seconds: Optional[float],
unresponsive_seconds: Optional[float],
should_timeout: bool,
should_unresponsive: bool,
) -> None:
"""Test healthcheck with various timeout configurations."""
current_time = time.time()
config = RolloutConfig(timeout_seconds=timeout_seconds, unresponsive_seconds=unresponsive_seconds)
attempt = Attempt(
rollout_id="test-rollout",
attempt_id="test-attempt",
sequence_id=1,
start_time=current_time - 1.5,
status="running", # 1.5 seconds ago
last_heartbeat_time=None, # No heartbeat for unresponsive detection
)
rollout = AttemptedRollout(
rollout_id="test-rollout",
input={"test": 1},
status="running",
start_time=current_time,
config=config,
attempt=attempt,
)
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
with patch("time.time", return_value=current_time):
await healthcheck([rollout], update_rollout_mock, update_attempt_mock)
if should_timeout:
update_attempt_mock.assert_called_once_with("test-rollout", "test-attempt", "timeout")
elif should_unresponsive:
update_attempt_mock.assert_called_once_with("test-rollout", "test-attempt", "unresponsive")
else:
update_attempt_mock.assert_not_called()
@pytest.mark.asyncio
async def test_healthcheck_unresponsive_with_heartbeat_timing() -> None:
"""Test unresponsive detection considers heartbeat timing correctly."""
current_time = time.time()
config = RolloutConfig(unresponsive_seconds=1.0)
# Case 1: Recent heartbeat - should not be unresponsive
attempt_recent = Attempt(
rollout_id="rollout-recent",
attempt_id="attempt-recent",
sequence_id=1,
start_time=current_time - 5.0,
status="running",
last_heartbeat_time=current_time - 0.5, # Recent heartbeat
)
rollout_recent = AttemptedRollout(
rollout_id="rollout-recent",
input={"test": 1},
status="running",
start_time=current_time,
config=config,
attempt=attempt_recent,
)
# Case 2: Old heartbeat - should be unresponsive
attempt_old = Attempt(
rollout_id="rollout-old",
attempt_id="attempt-old",
sequence_id=1,
start_time=current_time - 5.0,
status="running",
last_heartbeat_time=current_time - 2.0, # Old heartbeat
)
rollout_old = AttemptedRollout(
rollout_id="rollout-old",
input={"test": 2},
status="running",
start_time=current_time,
config=config,
attempt=attempt_old,
)
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
with patch("time.time", return_value=current_time):
await healthcheck([rollout_recent, rollout_old], update_rollout_mock, update_attempt_mock)
# Only the old heartbeat should trigger unresponsive
update_attempt_mock.assert_called_once_with("rollout-old", "attempt-old", "unresponsive")
@pytest.mark.asyncio
async def test_healthcheck_preparing_with_heartbeat_promotion() -> None:
"""Test healthcheck promotes preparing attempts with heartbeat to running."""
current_time = time.time()
config = RolloutConfig()
attempt = Attempt(
rollout_id="test-rollout",
attempt_id="test-attempt",
sequence_id=1,
start_time=current_time,
status="preparing",
last_heartbeat_time=current_time, # Has heartbeat
)
rollout = AttemptedRollout(
rollout_id="test-rollout",
input={"test": 1},
status="preparing",
start_time=current_time,
config=config,
attempt=attempt,
)
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
await healthcheck([rollout], update_rollout_mock, update_attempt_mock)
# Should promote to running
update_attempt_mock.assert_called_once_with("test-rollout", "test-attempt", "running")
@pytest.mark.asyncio
async def test_healthcheck_skips_rollouts_without_attempts() -> None:
"""Test healthcheck gracefully skips rollouts with no attempts."""
config = RolloutConfig()
# Create a valid attempt first, then set it to None
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status="running"
)
rollout = AttemptedRollout(
rollout_id="test-rollout",
input={"test": 1},
status="running",
start_time=time.time(),
config=config,
attempt=attempt,
)
# Bypass Pydantic validation by directly setting the attribute
rollout.attempt = cast(Attempt, None) # No attempt
update_rollout_mock = AsyncMock()
update_attempt_mock = AsyncMock()
await healthcheck([rollout], update_rollout_mock, update_attempt_mock)
# Should not call any updates
update_rollout_mock.assert_not_called()
update_attempt_mock.assert_not_called()