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

1416 lines
53 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from contextlib import asynccontextmanager
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
Generic,
List,
Mapping,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
cast,
)
import aiologic
from agentlightning.utils.metrics import MetricsBackend
if TYPE_CHECKING:
from typing import Self
from pydantic import BaseModel, TypeAdapter
from pymongo import AsyncMongoClient, ReadPreference, ReturnDocument, WriteConcern
from pymongo.asynchronous.client_session import AsyncClientSession
from pymongo.asynchronous.collection import AsyncCollection
from pymongo.asynchronous.database import AsyncDatabase
from pymongo.errors import (
BulkWriteError,
CollectionInvalid,
ConnectionFailure,
DuplicateKeyError,
OperationFailure,
PyMongoError,
)
from pymongo.read_concern import ReadConcern
from agentlightning.types import (
Attempt,
FilterOptions,
PaginatedResult,
ResourcesUpdate,
Rollout,
SortOptions,
Span,
Worker,
)
from .base import (
AtomicLabels,
AtomicMode,
Collection,
DuplicatedPrimaryKeyError,
KeyValue,
LightningCollections,
Queue,
ensure_numeric,
normalize_filter_options,
resolve_sort_options,
tracked,
)
T_model = TypeVar("T_model", bound=BaseModel)
T_generic = TypeVar("T_generic")
T_mapping = TypeVar("T_mapping", bound=Mapping[str, Any])
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
K = TypeVar("K")
V = TypeVar("V")
logger = logging.getLogger(__name__)
def resolve_mongo_error_type(exc: BaseException | None) -> str | None:
is_transient = isinstance(exc, PyMongoError) and exc.has_error_label("TransientTransactionError")
if isinstance(exc, OperationFailure):
if is_transient:
return f"OperationFailure-{exc.code}-Transient"
else:
return f"OperationFailure-{exc.code}"
if isinstance(exc, DuplicateKeyError):
return "DuplicateKeyError-Transient" if is_transient else "DuplicateKeyError"
if isinstance(exc, PyMongoError):
if is_transient:
return f"{exc.__class__.__name__}-Transient"
else:
return exc.__class__.__name__
if isinstance(exc, ConnectionFailure):
return "ConnectionFailure-Transient" if is_transient else "ConnectionFailure"
if is_transient:
return "Other-Transient"
else:
return None
def _field_ops_to_conditions(field: str, ops: Mapping[str, Any]) -> List[Dict[str, Any]]:
"""Convert a FilterField (ops) into one or more Mongo conditions."""
conditions: List[Dict[str, Any]] = []
for op_name, raw_value in ops.items():
if op_name == "exact":
if raw_value is None:
logger.debug(f"Skipping exact filter for field '{field}' with None value")
continue
conditions.append({field: raw_value})
elif op_name == "within":
if raw_value is None:
logger.debug(f"Skipping within filter for field '{field}' with None value")
continue
try:
iterable = list(raw_value)
except TypeError as exc:
raise ValueError(f"Invalid iterable for within filter for field '{field}': {raw_value!r}") from exc
conditions.append({field: {"$in": iterable}})
elif op_name == "contains":
if raw_value is None:
logger.debug(f"Skipping contains filter for field '{field}' with None value")
continue
value = str(raw_value)
pattern = f".*{re.escape(value)}.*"
conditions.append({field: {"$regex": pattern, "$options": "i"}})
else:
raise ValueError(f"Unsupported filter operator '{op_name}' for field '{field}'")
return conditions
def _build_mongo_filter(filter_options: Optional[FilterOptions]) -> Dict[str, Any]:
"""Translate FilterOptions into a MongoDB filter dict."""
normalized, must_filters, aggregate = normalize_filter_options(filter_options)
regular_conditions: List[Dict[str, Any]] = []
must_conditions: List[Dict[str, Any]] = []
# Normal filters
if normalized:
for field_name, ops in normalized.items():
regular_conditions.extend(_field_ops_to_conditions(field_name, ops))
# Must filters
if must_filters:
for field_name, ops in must_filters.items():
must_conditions.extend(_field_ops_to_conditions(field_name, ops))
# No filters at all
if not regular_conditions and not must_conditions:
return {}
# Aggregate logic for regular conditions; _must always ANDs in.
if aggregate == "and":
all_conds = regular_conditions + must_conditions
if len(all_conds) == 1:
return all_conds[0]
return {"$and": all_conds}
# aggregate == "or"
if regular_conditions and must_conditions:
# (OR of regular) AND (all must)
if len(regular_conditions) == 1:
or_part: Dict[str, Any] = regular_conditions[0]
else:
or_part = {"$or": regular_conditions}
and_parts: List[Dict[str, Any]] = [or_part] + must_conditions
if len(and_parts) == 1:
return and_parts[0]
return {"$and": and_parts}
if regular_conditions:
if len(regular_conditions) == 1:
return regular_conditions[0]
return {"$or": regular_conditions}
# Only must conditions
if len(must_conditions) == 1:
return must_conditions[0]
return {"$and": must_conditions}
async def _ensure_collection(
db: AsyncDatabase[Mapping[str, Any]],
collection_name: str,
primary_keys: Optional[Sequence[str]] = None,
extra_indexes: Optional[Sequence[Sequence[str]]] = None,
) -> bool:
"""Ensure the backing MongoDB collection exists.
This method is idempotent and safe to call multiple times.
"""
# Create collection if it doesn't exist yet
try:
await db.create_collection(collection_name)
except CollectionInvalid as exc:
# Thrown if collection already exists
logger.debug(f"Collection '{collection_name}' may have already existed. No need to create it: {exc!r}")
except OperationFailure as exc:
logger.debug(f"Failed to create collection '{collection_name}'. Probably already exists: {exc!r}")
# Some servers use OperationFailure w/ specific codes for "NamespaceExists"
if exc.code in (48, 68): # 48: NamespaceExists, 68: already exists on older versions
pass
else:
raise
# Optionally create a unique index on primary keys (scoped by partition_id)
if primary_keys:
# Always include the partition field in the unique index.
keys = [("partition_id", 1)] + [(pk, 1) for pk in primary_keys]
try:
await db[collection_name].create_index(keys, name=f"uniq_partition_{'_'.join(primary_keys)}", unique=True)
except OperationFailure as exc:
logger.debug(f"Index for collection '{collection_name}' already exists. No need to create it: {exc!r}")
# Ignore "index already exists" type errors
if exc.code in (68, 85): # IndexOptionsConflict, etc.
pass
else:
raise
# Optionally create extra indexes
if extra_indexes:
for index in extra_indexes:
try:
await db[collection_name].create_index(index, name=f"idx_{'_'.join(index)}")
except OperationFailure as exc:
logger.debug(f"Index for collection '{collection_name}' already exists. No need to create it: {exc!r}")
# Ignore "index already exists" type errors
if exc.code in (68, 85): # IndexOptionsConflict, etc.
pass
else:
raise
return True
class MongoClientPool(Generic[T_mapping]):
"""A pool of MongoDB clients, each bound to a specific event loop.
The pool lazily creates `AsyncMongoClient` instances per event loop using the provided
connection parameters, ensuring we never try to reuse a client across loops.
"""
def __init__(self, *, mongo_uri: str, mongo_client_kwargs: Mapping[str, Any] | None = None):
self._get_collection_lock = aiologic.Lock()
self._get_client_lock = aiologic.Lock()
self._mongo_uri = mongo_uri
self._mongo_client_kwargs = dict(mongo_client_kwargs or {})
self._client_pool: Dict[int, AsyncMongoClient[T_mapping]] = {}
self._collection_pool: Dict[Tuple[int, str, str], AsyncCollection[T_mapping]] = {}
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None:
await self.close()
async def close(self) -> None:
"""Close all clients currently tracked by the pool."""
async with self._get_client_lock, self._get_collection_lock:
clients = list(self._client_pool.values())
self._client_pool.clear()
self._collection_pool.clear()
for client in clients:
try:
await client.close()
except Exception:
logger.exception("Error closing MongoDB client: %s", client)
async def get_client(self) -> AsyncMongoClient[T_mapping]:
loop = asyncio.get_running_loop()
key = id(loop)
# If there is already a client specifically for this loop, return it.
existing = self._client_pool.get(key)
if existing is not None:
await existing.aconnect() # This actually does nothing if the client is already connected.
return existing
async with self._get_client_lock:
# Another coroutine may have already created the client.
if key in self._client_pool:
await self._client_pool[key].aconnect()
return self._client_pool[key]
# Create a new client for this loop.
client = AsyncMongoClient[T_mapping](self._mongo_uri, **self._mongo_client_kwargs)
await client.aconnect()
self._client_pool[key] = client
return client
async def get_collection(self, database_name: str, collection_name: str) -> AsyncCollection[T_mapping]:
loop = asyncio.get_running_loop()
key = (id(loop), database_name, collection_name)
if key in self._collection_pool:
return self._collection_pool[key]
async with self._get_collection_lock:
# Another coroutine may have already created the collection.
if key in self._collection_pool:
return self._collection_pool[key]
# Create a new collection for this loop.
client = await self.get_client()
collection = client[database_name][collection_name]
self._collection_pool.setdefault(key, collection)
return collection
class MongoBasedCollection(Collection[T_model]):
"""Mongo-based implementation of Collection.
Args:
client_pool: The pool of MongoDB clients.
database_name: The name of the database.
collection_name: The name of the collection.
partition_id: The partition ID. Used to partition the collection into multiple collections.
primary_keys: The primary keys of the collection.
item_type: The type of the items in the collection.
extra_indexes: The extra indexes to create on the collection.
tracker: The metrics tracker to use.
"""
def __init__(
self,
client_pool: MongoClientPool[Mapping[str, Any]],
database_name: str,
collection_name: str,
partition_id: str,
primary_keys: Sequence[str],
item_type: Type[T_model],
extra_indexes: Sequence[Sequence[str]] = [],
tracker: MetricsBackend | None = None,
):
super().__init__(tracker=tracker)
self._client_pool = client_pool
self._database_name = database_name
self._collection_name = collection_name
self._partition_id = partition_id
self._collection_created = False
self._extra_indexes = [list(index) for index in extra_indexes]
self._session: Optional[AsyncClientSession] = None
if not primary_keys:
raise ValueError("primary_keys must be non-empty")
self._primary_keys = list(primary_keys)
if not issubclass(item_type, BaseModel): # type: ignore
raise ValueError(f"item_type must be a subclass of BaseModel, got {item_type.__name__}")
self._item_type = item_type
@property
def collection_name(self) -> str:
return self._collection_name
@property
def extra_tracking_labels(self) -> Mapping[str, str]:
return {
"database": self._database_name,
}
@tracked("ensure_collection")
async def ensure_collection(self) -> AsyncCollection[Mapping[str, Any]]:
"""Ensure the backing MongoDB collection exists (and optionally its indexes).
This method is idempotent and safe to call multiple times.
It will also create a unique index across the configured primary key fields.
"""
if not self._collection_created:
client = await self._client_pool.get_client()
self._collection_created = await _ensure_collection(
client[self._database_name], self._collection_name, self._primary_keys, self._extra_indexes
)
return await self._client_pool.get_collection(self._database_name, self._collection_name)
def with_session(self, session: AsyncClientSession) -> MongoBasedCollection[T_model]:
"""Create a new collection with the same configuration but a new session."""
collection = MongoBasedCollection(
client_pool=self._client_pool,
database_name=self._database_name,
collection_name=self._collection_name,
partition_id=self._partition_id,
primary_keys=self._primary_keys,
item_type=self._item_type,
extra_indexes=self._extra_indexes,
tracker=self._tracker,
)
collection._collection_created = self._collection_created
collection._session = session
return collection
def primary_keys(self) -> Sequence[str]:
"""Return the primary key field names for this collection."""
return self._primary_keys
def item_type(self) -> Type[T_model]:
return self._item_type
@tracked("size")
async def size(self) -> int:
collection = await self.ensure_collection()
return await collection.count_documents({"partition_id": self._partition_id}, session=self._session)
def _pk_filter(self, item: T_model) -> Dict[str, Any]:
"""Build a Mongo filter for the primary key(s) of a model instance."""
data = item.model_dump()
missing = [pk for pk in self._primary_keys if pk not in data]
if missing:
raise ValueError(f"Missing primary key fields {missing} on item {item!r}")
pk_filter: Dict[str, Any] = {"partition_id": self._partition_id}
pk_filter.update({pk: data[pk] for pk in self._primary_keys})
return pk_filter
def _render_pk_values(self, values: Sequence[Any]) -> str:
return ", ".join(f"{pk}={value!r}" for pk, value in zip(self._primary_keys, values))
def _ensure_item_type(self, item: T_model) -> None:
if not isinstance(item, self._item_type):
raise TypeError(f"Expected item of type {self._item_type.__name__}, got {type(item).__name__}")
def _inject_partition_filter(self, filter: Optional[FilterOptions]) -> Dict[str, Any]:
"""Ensure every query is scoped to this collection's partition."""
combined: Dict[str, Any]
if filter is None:
combined = {}
else:
combined = dict(filter)
partition_must = {"partition_id": {"exact": self._partition_id}}
existing_must = combined.get("_must")
if existing_must is None:
combined["_must"] = partition_must
return combined
if isinstance(existing_must, Mapping):
combined["_must"] = [existing_must, partition_must]
elif isinstance(existing_must, Sequence) and not isinstance(existing_must, (str, bytes)):
combined["_must"] = [*existing_must, partition_must]
else:
raise TypeError("`_must` filters must be a mapping or sequence of mappings")
return combined
def _model_validate_item(self, raw: Mapping[str, Any]) -> T_model:
item_type_has_id = "_id" in self._item_type.model_fields
# Remove _id from the raw document if the item type does not have it.
if not item_type_has_id:
raw = {k: v for k, v in raw.items() if k != "_id"}
# Convert Mongo document to Pydantic model
return self._item_type.model_validate(raw) # type: ignore[arg-type]
@tracked("query")
async def query(
self,
filter: Optional[FilterOptions] = None,
sort: Optional[SortOptions] = None,
limit: int = -1,
offset: int = 0,
) -> PaginatedResult[T_model]:
"""Mongo-based implementation of Collection.query.
The handling of null-values in sorting is different from memory-based implementation.
In MongoDB, null values are treated as less than non-null values.
"""
collection = await self.ensure_collection()
combined = self._inject_partition_filter(filter)
mongo_filter = _build_mongo_filter(cast(FilterOptions, combined))
total = await collection.count_documents(mongo_filter, session=self._session)
if limit == 0:
return PaginatedResult[T_model](items=[], limit=0, offset=offset, total=total)
cursor = collection.find(mongo_filter, session=self._session)
sort_name, sort_order = resolve_sort_options(sort)
if sort_name is not None:
model_fields = getattr(self._item_type, "model_fields", {})
if sort_name not in model_fields:
raise ValueError(
f"Failed to sort items by '{sort_name}': field does not exist on {self._item_type.__name__}"
)
direction = 1 if sort_order == "asc" else -1
cursor = cursor.sort(sort_name, direction)
if offset > 0:
cursor = cursor.skip(offset)
if limit >= 0:
cursor = cursor.limit(limit)
items: List[T_model] = []
async for raw in cursor:
items.append(self._model_validate_item(raw))
return PaginatedResult[T_model](items=items, limit=limit, offset=offset, total=total)
@tracked("get")
async def get(
self,
filter: Optional[FilterOptions] = None,
sort: Optional[SortOptions] = None,
) -> Optional[T_model]:
collection = await self.ensure_collection()
combined = self._inject_partition_filter(filter)
mongo_filter = _build_mongo_filter(cast(FilterOptions, combined))
sort_name, sort_order = resolve_sort_options(sort)
mongo_sort: Optional[List[Tuple[str, int]]] = None
if sort_name is not None:
model_fields = getattr(self._item_type, "model_fields", {})
if sort_name not in model_fields:
raise ValueError(
f"Failed to sort items by '{sort_name}': field does not exist on {self._item_type.__name__}"
)
direction = 1 if sort_order == "asc" else -1
mongo_sort = [(sort_name, direction)]
raw = await collection.find_one(mongo_filter, sort=mongo_sort, session=self._session)
if raw is None:
return None
return self._model_validate_item(raw)
@tracked("insert")
async def insert(self, items: Sequence[T_model]) -> None:
"""Insert items into the collection.
The implementation does NOT do checks for duplicate primary keys,
neither within the same insert call nor across different insert calls.
It relies on the database to enforce uniqueness via indexes.
"""
if not items:
return
collection = await self.ensure_collection()
docs: List[Mapping[str, Any]] = []
for item in items:
self._ensure_item_type(item)
doc = item.model_dump()
doc["partition_id"] = self._partition_id
docs.append(doc)
if not docs:
return
try:
async with self.tracking_context("insert.insert_many", self._collection_name):
await collection.insert_many(docs, session=self._session)
except DuplicateKeyError as exc:
# In case the DB enforces uniqueness via index, normalize to ValueError
raise DuplicatedPrimaryKeyError("Duplicated primary key(s) while inserting items") from exc
except BulkWriteError as exc:
write_errors = exc.details.get("writeErrors", [])
if write_errors and write_errors[0].get("code") == 11000:
raise DuplicatedPrimaryKeyError("Duplicated primary key(s) while inserting items") from exc
raise
@tracked("update")
async def update(self, items: Sequence[T_model], update_fields: Sequence[str] | None = None) -> List[T_model]:
if not items:
return []
updated_items: List[T_model] = []
collection = await self.ensure_collection()
for item in items:
self._ensure_item_type(item)
pk_filter = self._pk_filter(item)
doc = item.model_dump()
doc["partition_id"] = self._partition_id
updated_doc = None
# Branch 1: Full Replace
if update_fields is None:
async with self.tracking_context("update.find_one_and_replace", self._collection_name):
updated_doc = await collection.find_one_and_replace(
filter=pk_filter,
replacement=doc,
session=self._session,
return_document=ReturnDocument.AFTER, # Returns the new version
)
# Branch 2: Partial Update
else:
update_doc = {field: doc[field] for field in update_fields if field in doc}
async with self.tracking_context("update.find_one_and_update", self._collection_name):
updated_doc = await collection.find_one_and_update(
filter=pk_filter,
update={"$set": update_doc},
session=self._session,
return_document=ReturnDocument.AFTER, # Returns the new version
)
# Validation and Reconstruction
if updated_doc is None: # type: ignore
raise ValueError(f"Item with primary key(s) {pk_filter} does not exist")
# Re-instantiate the model from the raw MongoDB dictionary.
new_item = self._model_validate_item(updated_doc)
updated_items.append(new_item)
return updated_items
@tracked("upsert")
async def upsert(self, items: Sequence[T_model], update_fields: Sequence[str] | None = None) -> List[T_model]:
if not items:
return []
upserted_items: List[T_model] = []
collection = await self.ensure_collection()
for item in items:
self._ensure_item_type(item)
pk_filter = self._pk_filter(item)
insert_doc = item.model_dump()
insert_doc["partition_id"] = self._partition_id
# If update_fields is None, we update ALL fields (standard upsert behavior).
# Otherwise, we only update specific fields, but insert the full doc if it's new.
target_fields = update_fields if update_fields is not None else list(insert_doc.keys())
# 1. $set: Fields that should be overwritten if the document exists
update_subset = {field: insert_doc[field] for field in target_fields if field in insert_doc}
# 2. $setOnInsert: Fields that are only set if we are creating a NEW document
# (Everything in the model that isn't in the update_subset)
set_on_insert = {k: v for k, v in insert_doc.items() if k not in update_subset}
update_spec: Dict[str, Dict[str, Any]] = {}
if set_on_insert:
update_spec["$setOnInsert"] = set_on_insert
if update_subset:
update_spec["$set"] = update_subset
async with self.tracking_context("upsert.find_one_and_update", self._collection_name):
result_doc = await collection.find_one_and_update(
filter=pk_filter,
update=update_spec,
upsert=True,
session=self._session,
return_document=ReturnDocument.AFTER,
)
if result_doc is None: # pyright: ignore[reportUnnecessaryComparison]
raise RuntimeError(f"Upsert resulted in no document for filter: {pk_filter}")
# Because upsert=True, result_doc is guaranteed to be not None
new_item = self._model_validate_item(result_doc)
upserted_items.append(new_item)
return upserted_items
@tracked("delete")
async def delete(self, items: Sequence[T_model]) -> None:
if not items:
return
collection = await self.ensure_collection()
for item in items:
self._ensure_item_type(item)
pk_filter = self._pk_filter(item)
async with self.tracking_context("delete.delete_one", self._collection_name):
result = await collection.delete_one(pk_filter, session=self._session)
if result.deleted_count == 0:
raise ValueError(f"Item with primary key(s) {pk_filter} does not exist")
class MongoBasedQueue(Queue[T_generic], Generic[T_generic]):
"""Mongo-based implementation of Queue backed by a MongoDB collection.
Items are stored append-only; dequeue marks items as consumed instead of deleting them.
"""
def __init__(
self,
client_pool: MongoClientPool[Mapping[str, Any]],
database_name: str,
collection_name: str,
partition_id: str,
item_type: Type[T_generic],
tracker: MetricsBackend | None = None,
) -> None:
"""
Args:
client_pool: The pool of MongoDB clients.
database_name: The name of the database.
collection_name: The name of the collection backing the queue.
partition_id: Partition identifier; allows multiple logical queues in one collection.
item_type: The Python type of queue items (primitive or BaseModel subclass).
"""
super().__init__(tracker=tracker)
self._client_pool = client_pool
self._database_name = database_name
self._collection_name = collection_name
self._partition_id = partition_id
self._item_type = item_type
self._adapter: TypeAdapter[T_generic] = TypeAdapter(item_type)
self._collection_created = False
self._session: Optional[AsyncClientSession] = None
def item_type(self) -> Type[T_generic]:
return self._item_type
@property
def extra_tracking_labels(self) -> Mapping[str, str]:
return {
"database": self._database_name,
}
@property
def collection_name(self) -> str:
return self._collection_name
@tracked("ensure_collection")
async def ensure_collection(self) -> AsyncCollection[Mapping[str, Any]]:
"""Ensure the backing collection exists.
If it already exists, it returns the existing collection.
"""
if not self._collection_created:
client = await self._client_pool.get_client()
self._collection_created = await _ensure_collection(
client[self._database_name], self._collection_name, primary_keys=["consumed", "_id"]
)
return await self._client_pool.get_collection(self._database_name, self._collection_name)
def with_session(self, session: AsyncClientSession) -> MongoBasedQueue[T_generic]:
queue = MongoBasedQueue(
client_pool=self._client_pool,
database_name=self._database_name,
collection_name=self._collection_name,
partition_id=self._partition_id,
item_type=self._item_type,
tracker=self._tracker,
)
queue._collection_created = self._collection_created
queue._session = session
return queue
@tracked("has")
async def has(self, item: T_generic) -> bool:
collection = await self.ensure_collection()
encoded = self._adapter.dump_python(item, mode="python")
doc = await collection.find_one(
{
"partition_id": self._partition_id,
"consumed": False,
"value": encoded,
},
session=self._session,
)
return doc is not None
@tracked("enqueue")
async def enqueue(self, items: Sequence[T_generic]) -> Sequence[T_generic]:
if not items:
return []
collection = await self.ensure_collection()
docs: List[Mapping[str, Any]] = []
for item in items:
if not isinstance(item, self._item_type):
raise TypeError(f"Expected item of type {self._item_type.__name__}, got {type(item).__name__}")
docs.append(
{
"partition_id": self._partition_id,
"value": self._adapter.dump_python(item, mode="python"),
"consumed": False,
"created_at": datetime.now(),
}
)
async with self.tracking_context("enqueue.insert_many", self.collection_name):
await collection.insert_many(docs, session=self._session)
return list(items)
@tracked("dequeue")
async def dequeue(self, limit: int = 1) -> Sequence[T_generic]:
if limit <= 0:
return []
collection = await self.ensure_collection()
results: list[T_generic] = []
# Atomic claim loop using find_one_and_update
for _ in range(limit):
async with self.tracking_context("dequeue.find_one_and_update", self.collection_name):
doc = await collection.find_one_and_update(
{
"partition_id": self._partition_id,
"consumed": False,
},
{"$set": {"consumed": True}},
sort=[("_id", 1)], # FIFO using insertion order
return_document=True,
session=self._session,
)
if doc is None: # type: ignore
# No more items to dequeue
break
raw_value = doc["value"]
item = self._adapter.validate_python(raw_value)
results.append(item)
return results
@tracked("peek")
async def peek(self, limit: int = 1) -> Sequence[T_generic]:
if limit <= 0:
return []
collection = await self.ensure_collection()
async with self.tracking_context("peek.find", self.collection_name):
cursor = (
collection.find(
{
"partition_id": self._partition_id,
"consumed": False,
},
session=self._session,
)
.sort("_id", 1)
.limit(limit)
)
items: list[T_generic] = []
async for doc in cursor:
raw_value = doc["value"]
items.append(self._adapter.validate_python(raw_value))
return items
@tracked("size")
async def size(self) -> int:
collection = await self.ensure_collection()
return await collection.count_documents(
{
"partition_id": self._partition_id,
"consumed": False,
},
session=self._session,
)
class MongoBasedKeyValue(KeyValue[K, V], Generic[K, V]):
"""Mongo-based implementation of KeyValue."""
def __init__(
self,
client_pool: MongoClientPool[Mapping[str, Any]],
database_name: str,
collection_name: str,
partition_id: str,
key_type: Type[K],
value_type: Type[V],
tracker: MetricsBackend | None = None,
) -> None:
"""
Args:
client_pool: The pool of MongoDB clients.
database_name: The name of the database.
collection_name: The name of the collection backing the key-value store.
partition_id: Partition identifier; allows multiple logical maps in one collection.
key_type: The Python type of keys (primitive or BaseModel).
value_type: The Python type of values (primitive or BaseModel).
tracker: The metrics tracker to use.
"""
super().__init__(tracker=tracker)
self._client_pool = client_pool
self._database_name = database_name
self._collection_name = collection_name
self._partition_id = partition_id
self._key_type = key_type
self._value_type = value_type
self._key_adapter: TypeAdapter[K] = TypeAdapter(key_type)
self._value_adapter: TypeAdapter[V] = TypeAdapter(value_type)
self._collection_created = False
self._session: Optional[AsyncClientSession] = None
@property
def extra_tracking_labels(self) -> Mapping[str, str]:
return {
"database": self._database_name,
}
@property
def collection_name(self) -> str:
return self._collection_name
@tracked("ensure_collection")
async def ensure_collection(self, *, create_indexes: bool = True) -> AsyncCollection[Mapping[str, Any]]:
"""Ensure the backing collection exists (and optionally its indexes)."""
if not self._collection_created:
client = await self._client_pool.get_client()
self._collection_created = await _ensure_collection(
client[self._database_name], self._collection_name, primary_keys=["key"]
)
return await self._client_pool.get_collection(self._database_name, self._collection_name)
def with_session(self, session: AsyncClientSession) -> MongoBasedKeyValue[K, V]:
key_value = MongoBasedKeyValue(
client_pool=self._client_pool,
database_name=self._database_name,
collection_name=self._collection_name,
partition_id=self._partition_id,
key_type=self._key_type,
value_type=self._value_type,
tracker=self._tracker,
)
key_value._collection_created = self._collection_created
key_value._session = session
return key_value
@tracked("has")
async def has(self, key: K) -> bool:
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
doc = await collection.find_one(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
session=self._session,
)
return doc is not None
@tracked("get")
async def get(self, key: K, default: V | None = None) -> V | None:
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
doc = await collection.find_one(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
session=self._session,
)
if doc is None:
return default
raw_value = doc["value"]
return self._value_adapter.validate_python(raw_value)
@tracked("set")
async def set(self, key: K, value: V) -> None:
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
encoded_value = self._value_adapter.dump_python(value, mode="python")
try:
async with self.tracking_context("set.replace_one", self.collection_name):
await collection.replace_one(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
{
"partition_id": self._partition_id,
"key": encoded_key,
"value": encoded_value,
},
upsert=True,
session=self._session,
)
except DuplicateKeyError as exc:
# Very unlikely with replace_one+upsert, but normalize anyway.
raise DuplicatedPrimaryKeyError("Duplicate key error while setting key-value item") from exc
@tracked("inc")
async def inc(self, key: K, amount: V) -> V:
assert ensure_numeric(amount, description="amount")
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
encoded_amount = self._value_adapter.dump_python(amount, mode="python")
try:
async with self.tracking_context("inc.find_one_and_update", self.collection_name):
doc = await collection.find_one_and_update(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
{
"$inc": {"value": encoded_amount},
},
upsert=True,
return_document=ReturnDocument.AFTER,
session=self._session,
)
except OperationFailure as exc:
if exc.code == 14 or "Cannot apply $inc" in str(exc):
raise TypeError(f"value for key {key!r} is not numeric") from exc
raise
if doc is None: # type: ignore
raise RuntimeError("Failed to increment value; MongoDB did not return a document")
raw_value = doc["value"]
return self._value_adapter.validate_python(raw_value)
@tracked("chmax")
async def chmax(self, key: K, value: V) -> V:
assert ensure_numeric(value, description="value")
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
encoded_value = self._value_adapter.dump_python(value, mode="python")
try:
async with self.tracking_context("chmax.find_one_and_update", self.collection_name):
doc = await collection.find_one_and_update(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
{
"$max": {"value": encoded_value},
},
upsert=True,
return_document=ReturnDocument.AFTER,
session=self._session,
)
except OperationFailure as exc:
if exc.code == 14 or "Cannot apply $max" in str(exc):
raise TypeError(f"value for key {key!r} is not numeric") from exc
raise
if doc is None: # type: ignore
raise RuntimeError("Failed to update value; MongoDB did not return a document")
raw_value = doc["value"]
return self._value_adapter.validate_python(raw_value)
@tracked("pop")
async def pop(self, key: K, default: V | None = None) -> V | None:
collection = await self.ensure_collection()
encoded_key = self._key_adapter.dump_python(key, mode="python")
doc = await collection.find_one_and_delete(
{
"partition_id": self._partition_id,
"key": encoded_key,
},
session=self._session,
)
if doc is None: # type: ignore
return default
raw_value = doc["value"]
return self._value_adapter.validate_python(raw_value)
@tracked("size")
async def size(self) -> int:
collection = await self.ensure_collection()
return await collection.count_documents(
{
"partition_id": self._partition_id,
},
session=self._session,
)
class MongoLightningCollections(LightningCollections):
"""Mongo implementation of LightningCollections using MongoDB collections.
Serves as the storage base for [`MongoLightningStore`][agentlightning.store.mongo.MongoLightningStore].
"""
def __init__(
self,
client_pool: MongoClientPool[Mapping[str, Any]],
database_name: str,
partition_id: str,
rollouts: Optional[MongoBasedCollection[Rollout]] = None,
attempts: Optional[MongoBasedCollection[Attempt]] = None,
spans: Optional[MongoBasedCollection[Span]] = None,
resources: Optional[MongoBasedCollection[ResourcesUpdate]] = None,
workers: Optional[MongoBasedCollection[Worker]] = None,
rollout_queue: Optional[MongoBasedQueue[str]] = None,
span_sequence_ids: Optional[MongoBasedKeyValue[str, int]] = None,
tracker: MetricsBackend | None = None,
):
super().__init__(tracker=tracker, extra_labels=["database"])
self._client_pool = client_pool
self._database_name = database_name
self._partition_id = partition_id
self._collection_ensured = False
self._lock = aiologic.Lock() # used for generic atomic operations like scan debounce seconds
self._rollouts = (
rollouts
if rollouts is not None
else MongoBasedCollection(
self._client_pool,
self._database_name,
"rollouts",
self._partition_id,
["rollout_id"],
Rollout,
[["status"]],
tracker=self._tracker,
)
)
self._attempts = (
attempts
if attempts is not None
else MongoBasedCollection(
self._client_pool,
self._database_name,
"attempts",
self._partition_id,
["rollout_id", "attempt_id"],
Attempt,
[["status"], ["sequence_id"]],
tracker=self._tracker,
)
)
self._spans = (
spans
if spans is not None
else MongoBasedCollection(
self._client_pool,
self._database_name,
"spans",
self._partition_id,
["rollout_id", "attempt_id", "span_id"],
Span,
[["sequence_id"]],
tracker=self._tracker,
)
)
self._resources = (
resources
if resources is not None
else MongoBasedCollection(
self._client_pool,
self._database_name,
"resources",
self._partition_id,
["resources_id"],
ResourcesUpdate,
["update_time"],
tracker=self._tracker,
)
)
self._workers = (
workers
if workers is not None
else MongoBasedCollection(
self._client_pool,
self._database_name,
"workers",
self._partition_id,
["worker_id"],
Worker,
["status"],
tracker=self._tracker,
)
)
self._rollout_queue = (
rollout_queue
if rollout_queue is not None
else MongoBasedQueue(
self._client_pool,
self._database_name,
"rollout_queue",
self._partition_id,
str,
tracker=self._tracker,
)
)
self._span_sequence_ids = (
span_sequence_ids
if span_sequence_ids is not None
else MongoBasedKeyValue(
self._client_pool,
self._database_name,
"span_sequence_ids",
self._partition_id,
str,
int,
tracker=self._tracker,
)
)
@property
def collection_name(self) -> str:
return "router" # Special collection name for tracking transactions
@property
def extra_tracking_labels(self) -> Mapping[str, str]:
return {
"database": self._database_name,
}
def with_session(self, session: AsyncClientSession) -> Self:
instance = self.__class__(
client_pool=self._client_pool,
database_name=self._database_name,
partition_id=self._partition_id,
rollouts=self._rollouts.with_session(session),
attempts=self._attempts.with_session(session),
spans=self._spans.with_session(session),
resources=self._resources.with_session(session),
workers=self._workers.with_session(session),
rollout_queue=self._rollout_queue.with_session(session),
span_sequence_ids=self._span_sequence_ids.with_session(session),
tracker=self._tracker,
)
instance._collection_ensured = self._collection_ensured
return instance
@property
def rollouts(self) -> MongoBasedCollection[Rollout]:
return self._rollouts
@property
def attempts(self) -> MongoBasedCollection[Attempt]:
return self._attempts
@property
def spans(self) -> MongoBasedCollection[Span]:
return self._spans
@property
def resources(self) -> MongoBasedCollection[ResourcesUpdate]:
return self._resources
@property
def workers(self) -> MongoBasedCollection[Worker]:
return self._workers
@property
def rollout_queue(self) -> MongoBasedQueue[str]:
return self._rollout_queue
@property
def span_sequence_ids(self) -> MongoBasedKeyValue[str, int]:
return self._span_sequence_ids
@tracked("ensure_collections")
async def _ensure_collections(self) -> None:
"""Ensure all collections exist."""
if self._collection_ensured:
return
await self._rollouts.ensure_collection()
await self._attempts.ensure_collection()
await self._spans.ensure_collection()
await self._resources.ensure_collection()
await self._workers.ensure_collection()
await self._rollout_queue.ensure_collection()
await self._span_sequence_ids.ensure_collection()
self._collection_ensured = True
@asynccontextmanager
async def _lock_manager(self, labels: Optional[Sequence[AtomicLabels]]):
if labels is None or "generic" not in labels:
yield
else:
# Only lock the generic label.
try:
async with self.tracking_context("lock", self.collection_name):
await self._lock.async_acquire()
yield
finally:
self._lock.async_release()
@asynccontextmanager
async def atomic(
self,
mode: AtomicMode = "rw",
snapshot: bool = False,
commit: bool = False,
labels: Optional[Sequence[AtomicLabels]] = None,
*args: Any,
**kwargs: Any,
):
"""Perform a atomic operation on the collections."""
if commit:
raise ValueError("Commit should be used with execute() instead.")
async with self._lock_manager(labels):
async with self.tracking_context("atomic", self.collection_name):
# First step: ensure all collections exist before going into the atomic block
if not self._collection_ensured:
await self._ensure_collections()
# Execute directly without commit
yield self
@tracked("execute")
async def execute(
self,
callback: Callable[[Self], Awaitable[T_generic]],
*,
mode: AtomicMode = "rw",
snapshot: bool = False,
commit: bool = False,
labels: Optional[Sequence[AtomicLabels]] = None,
**kwargs: Any,
) -> T_generic:
"""Execute the given callback within an atomic operation, and with retries on transient errors."""
if not self._collection_ensured:
await self._ensure_collections()
client = await self._client_pool.get_client()
# If commit is not turned on, just execute the callback directly.
if not commit:
async with self._lock_manager(labels):
return await callback(self)
# If snapshot is enabled, use snapshot read concern.
read_concern = ReadConcern("snapshot") if snapshot else ReadConcern("local")
# If mode is "r", write_concern is not needed.
write_concern = WriteConcern("majority") if mode != "r" else None
async with client.start_session() as session:
collections = self.with_session(session)
try:
async with self._lock_manager(labels):
return await self.with_transaction(session, collections, callback, read_concern, write_concern)
except (ConnectionFailure, OperationFailure) as exc:
# Un-retryable errors.
raise RuntimeError("Transaction failed with connection or operation error") from exc
@tracked("with_transaction")
async def with_transaction(
self,
session: AsyncClientSession,
collections: Self,
callback: Callable[[Self], Awaitable[T_generic]],
read_concern: ReadConcern,
write_concern: Optional[WriteConcern],
) -> T_generic:
# This will start a transaction, run transaction callback, and commit.
# It will also transparently retry on some transient errors.
# Expanded implementation of with_transaction from client_session
read_preference = ReadPreference.PRIMARY
transaction_retry_time_limit = 120
start_time = time.monotonic()
def _within_time_limit() -> bool:
return time.monotonic() - start_time < transaction_retry_time_limit
async def _jitter_before_retry() -> None:
async with self.tracking_context("execute.jitter", self.collection_name):
await asyncio.sleep(random.uniform(0, 0.05))
while True:
await session.start_transaction(read_concern, write_concern, read_preference)
try:
# The _session is always the same within one transaction,
# so we can use the same collections object.
async with self.tracking_context("execute.callback", self.collection_name):
ret = await callback(collections)
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
except BaseException as exc:
if session.in_transaction:
await session.abort_transaction()
if (
isinstance(exc, PyMongoError)
and exc.has_error_label("TransientTransactionError")
and _within_time_limit()
):
# Retry the entire transaction.
await _jitter_before_retry()
continue
raise
if not session.in_transaction:
# Assume callback intentionally ended the transaction.
return ret
# Tracks the commit operation.
async with self.tracking_context("execute.commit", self.collection_name):
# Loop until the commit succeeds or we hit the time limit.
while True:
# Tracks the commit attempt.
try:
async with self.tracking_context("execute.commit_once", self.collection_name):
await session.commit_transaction()
except PyMongoError as exc:
if (
exc.has_error_label("UnknownTransactionCommitResult")
and _within_time_limit()
and not (isinstance(exc, OperationFailure) and exc.code == 50) # max_time_expired_error
):
# Retry the commit.
await _jitter_before_retry()
continue
if exc.has_error_label("TransientTransactionError") and _within_time_limit():
# Retry the entire transaction.
await _jitter_before_retry()
break
raise
# Commit succeeded.
return ret