# 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