Files
2026-07-13 12:24:33 +08:00

181 lines
6.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Node-local cache object operations (adapter listing, object listing, delete).
:class:`ObjectService` wraps the storage manager's L2 adapters and exposes
adapter resolution, paginated listing, and key-addressed deletion. It performs
its own validation and raises transport-agnostic domain errors (see
:mod:`cache_control.errors`); the HTTP layer maps those to status codes.
Blocking adapter I/O is off-loaded to a worker thread so callers can ``await``.
"""
# Standard
from typing import Any
import asyncio
# First Party
from lmcache.v1.distributed.api import EncodedObjectKey
from lmcache.v1.distributed.tiers import Tier
from lmcache.v1.multiprocess.cache_control.errors import (
InvalidRequest,
NotFound,
Unavailable,
)
# Hard cap on how many keys a single delete request may target. Keeps the
# request body bounded and prevents one call from monopolizing the adapter's
# I/O loop for an unbounded interval.
MAX_DELETE_BATCH = 10_000
# Object operations address the L2 tier today; other tiers are rejected.
_SUPPORTED_TIER = Tier.L2
class ObjectService:
"""Adapter-listing, object-listing, and key-addressed deletion on one node.
Args:
engine: The node's cache engine; its ``storage_manager`` owns the L2
adapters.
"""
def __init__(self, engine: Any) -> None:
self._engine = engine
@staticmethod
def _require_supported_tier(tier: Tier) -> None:
"""Raise :class:`InvalidRequest` unless ``tier`` is the supported one."""
if tier != _SUPPORTED_TIER:
raise InvalidRequest(
f"tier {tier.value!r} not supported; only {_SUPPORTED_TIER.value!r}"
)
def _resolve_adapter(self, selector: str | None) -> tuple[Any, Any]:
"""Resolve the ``(descriptor, adapter)`` pair a request targets.
``selector=None`` picks the primary (first-configured) adapter.
Raises:
Unavailable: No adapters configured.
NotFound: ``selector`` matches none.
"""
adapters = self._engine.storage_manager.l2_adapters()
if not adapters:
raise Unavailable("no L2 adapters configured")
if selector is None:
return adapters[0]
for desc, adapter in adapters:
if desc.type_name == selector:
return desc, adapter
raise NotFound(f"no L2 adapter with type_name={selector!r}")
def list_adapters(self) -> dict[str, object]:
"""Return the live L2 adapter inventory.
Returns:
``{"adapters": [{index, type_name, tier, primary, reconfigurable},
...]}`` (empty list when no backends are configured).
``reconfigurable`` is ``True`` for adapters that accept runtime
``/reconfigure`` operations; the ``{backend}`` path parameter for
those routes is the adapter's ``type_name``. This is the single live
adapter listing -- it supersedes the old ``/reconfigure/backends``.
"""
sm = self._engine.storage_manager
adapters = sm.l2_adapters()
reconfigurable = sm.reconfigurable_l2_backends()
return {
"adapters": [
{
"index": i,
"type_name": desc.type_name,
"tier": _SUPPORTED_TIER.value,
"primary": i == 0,
"reconfigurable": desc.type_name in reconfigurable,
}
for i, (desc, _) in enumerate(adapters)
]
}
async def list_objects(
self,
tier: Tier,
adapter_selector: str | None,
model_name: str | None,
page_size: int,
page_token: str | None,
) -> dict[str, object]:
"""List keys resident in one adapter, paginated.
Returns:
``{"adapter", "entries", "next_page_token"}``.
Raises:
InvalidRequest: unsupported ``tier`` or malformed ``page_token``.
Unavailable: no adapters configured, or the adapter does not
implement listing.
NotFound: ``adapter_selector`` matches none.
"""
self._require_supported_tier(tier)
desc, adapter = self._resolve_adapter(adapter_selector)
try:
page = await asyncio.to_thread(
adapter.list_l2_keys,
model_name=model_name,
page_size=page_size,
cursor=page_token,
)
except ValueError as exc:
raise InvalidRequest(str(exc)) from None
except NotImplementedError as exc:
raise Unavailable(
f"L2 adapter {desc.type_name!r} does not support listing: {exc}"
) from None
return {
"adapter": desc.type_name,
"entries": page.entries,
"next_page_token": page.next_page_token,
}
async def delete_objects(
self,
tier: Tier,
adapter_selector: str | None,
keys: list[EncodedObjectKey],
) -> dict[str, object]:
"""Delete a caller-supplied list of keys from one adapter.
Idempotent at the adapter level (absent / locked keys are skipped).
Returns:
``{"requested", "adapter", "ok"[, "error"]}``; ``ok`` is ``False``
with ``error`` set when the adapter raised (a structured failure,
not a crash).
Raises:
InvalidRequest: unsupported ``tier``, batch too large, or an
``ObjectKey`` invariant violation.
Unavailable / NotFound: adapter resolution.
"""
self._require_supported_tier(tier)
if len(keys) > MAX_DELETE_BATCH:
raise InvalidRequest(
f"too many keys in a single request "
f"(limit={MAX_DELETE_BATCH}, got={len(keys)})"
)
parsed = []
for i, cache_key in enumerate(keys):
try:
parsed.append(cache_key.to_object_key())
except ValueError as exc:
raise InvalidRequest(f"keys[{i}]: {exc}") from None
desc, adapter = self._resolve_adapter(adapter_selector)
body: dict[str, object] = {"requested": len(parsed), "adapter": desc.type_name}
try:
await asyncio.to_thread(adapter.delete, parsed)
except Exception as exc: # noqa: BLE001 - surfaced as a structured result
body["ok"] = False
body["error"] = str(exc)
return body
body["ok"] = True
return body