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

1137 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""
Distributed multi-tier storage manager for MP mode
"""
# Standard
from contextlib import contextmanager
from typing import Iterator, Literal, Optional
import threading
import time
# First Party
from lmcache.logging import init_logger
from lmcache.native_storage_ops import Bitmap, PeriodicEventNotifier
from lmcache.v1.distributed.api import (
DEFAULT_ATTN_WINDOW_DESC,
AttnWindowDesc,
MemoryLayoutDesc,
ObjectKey,
PrefetchHandle,
PrefetchMode,
TrimPolicy,
)
from lmcache.v1.distributed.config import EvictionConfig, StorageManagerConfig
from lmcache.v1.distributed.error import L1Error, strerror
from lmcache.v1.distributed.internal_api import L1MemoryDesc, L2AdapterListener
from lmcache.v1.distributed.l1_manager import L1Manager
from lmcache.v1.distributed.l2_adapters import create_l2_adapter
from lmcache.v1.distributed.l2_adapters.base import L2AdapterInterface
from lmcache.v1.distributed.l2_adapters.config import L2AdapterConfigBase
from lmcache.v1.distributed.l2_adapters.reconfiguration import (
L2ReconfigurableAdapter,
L2ReconfigureError,
)
from lmcache.v1.distributed.l2_adapters.serde_wrapper import SerdeL2AdapterWrapper
from lmcache.v1.distributed.quota_manager import QuotaManager
from lmcache.v1.distributed.serde import create_serde_processor
from lmcache.v1.distributed.storage_controllers import (
L1EvictionController,
L2AdapterEvictionState,
L2EvictionController,
PrefetchController,
StoreController,
)
from lmcache.v1.distributed.storage_controllers.prefetch_policy import (
create_prefetch_policy,
)
from lmcache.v1.distributed.storage_controllers.store_policy import (
AdapterDescriptor,
create_store_policy,
)
from lmcache.v1.memory_management import MemoryObj
from lmcache.v1.mp_observability.errors import LMCacheTimeoutError
from lmcache.v1.mp_observability.event import Event, EventType
from lmcache.v1.mp_observability.event_bus import get_event_bus
from lmcache.v1.mp_observability.otel_init import register_gauge
from lmcache.v1.mp_observability.trace.decorator import (
enable_tracing,
is_tracing_enabled,
publish_call_event,
)
from lmcache.v1.platform import HAS_EVENTFD
logger = init_logger(__name__)
class StorageManager:
def __init__(self, config: StorageManagerConfig):
self._l1_manager = L1Manager(config.l1_manager_config)
self._event_bus = get_event_bus()
# L1 eviction controller
self._eviction_controller = L1EvictionController(
l1_manager=self._l1_manager,
eviction_config=config.eviction_config,
)
self._eviction_controller.start()
# L2 adapters and store controller. When an adapter config carries
# a ``serde_config``, the adapter is wrapped with
# ``SerdeL2AdapterWrapper`` so controllers see a plain L2 adapter
# and serde is transparent.
self._l1_memory_desc = self._l1_manager.get_l1_memory_desc()
self._next_adapter_id = 0
# Serializes add_l2_adapter / delete_l2_adapter against each other.
self._lifecycle_lock = threading.Lock()
# Guards the _l2_adapters and _adapter_descriptors dicts.
self._adapters_lock = threading.Lock()
self._registered_l2_listeners: list[L2AdapterListener] = []
self._l2_adapters: dict[int, L2AdapterInterface] = {}
self._adapter_descriptors: dict[int, AdapterDescriptor] = {}
for ac in config.l2_adapter_config.adapters:
adapter_id, adapter, descriptor = self._build_l2_adapter(ac)
self._l2_adapters[adapter_id] = adapter
self._adapter_descriptors[adapter_id] = descriptor
PeriodicEventNotifier.create(
interval_ms=config.periodic_notifier_interval_ms,
use_eventfd=HAS_EVENTFD,
)
# Per-cache_salt quota registry. Shared across the L2 eviction
# controller (reads quotas each cycle) and the HTTP quota
# endpoints (CRUD). Present even when no adapter uses
# IsolatedLRU so the HTTP layer has a stable ``quota_manager``
# reference. No explicit cleanup on close — the registry is
# just a dict protected by a lock and has no OS resources.
self._quota_manager = QuotaManager()
# Unified L2 eviction controller for all adapters with eviction
# config. Aggregate-usage policies (``LRU``, ``noop``) need
# ``max_capacity_bytes > 0`` to compute a usage fraction;
# adapters without capacity are skipped for those. Isolated
# policies (``IsolatedLRU``) operate on per cache_salt byte
# counts which the base class tracks regardless of capacity,
# so they are wired up unconditionally.
l2_eviction_states: list[L2AdapterEvictionState] = []
for adapter_id, ac in zip(
self._l2_adapters, config.l2_adapter_config.adapters, strict=True
):
adapter = self._l2_adapters[adapter_id]
if self._should_enable_l2_eviction(adapter, ac.eviction_config):
assert ac.eviction_config is not None # make linter happy
l2_eviction_states.append(
L2AdapterEvictionState(
adapter_id=adapter_id,
adapter=adapter,
eviction_config=ac.eviction_config,
)
)
self._l2_eviction_controller = L2EvictionController(
l2_eviction_states, quota_manager=self._quota_manager
)
self._l2_eviction_controller.start()
# Controllers receive the initial set as ordered lists; they key
# their own copies by ``descriptor.index`` (== adapter_id) and learn
# of later changes via add_adapter/request_remove_adapter.
self._store_controller = StoreController(
l1_manager=self._l1_manager,
l2_adapters=list(self._l2_adapters.values()),
adapter_descriptors=list(self._adapter_descriptors.values()),
policy=create_store_policy(config.store_policy),
)
self._store_controller.start()
# Prefetch controller
self._prefetch_controller = PrefetchController(
l1_manager=self._l1_manager,
l2_adapters=list(self._l2_adapters.values()),
adapter_descriptors=list(self._adapter_descriptors.values()),
policy=create_prefetch_policy(config.prefetch_policy),
max_in_flight=config.prefetch_max_in_flight,
)
self._prefetch_controller.start()
# L2 usage gauge — one observation per adapter, tagged by
# ``l2_name``. Parallel to L1Manager's ``l1_memory_usage_bytes``.
register_gauge(
"lmcache.l2",
"lmcache_mp.l2_usage_bytes",
(
"Bytes currently held in each L2 adapter, tagged by "
"``l2_name`` (one observation per adapter)."
),
self.get_l2_usages,
)
# External APIs for serving engine integration code to call
@enable_tracing()
def reserve_write(
self,
keys: list[ObjectKey],
layout_desc: MemoryLayoutDesc,
mode: Literal["new", "update", "all"],
) -> dict[ObjectKey, MemoryObj]:
"""
Reserve the object for writing into the storage manager.
Args:
keys (list[ObjectKey]): List of object keys to reserve for writing.
layout_desc (MemoryLayoutDesc): Description of the memory layout
for the objects to be reserved.
mode (Literal["new", "update", "all"]): Reservation mode.
- "new": Reserve only new objects that do not exist.
- "update": Reserve only existing objects for update.
- "all": Reserve all writable objects regardless of existence.
Returns:
dict[ObjectKey, MemoryObj]: A dictionary mapping object keys to their
reserved memory objects. Note that not all requested keys could be
reserved (e.g., out of memory or write conflict)
"""
reserve_result = self._l1_manager.reserve_write(
keys=keys,
is_temporary=[False] * len(keys),
layout_desc=layout_desc,
mode=mode,
)
result = {k: m for k, (e, m) in reserve_result.items() if m is not None}
successful_keys = list(result.keys())
failed_keys = [k for k, (e, m) in reserve_result.items() if m is None]
self._event_bus.publish(
Event(
event_type=EventType.SM_WRITE_RESERVED,
metadata={
"succeeded_keys": successful_keys,
"failed_keys": failed_keys,
},
)
)
oom_keys = [
k for k, (e, _) in reserve_result.items() if e == L1Error.OUT_OF_MEMORY
]
if oom_keys:
self._event_bus.publish(
Event(
event_type=EventType.L1_ALLOCATION_FAILED,
metadata={"during": "l1_store", "keys": oom_keys},
)
)
return result
@enable_tracing()
def finish_write(
self,
keys: list[ObjectKey],
) -> None:
"""
Finish writing the objects into the storage manager.
Args:
keys (list[ObjectKey]): List of object keys that have been written.
"""
finish_result = self._l1_manager.finish_write(keys)
successful_keys = [k for k, e in finish_result.items() if e == L1Error.SUCCESS]
failed_keys = [k for k, e in finish_result.items() if e != L1Error.SUCCESS]
self._event_bus.publish(
Event(
event_type=EventType.SM_WRITE_FINISHED,
metadata={
"succeeded_keys": successful_keys,
"failed_keys": failed_keys,
},
)
)
# TODO: global key states update
@contextmanager
def read_prefetched_results(
self,
keys: list[ObjectKey],
) -> Iterator[list[MemoryObj] | None]:
"""
Read the memory objects from L1 storage that has been prefetched beforehand.
Yielding an optional list of memory objects corresponding to the requested
keys. If any the object is not found in L1, None is yielded.
Args:
keys (list[ObjectKey]): List of object keys to reserve for reading.
Returns:
Iterator[list[MemoryObj] | None]: An iterator yielding an optional list of
memory objects corresponding to the requested keys.
Note:
If any object is not found in L1 storage, None is yielded. In this case,
this function will release release the read lock of all successfully read
memory objects when exiting the context.
If the caller raised exception during the processing of the yielded memory
objects, this function will ensure that the read locks will be decreased.
"""
# Manual TRACE_CALL emission for the context manager. The
# ``@enable_tracing`` decorator cannot wrap a ``@contextmanager``
# generator function (it would publish the call to the wrapper
# rather than to ``__enter__``). Emit enter/exit events
# directly, gated on the tracing flag for zero overhead when
# disabled.
if is_tracing_enabled():
publish_call_event(
"lmcache.v1.distributed.storage_manager."
"StorageManager.read_prefetched_results.__enter__",
{"keys": keys},
)
read_results = self._l1_manager.unsafe_read(keys)
good_keys: list[ObjectKey] = []
good_objs: list[MemoryObj] = []
bad_keys: list[ObjectKey] = []
not_found_keys: list[ObjectKey] = []
write_locked_keys: list[ObjectKey] = []
all_good = True
for k, (e, o) in read_results.items():
if o is None:
logger.error(
"Failed to read prefetched object %s from L1 storage: %s",
k,
strerror(e),
)
bad_keys.append(k)
all_good = False
if e == L1Error.KEY_NOT_EXIST:
not_found_keys.append(k)
elif e == L1Error.KEY_NOT_READABLE:
write_locked_keys.append(k)
continue
good_keys.append(k)
good_objs.append(o)
# L1 read-failure anomaly reporting: unsafe_read is required to be
# called post-reserve_read, so any failure here is a lock/eviction
# race, not a normal cache miss.
if not_found_keys:
self._event_bus.publish(
Event(
event_type=EventType.L1_READ_FAILED,
metadata={
"during": "l1_retrieve",
"reason": "not_found",
"keys": not_found_keys,
},
)
)
if write_locked_keys:
self._event_bus.publish(
Event(
event_type=EventType.L1_READ_FAILED,
metadata={
"during": "l1_retrieve",
"reason": "write_locked",
"keys": write_locked_keys,
},
)
)
successfully_yielded = False
try:
yield good_objs if all_good else None
successfully_yielded = True
except Exception:
logger.exception(
"Exception occurred while processing read prefetched results",
)
raise
finally:
# Decrease the read lock for all successfully read memory objects
# if None is yielded or exception occurs during caller's processing
if not all_good or not successfully_yielded:
self._l1_manager.finish_read(good_keys)
self._event_bus.publish(
Event(
event_type=EventType.SM_READ_PREFETCHED_FINISHED,
metadata={
"succeeded_keys": good_keys,
"failed_keys": bad_keys,
},
)
)
if is_tracing_enabled():
publish_call_event(
"lmcache.v1.distributed.storage_manager."
"StorageManager.read_prefetched_results.__exit__",
{"keys": keys},
)
@enable_tracing()
def finish_read_prefetched(
self,
keys: list[ObjectKey],
extra_count: int = 0,
) -> None:
"""Finish reading prefetched objects.
Args:
keys: Object keys that have been read.
extra_count: Extra read locks to release per key
(on top of the default 1).
"""
finish_result = self._l1_manager.finish_read(keys, extra_count=extra_count)
successful_keys = [k for k, e in finish_result.items() if e == L1Error.SUCCESS]
failed_keys = [k for k, e in finish_result.items() if e != L1Error.SUCCESS]
self._event_bus.publish(
Event(
event_type=EventType.SM_READ_PREFETCHED_FINISHED,
metadata={
"succeeded_keys": successful_keys,
"failed_keys": failed_keys,
},
)
)
@enable_tracing()
def submit_prefetch_task(
self,
keys: list[ObjectKey],
layout_desc: MemoryLayoutDesc,
extra_count: int = 0,
external_request_id: str = "",
policy: TrimPolicy = TrimPolicy.PREFIX,
attn_desc: AttnWindowDesc = DEFAULT_ATTN_WINDOW_DESC,
skip_l2: bool = False,
mode: PrefetchMode = PrefetchMode.LOOKUP,
) -> PrefetchHandle:
"""Prefetch objects into L1 asynchronously.
Args:
keys: Object keys to prefetch.
layout_desc: Memory layout description.
extra_count: Extra workers (on top of the default
1) that will independently retrieve the same
key. Total locks = 1 + extra_count.
external_request_id: Request ID from the caller
for end-to-end log tracing.
policy: Which retained-subset policy to apply (see
:class:`TrimPolicy`). ``PREFIX`` keeps the contiguous prefix;
``SPARSE`` keeps every found key (gap-tolerant).
attn_desc: Cross-chunk attention windows of all object groups, in
object-group order.
skip_l2: If True, do not load from L2. For ``LOOKUP`` only
already-resident L1 keys are returned; for ``WARM`` nothing is
loaded and an empty handle is returned.
mode: The prefetch intent (see :class:`PrefetchMode`). ``WARM``
retains loaded keys and pins none; ``LOOKUP`` (default) pins
them for an imminent reader and follows the policy.
Returns:
PrefetchHandle to track the task.
"""
if mode is PrefetchMode.WARM:
# Warm path: load all keys, pin none. skip_l2 makes it a no-op.
prefetch_request_id = -1
if not skip_l2 and keys and self._l2_adapters:
prefetch_request_id = self._prefetch_controller.submit_prefetch_request(
keys,
layout_desc,
extra_count=extra_count,
policy=policy,
mode=mode,
)
return PrefetchHandle(
prefetch_request_id=prefetch_request_id,
external_request_id=external_request_id,
l1_found_indices=(),
total_requested_keys=len(keys),
submit_time=time.monotonic(),
l2_orig_indices=(
tuple(range(len(keys))) if prefetch_request_id != -1 else ()
),
)
# NOTE: now we only have L1, so the prefetch is essentially checking how many
# objects are already in L1, and adding read locks to them.
l1_read_result = self._l1_manager.reserve_read(keys, extra_count=extra_count)
if policy is TrimPolicy.SPARSE:
# SPARSE: retain a read lock on every L1 hit (not just the leading
# prefix) and send all L1 misses to L2 as one coalesced request.
# reserve_read locks only SUCCESS keys, so the found-set already
# equals the locked set -- nothing to release.
l1_found_indices: list[int] = []
succeeded_keys: list[ObjectKey] = []
sparse_l2_indices: list[int] = []
remaining_keys: list[ObjectKey] = []
for i, key in enumerate(keys):
ent = l1_read_result.get(key)
if ent is not None and ent[0] == L1Error.SUCCESS and ent[1] is not None:
l1_found_indices.append(i)
succeeded_keys.append(key)
else:
sparse_l2_indices.append(i)
remaining_keys.append(key)
self._event_bus.publish(
Event(
event_type=EventType.SM_READ_PREFETCHED,
metadata={
"succeeded_keys": succeeded_keys,
"failed_keys": remaining_keys,
},
)
)
prefetch_request_id = -1
if remaining_keys and self._has_l2_adapters():
prefetch_request_id = self._prefetch_controller.submit_prefetch_request(
remaining_keys,
layout_desc,
extra_count=extra_count,
policy=TrimPolicy.SPARSE,
attn_desc=attn_desc,
mode=mode,
)
return PrefetchHandle(
prefetch_request_id=prefetch_request_id,
external_request_id=external_request_id,
l1_found_indices=tuple(l1_found_indices),
total_requested_keys=len(keys),
submit_time=time.monotonic(),
l2_orig_indices=tuple(sparse_l2_indices),
)
hit_count = 0
for key in keys:
entry = l1_read_result.get(key, None)
if entry is None:
break
err, obj = entry
if err != L1Error.SUCCESS:
break
hit_count += 1
# NOTE: For L1, there will be cases that "object in the middle" is not found.
# In this case, we need to `finish_read` for the latter objects so that
# there won't be dangling read locks.
skipped_keys = []
for key in keys[hit_count:]:
if key in l1_read_result and l1_read_result[key][1] is not None:
# this key is actually reserved, need to release the read lock
skipped_keys.append(key)
if skipped_keys:
self._l1_manager.finish_read(skipped_keys, extra_count=extra_count)
self._event_bus.publish(
Event(
event_type=EventType.SM_READ_PREFETCHED,
metadata={
"succeeded_keys": keys[:hit_count],
"failed_keys": keys[hit_count:],
},
)
)
if skip_l2:
return PrefetchHandle(
prefetch_request_id=-1,
external_request_id=external_request_id,
l1_found_indices=tuple(range(hit_count)),
total_requested_keys=len(keys),
submit_time=time.monotonic(),
l2_orig_indices=(),
)
# Submit remaining keys to L2 prefetch controller
remaining_keys = keys[hit_count:]
prefetch_request_id = -1
l2_orig_indices: tuple[int, ...] = ()
if remaining_keys and self._has_l2_adapters():
prefetch_request_id = self._prefetch_controller.submit_prefetch_request(
remaining_keys,
layout_desc,
extra_count=extra_count,
attn_desc=attn_desc,
policy=policy,
mode=mode,
)
# The controller indexes its result bitmap over remaining_keys
# (0-based); map those local indices back to original positions.
l2_orig_indices = tuple(range(hit_count, len(keys)))
submit_time = time.monotonic()
logger.debug(
"Prefetch request submitted: "
"%d total keys, %d L1 prefix hits, "
"%d remaining for L2 "
"(external_request_id=%s, "
"prefetch_request_id=%d)",
len(keys),
hit_count,
len(remaining_keys),
external_request_id,
prefetch_request_id,
)
return PrefetchHandle(
prefetch_request_id=prefetch_request_id,
external_request_id=external_request_id,
l1_found_indices=tuple(range(hit_count)),
total_requested_keys=len(keys),
submit_time=submit_time,
l2_orig_indices=l2_orig_indices,
)
def _combine_found(
self, handle: PrefetchHandle, l2_local: "Bitmap | None"
) -> Bitmap:
"""Merge the L1 found indices with an L2 result bitmap into one bitmap
over the original key positions.
``l2_local`` is indexed over the keys submitted to L2 (0-based); its
set bits are mapped back to original positions via
``handle.l2_orig_indices``.
"""
found = Bitmap(handle.total_requested_keys)
found.batched_set(handle.l1_found_indices)
if l2_local is not None:
# gather maps each L2 set bit i to its original position
# ``l2_orig_indices[i]``; batched_set drops any position >= size.
found.batched_set(l2_local.gather(handle.l2_orig_indices))
return found
def query_prefetch_lookup_hits(
self,
handle: PrefetchHandle,
) -> int | None:
"""
Query the number of prefix-hit chunks for a prefetch task before the
L2 prefetching is done.
Args:
handle (PrefetchHandle): The handle of the lookup task.
Returns:
the number of prefix-hit chunks (L1 + L2) if the lookup is done,
None if it's still in progress or the prefetch task is already done.
Note:
This function is designed for the scenario where the caller wants
to check the L1 prefix hits as soon as possible without waiting for
the whole prefetch task to be done.
When the prefetch task is already done and the prefetch task result
has already been queried by `query_prefetch_status`, this function
will return None forever for the same prefetch handle.
Therefore, it's the callers responsibility to make sure not calling
this function after the prefetch task is done.
"""
# Prefix-path only: l1_found_indices is contiguous, so len() == prefix hits.
l1_hits = len(handle.l1_found_indices)
if handle.prefetch_request_id == -1:
# No L2 request: the L1 prefix hit count is final.
return l1_hits
l2_r = self._prefetch_controller.query_lookup_result(handle.prefetch_request_id)
if l2_r is None:
# Still in progress, or already consumed by query_prefetch_status.
return None
# L2 lookup done: total prefix hits are L1 plus the L2 continuation.
return l1_hits + l2_r
def wait_prefetch_status(
self,
handle: PrefetchHandle,
timeout: float,
) -> bool:
"""
Block until the prefetch task for ``handle`` has a result, or timeout.
L1-only prefetches (``prefetch_request_id == -1``) have no L2 result to
wait for and return immediately. This lets a caller avoid busy-polling
query_prefetch_status; the status itself is still retrieved via
query_prefetch_status afterwards.
Args:
handle (PrefetchHandle): The handle of the prefetch task.
timeout: Maximum number of seconds to wait for the L2 result.
Returns:
True if a result is available within the timeout (always True for
an L1-only prefetch), False if the wait timed out.
"""
if handle.prefetch_request_id == -1:
return True
return self._prefetch_controller.wait_prefetch_result(
handle.prefetch_request_id, timeout
)
def query_prefetch_status(
self,
handle: PrefetchHandle,
) -> Bitmap | None:
"""
Query the status of the prefetch task.
Args:
handle (PrefetchHandle): The handle of the prefetch task.
Returns:
the found-key bitmap (over original positions) if the prefetch is
done, None if it's still in progress. Derive the prefix hit count
via ``count_leading_ones``.
"""
l2_r: Bitmap | None = None
if handle.prefetch_request_id != -1:
l2_r = self._prefetch_controller.query_prefetch_result(
handle.prefetch_request_id
)
if l2_r is None:
return None
found = self._combine_found(handle, l2_r)
# popcount (not count_leading_ones) so the log is accurate for
# non-contiguous policies (SEGMENTED_PREFIX / SPARSE) too.
total_hits = found.popcount()
elapsed_ms = (time.monotonic() - handle.submit_time) * 1000
if total_hits > 0:
# L1 and L2 sets are disjoint (only L1-misses go to L2).
l1_hits = len(handle.l1_found_indices)
l2_hits = l2_r.popcount() if l2_r is not None else 0
logger.info(
"Prefetch request completed (L1+L2): "
"%d/%d retained keys (%d L1, %d L2) in %.1f ms "
"(external_request_id=%s, prefetch_request_id=%d)",
total_hits,
handle.total_requested_keys,
l1_hits,
l2_hits,
elapsed_ms,
handle.external_request_id,
handle.prefetch_request_id,
)
return found
def touch_l1_keys(self, keys: list[ObjectKey]):
"""
Touch the keys in L1 storage, marking the keys
as accessed(retrieved or stored).
Args:
keys (list[ObjectKey]): List of object keys to touch.
"""
self._l1_manager.touch_keys(keys)
def unsafe_read(
self, keys: list[ObjectKey]
) -> tuple[list[ObjectKey], list[MemoryObj]]:
"""Read already read-locked objects without acquiring new read locks."""
read_results = self._l1_manager.unsafe_read(keys)
good_keys: list[ObjectKey] = []
good_objs: list[MemoryObj] = []
for key in keys:
err, obj = read_results.get(key, (L1Error.KEY_NOT_EXIST, None))
if err != L1Error.SUCCESS or obj is None:
continue
good_keys.append(key)
good_objs.append(obj)
return good_keys, good_objs
@property
def quota_manager(self) -> QuotaManager:
"""Per-cache_salt quota registry.
Exposed so the HTTP layer can serve CRUD endpoints without
reaching into private state. Always non-``None`` — the
storage manager creates the registry at construction time.
"""
return self._quota_manager
@property
def l1_memory_desc(self) -> L1MemoryDesc:
"""Descriptor of the L1 memory buffer backing this storage manager."""
return self._l1_memory_desc
def get_l2_usages(
self,
) -> list[tuple[int | float, dict[str, object]]]:
"""Per-adapter L2 usage in OTel-observation shape.
Backing data for the ``lmcache_mp.l2_usage_bytes`` observable
gauge. One entry per configured adapter.
Returns:
A list of ``(total_bytes_used, {"l2_name": <type_name>})``
tuples — empty when no L2 adapters are configured. Adapters
whose ``get_usage()`` raises are skipped (the gauge prefers
silence over a poison observation).
"""
out: list[tuple[int | float, dict[str, object]]] = []
for _adapter_id, desc, adapter in self._snapshot_adapters():
try:
usage = adapter.get_usage()
except Exception:
logger.exception(
"L2 adapter %s get_usage() failed; skipping in gauge",
desc.type_name,
)
continue
out.append((int(usage.total_bytes_used), {"l2_name": desc.type_name}))
return out
def get_usage_bytes_by_cache_salt(self) -> dict[str, int]:
"""Aggregate ``cache_salt`` byte usage across every L2 adapter.
Used by the HTTP quota endpoints to report ``current_usage_gb``
alongside the configured limit. Aggregation is a simple sum:
each adapter tracks the same salt independently so the totals
are additive.
"""
totals: dict[str, int] = {}
for _adapter_id, _desc, adapter in self._snapshot_adapters():
snap = adapter.get_usage().bytes_by_cache_salt
for salt, used in snap.items():
totals[salt] = totals.get(salt, 0) + used
return totals
# L2 APIs
def get_l2_adapter_reconfigure_status(self) -> dict:
"""Return status for all runtime-reconfigurable L2 adapters.
Returns:
JSON-serializable status. If no reconfigurable adapter is configured,
``enabled`` is ``False`` and the adapter list is empty.
"""
type_names = {
adapter_id: desc.type_name
for adapter_id, desc, _ in self._snapshot_adapters()
}
adapters = []
for adapter_index, (
l2_adapter_index,
adapter,
) in enumerate(self._list_reconfigurable_l2_adapters()):
status = dict(adapter.reconfigure_status())
if l2_adapter_index in type_names:
status["backend"] = type_names[l2_adapter_index]
status["adapter_index"] = adapter_index
status["l2_adapter_index"] = l2_adapter_index
adapters.append(status)
return {
"enabled": bool(adapters),
"num_adapters": len(adapters),
"adapters": adapters,
}
def reconfigurable_l2_backends(self) -> set[str]:
"""Return the ``type_name`` of every L2 adapter that supports runtime
reconfiguration.
Returns:
The set of reconfigurable adapter ``type_name`` strings (empty when
none are reconfigurable). The ``{backend}`` path parameter the
``/reconfigure`` routes expect is the adapter's ``type_name``.
"""
return {
desc.type_name
for _adapter_id, desc, adapter in self._snapshot_adapters()
if self._unwrap_reconfigurable_l2_adapter(adapter) is not None
}
def reconfigure_l2_adapter(
self,
adapter_index: int,
operation: str,
payload: dict[str, object],
) -> dict:
"""Route a runtime reconfiguration request to one L2 adapter.
Args:
adapter_index: Zero-based reconfigurable-adapter index.
operation: Adapter-specific operation name.
payload: Adapter-specific operation payload.
Returns:
JSON-serializable operation result.
"""
adapter = self._get_reconfigurable_l2_adapter(adapter_index)
result = adapter.reconfigure(operation, payload)
result["adapter_index"] = adapter_index
return result
def add_l2_adapter(self, config: L2AdapterConfigBase) -> int:
"""Blocking function to add a new L2 adapter at runtime. Thread-safe.
Args:
config: The adapter configuration.
Returns:
The stable id assigned to the new adapter.
"""
with self._lifecycle_lock:
adapter_id, adapter, descriptor = self._build_l2_adapter(config)
for listener in self._registered_l2_listeners:
adapter.register_listener(listener)
with self._adapters_lock:
self._l2_adapters[adapter_id] = adapter
self._adapter_descriptors[adapter_id] = descriptor
self._store_controller.add_adapter(adapter_id, adapter, descriptor)
self._prefetch_controller.add_adapter(adapter_id, adapter, descriptor)
if self._should_enable_l2_eviction(adapter, config.eviction_config):
assert config.eviction_config is not None # make linter happy
self._l2_eviction_controller.add_adapter_state(
L2AdapterEvictionState(
adapter_id=adapter_id,
adapter=adapter,
eviction_config=config.eviction_config,
)
)
logger.info("Added L2 adapter %d (%s)", adapter_id, descriptor.type_name)
return adapter_id
def delete_l2_adapter(self, adapter_id: int, timeout: float = 30.0) -> None:
"""Blocking function to drain the L2 adapter gracefully at runtime.
Thread-safe.
Stops routing new stores/prefetches to the adapter, waits for its
in-flight work to finish, removes it from the controllers, and
closes it.
Args:
adapter_id: Stable id of the adapter to remove.
timeout: Maximum seconds to wait for in-flight work to drain.
Raises:
ValueError: If no adapter with ``adapter_id`` is active.
TimeoutError: If draining did not complete within ``timeout``;
the adapter is left active (draining) so the caller can
retry.
"""
with self._lifecycle_lock:
if adapter_id not in self._l2_adapters:
raise ValueError(f"No L2 adapter with id {adapter_id}")
deadline = time.monotonic() + timeout
store_done = self._store_controller.request_remove_adapter(adapter_id)
prefetch_done = self._prefetch_controller.request_remove_adapter(adapter_id)
if not store_done.wait(timeout=max(0.0, deadline - time.monotonic())):
raise LMCacheTimeoutError(
f"Timed out draining adapter {adapter_id} from store controller"
)
if not prefetch_done.wait(timeout=max(0.0, deadline - time.monotonic())):
raise LMCacheTimeoutError(
f"Timed out draining adapter {adapter_id} from prefetch controller"
)
self._l2_eviction_controller.remove_adapter_state(adapter_id)
with self._adapters_lock:
adapter = self._l2_adapters.pop(adapter_id)
self._adapter_descriptors.pop(adapter_id, None)
adapter.close()
logger.info("Deleted L2 adapter %d", adapter_id)
def l2_adapters(self) -> list[tuple[AdapterDescriptor, L2AdapterInterface]]:
"""Return all active L2 adapters paired with descriptors, in
ascending adapter-id order (== configuration order for the initial
set, then runtime-added adapters). The list is empty when no L2 is
configured.
Do not cache the returned pairs — ``reconfigure_l2_adapter``,
``add_l2_adapter``, and ``delete_l2_adapter`` may change the set at
runtime.
"""
return [
(desc, adapter) for _adapter_id, desc, adapter in self._snapshot_adapters()
]
# Management APIs
def clear(self, force: bool = False):
"""
Clear data in the storage manager.
Args:
force: If True, clear ALL objects including locked ones.
This may corrupt in-flight store/prefetch operations.
If False (default), only clear unlocked objects, keeping
write-locked and read-locked objects intact.
"""
self._l1_manager.clear(force=force)
def close(self):
"""
Close the storage manager and release all resources.
"""
self._prefetch_controller.stop()
self._store_controller.stop()
self._eviction_controller.stop()
self._l2_eviction_controller.stop()
PeriodicEventNotifier.shutdown()
for adapter in self._l2_adapters.values():
adapter.close()
self._l1_manager.close()
def report_status(self) -> dict:
"""Return a status dict aggregating all sub-component statuses."""
l1 = self._l1_manager.report_status()
store = self._store_controller.report_status()
prefetch = self._prefetch_controller.report_status()
l1_eviction = self._eviction_controller.report_status()
l2_eviction = self._l2_eviction_controller.report_status()
adapters = [a.report_status() for _id, _desc, a in self._snapshot_adapters()]
children = [l1, store, prefetch, l1_eviction, l2_eviction] + adapters
return {
"is_healthy": all(c["is_healthy"] for c in children),
"l1_manager": l1,
"store_controller": store,
"prefetch_controller": prefetch,
"l1_eviction_controller": l1_eviction,
"l2_eviction_controller": l2_eviction,
"l2_adapters": adapters,
"num_l2_adapters": len(adapters),
}
def register_l2_listener(self, listener: L2AdapterListener) -> None:
"""Register a listener on all current and future L2 adapters.
The listener is recorded so that adapters added later via
:meth:`add_l2_adapter` receive it too.
Args:
listener: The listener to register.
"""
with self._lifecycle_lock:
self._registered_l2_listeners.append(listener)
for adapter in self._l2_adapters.values():
adapter.register_listener(listener)
# Functions for debugging and testing
def memcheck(self) -> bool:
"""
Perform memory check for all storage tiers.
Returns:
True if memory is consistent, False otherwise.
"""
return self._l1_manager.memcheck()
def _snapshot_adapters(
self,
) -> list[tuple[int, AdapterDescriptor, L2AdapterInterface]]:
"""Snapshot the active adapters under the lock, in ascending
adapter-id order. Iterate this instead of the live dicts so a
concurrent add/delete cannot change them mid-iteration.
Returns:
A list of ``(adapter_id, descriptor, adapter)`` tuples.
"""
with self._adapters_lock:
return [
(adapter_id, self._adapter_descriptors[adapter_id], adapter)
for adapter_id, adapter in sorted(self._l2_adapters.items())
]
def _has_l2_adapters(self) -> bool:
"""Return whether any L2 adapter is currently active."""
with self._adapters_lock:
return bool(self._l2_adapters)
def _build_l2_adapter(
self,
config: L2AdapterConfigBase,
) -> tuple[int, L2AdapterInterface, AdapterDescriptor]:
"""Create a L2 adapter instance based on the config.
Args:
config: The adapter configuration.
Returns:
A ``(adapter_id, adapter, descriptor)`` tuple. ``adapter_id`` is
the freshly allocated stable id, ``adapter`` is the new adapter
instance, and ``descriptor`` is its descriptor carrying that id.
"""
adapter_id = self._next_adapter_id
self._next_adapter_id += 1
adapter: L2AdapterInterface = create_l2_adapter(config, self._l1_memory_desc)
if config.serde_config is not None:
adapter = SerdeL2AdapterWrapper(
inner=adapter,
serde=create_serde_processor(config.serde_config),
l1_manager=self._l1_manager,
)
descriptor = AdapterDescriptor(index=adapter_id, config=config)
return adapter_id, adapter, descriptor
def _should_enable_l2_eviction(
self,
adapter: L2AdapterInterface,
eviction_config: EvictionConfig | None,
) -> bool:
"""Whether to wire an adapter into the L2 eviction controller.
Args:
adapter: The adapter to evaluate.
eviction_config: The adapter's eviction config, if any.
Returns:
True if an eviction state should be created for this adapter.
"""
if eviction_config is None:
return False
policy_name = eviction_config.eviction_policy
if policy_name != "IsolatedLRU" and not adapter.supports_global_eviction:
logger.warning(
"L2 adapter %s configured with '%s' eviction but does "
"not support global eviction (max_capacity_bytes=0); "
"skipping aggregate-usage eviction setup.",
type(adapter).__name__,
policy_name,
)
return False
return True
def _unwrap_reconfigurable_l2_adapter(
self,
adapter: L2AdapterInterface,
) -> Optional[L2ReconfigurableAdapter]:
if isinstance(adapter, L2ReconfigurableAdapter):
return adapter
inner = getattr(adapter, "inner_adapter", None)
if inner is not None and isinstance(inner, L2ReconfigurableAdapter):
return inner
return None
def _list_reconfigurable_l2_adapters(
self,
) -> list[tuple[int, L2ReconfigurableAdapter]]:
with self._adapters_lock:
items = sorted(self._l2_adapters.items())
adapters: list[tuple[int, L2ReconfigurableAdapter]] = []
for l2_adapter_index, adapter in items:
reconfigurable_adapter = self._unwrap_reconfigurable_l2_adapter(adapter)
if reconfigurable_adapter is not None:
adapters.append((l2_adapter_index, reconfigurable_adapter))
return adapters
def _get_reconfigurable_l2_adapter(
self,
adapter_index: int,
) -> L2ReconfigurableAdapter:
adapters = self._list_reconfigurable_l2_adapters()
if adapter_index < 0 or adapter_index >= len(adapters):
raise L2ReconfigureError(404, "L2 adapter not reconfigurable")
return adapters[adapter_index][1]