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

411 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Future
from __future__ import annotations
# Standard
from abc import abstractmethod
from collections import Counter
from typing import TYPE_CHECKING
import threading
import time
# First Party
from lmcache.logging import init_logger
from lmcache.v1.distributed.api import ObjectKey
from lmcache.v1.distributed.config import EvictionConfig
from lmcache.v1.distributed.eviction import L1EvictionPolicy, L2EvictionPolicy
from lmcache.v1.distributed.eviction_policy import CreateEvictionPolicy
from lmcache.v1.distributed.internal_api import (
EvictionAction,
EvictionDestination,
)
from lmcache.v1.distributed.l1_manager import L1Manager
from lmcache.v1.distributed.l2_adapters.base import L2AdapterInterface
from lmcache.v1.distributed.storage_controller import StorageControllerInterface
from lmcache.v1.mp_observability.event import Event, EventType
from lmcache.v1.mp_observability.event_bus import get_event_bus
if TYPE_CHECKING:
# First Party
from lmcache.v1.distributed.quota_manager import QuotaManager
logger = init_logger(__name__)
class EvictionController(StorageControllerInterface):
"""
Abstract base class for eviction controllers.
Provides the shared eviction loop structure: background thread and stop
flag. Subclasses implement eviction_loop and execute_eviction_action
for their specific tier (L1 or L2).
"""
def __init__(self):
self._stop_flag = threading.Event()
self._thread = threading.Thread(
target=self.eviction_loop,
daemon=True,
)
def start(self):
logger.info("Starting %s...", self.__class__.__name__)
self._thread.start()
def stop(self):
self._stop_flag.set()
self._thread.join()
@abstractmethod
def report_status(self) -> dict:
"""Return a status dict for this controller.
The child class needs to override this function to report
controller-specific health and configuration information.
"""
pass
@abstractmethod
def eviction_loop(self):
"""Run the eviction loop.
The child class needs to override this function to implement
internal eviction controlling logic.
"""
pass
@abstractmethod
def execute_eviction_action(self, action: EvictionAction):
"""Execute a single eviction action.
The child class needs to override this function to implement
internal eviction controlling logic.
"""
pass
class L1EvictionController(EvictionController):
"""
Eviction controller for L1 cache.
Uses an L1EvictionPolicy bridge to keep the eviction policy up-to-date
with L1 manager events, and periodically triggers eviction based on
L1 memory usage.
"""
def __init__(
self,
l1_manager: L1Manager,
eviction_config: EvictionConfig,
):
super().__init__()
self._eviction_config = eviction_config
self._eviction_policy = CreateEvictionPolicy(eviction_config)
self._l1_manager = l1_manager
self._listener = L1EvictionPolicy(self._eviction_policy)
self._l1_manager.register_listener(self._listener)
self._event_bus = get_event_bus()
def report_status(self) -> dict:
return {
"is_healthy": self._thread.is_alive(),
"thread_alive": self._thread.is_alive(),
"eviction_policy": self._eviction_config.eviction_policy,
"trigger_watermark": self._eviction_config.trigger_watermark,
"eviction_ratio": self._eviction_config.eviction_ratio,
}
def _publish_skipped(self, usage: float, watermark: float) -> None:
"""Publish a below-watermark loop tick (no eviction this cycle)."""
self._event_bus.publish(
Event(
event_type=EventType.L1_EVICTION_LOOP_TICK,
metadata={
"usage": usage,
"watermark": watermark,
"triggered": False,
},
)
)
def _publish_triggered(self, usage: float, watermark: float) -> None:
"""Publish an above-watermark loop tick (eviction policy ran)."""
self._event_bus.publish(
Event(
event_type=EventType.L1_EVICTION_LOOP_TICK,
metadata={
"usage": usage,
"watermark": watermark,
"triggered": True,
},
)
)
def eviction_loop(self):
watermark = self._eviction_config.trigger_watermark
eviction_ratio = self._eviction_config.eviction_ratio
while not self._stop_flag.is_set():
time.sleep(1)
used_bytes, total_bytes = self._l1_manager.get_memory_usage()
usage = 0 if total_bytes == 0 else used_bytes / total_bytes
if usage < watermark:
logger.debug(
"L1 memory usage %.2f below watermark %.2f; skipping eviction.",
usage,
watermark,
)
self._publish_skipped(usage, watermark)
continue
logger.info(
"L1 memory usage %.2f above watermark %.2f; triggering eviction.",
usage,
watermark,
)
actions = self._eviction_policy.get_eviction_actions(
eviction_ratio,
key_eligible_filter=self._l1_manager.is_key_evictable,
)
for action in actions:
self.execute_eviction_action(action)
self._publish_triggered(usage, watermark)
def execute_eviction_action(self, action: EvictionAction):
if action.destination == EvictionDestination.DISCARD:
self._l1_manager.delete(action.keys)
else:
logger.error("Unsupported eviction destination: %s", action.destination)
logger.error("Treating it as DISCARD.")
self._l1_manager.delete(action.keys)
class L2AdapterEvictionState:
"""Per-adapter eviction state: its own policy, listener, and config."""
def __init__(
self,
adapter_id: int,
adapter: L2AdapterInterface,
eviction_config: EvictionConfig,
):
self.adapter_id = adapter_id
self.adapter = adapter
self.eviction_config = eviction_config
self.eviction_policy = CreateEvictionPolicy(eviction_config)
self.listener = L2EvictionPolicy(self.eviction_policy)
adapter.register_listener(self.listener)
class L2EvictionController(StorageControllerInterface):
"""
Unified eviction controller for all L2 adapters.
Each adapter gets its own eviction policy and listener bridge, but a
single background thread loops over all of them.
When the adapter's policy sets ``support_isolation == True``
(e.g. :class:`IsolatedLRUEvictionPolicy`), the controller consults
the injected :class:`QuotaManager` to decide which ``cache_salt``
buckets are over budget and evicts from each one in isolation.
Otherwise it uses the adapter's aggregate ``usage_fraction``
against the configured watermark — unchanged from the pre-PR5
behavior.
"""
def __init__(
self,
l2_adapter_states: list[L2AdapterEvictionState],
quota_manager: QuotaManager | None = None,
):
self._adapter_states = l2_adapter_states
self._quota_manager = quota_manager
# Guards _adapter_states against concurrent runtime add/remove.
self._states_lock = threading.Lock()
self._stop_flag = threading.Event()
self._thread = threading.Thread(
target=self._eviction_loop,
daemon=True,
)
def start(self):
logger.info("Starting %s...", self.__class__.__name__)
self._thread.start()
def stop(self):
self._stop_flag.set()
self._thread.join()
def add_adapter_state(self, state: L2AdapterEvictionState) -> None:
"""Register a new adapter's eviction state at runtime."""
with self._states_lock:
self._adapter_states.append(state)
def remove_adapter_state(self, adapter_id: int) -> None:
"""Drop the eviction state for ``adapter_id``.
Blocks until any in-progress eviction pass finishes (it holds the
same lock), so the adapter is guaranteed idle here before the
caller closes it. A no-op if the adapter has no eviction state.
"""
with self._states_lock:
self._adapter_states = [
s for s in self._adapter_states if s.adapter_id != adapter_id
]
def report_status(self) -> dict:
# NOTE: ``usage.bytes_by_cache_salt`` is intentionally NOT
# surfaced here. A deployment can have 10k+ salts, so embedding
# the full bucket map in the status response would blow up the
# payload. Per-salt inspection goes through the dedicated HTTP
# quota endpoints (which pull from ``QuotaManager`` +
# ``StorageManager.get_usage_bytes_by_cache_salt``).
adapter_statuses = []
with self._states_lock:
states = list(self._adapter_states)
for state in states:
usage = state.adapter.get_usage()
adapter_statuses.append(
{
"eviction_policy": state.eviction_config.eviction_policy,
"trigger_watermark": state.eviction_config.trigger_watermark,
"eviction_ratio": state.eviction_config.eviction_ratio,
"current_usage": usage.usage_fraction,
"total_bytes_used": usage.total_bytes_used,
"total_capacity_bytes": usage.total_capacity_bytes,
"num_cache_salt_buckets": len(usage.bytes_by_cache_salt),
}
)
return {
"is_healthy": self._thread.is_alive(),
"thread_alive": self._thread.is_alive(),
"adapters": adapter_statuses,
}
def _eviction_loop(self):
while not self._stop_flag.is_set():
time.sleep(1)
# Hold the lock across the whole pass so remove_adapter_state
# cannot detach (and the caller close) an adapter while we are
# calling into it.
with self._states_lock:
for state in self._adapter_states:
self._check_and_evict(state)
def _check_and_evict(self, state: L2AdapterEvictionState):
if state.eviction_policy.support_isolation and self._quota_manager is not None:
self._check_and_evict_by_cache_salt(state)
else:
self._check_and_evict_global(state)
def _check_and_evict_global(self, state: L2AdapterEvictionState):
"""Aggregate-usage eviction (``LRU`` / ``noop``)."""
watermark = state.eviction_config.trigger_watermark
eviction_ratio = state.eviction_config.eviction_ratio
# ``usage_fraction == -1`` means the adapter doesn't support
# usage-based eviction (no max_capacity_bytes declared), so we
# do not trigger eviction. Adapters with ``supports_global_eviction ==
# False`` should already have been filtered out at construction
# time in ``StorageManager``; this check is a defensive belt.
current_usage = state.adapter.get_usage().usage_fraction
if current_usage < 0 or current_usage < watermark:
logger.debug(
"L2 usage %.2f below watermark %.2f; skipping eviction.",
current_usage,
watermark,
)
return
logger.info(
"L2 usage %.2f above watermark %.2f; triggering eviction.",
current_usage,
watermark,
)
actions = state.eviction_policy.get_eviction_actions(eviction_ratio)
for action in actions:
self._execute_eviction_action(state.adapter, action)
def _check_and_evict_by_cache_salt(self, state: L2AdapterEvictionState):
"""Per-``cache_salt`` eviction driven by :class:`QuotaManager`.
For every salt with non-zero bytes, compare its usage against
``watermark * quota``. Salts over threshold get eviction scoped
to their own LRU list. Salts with no quota registered have an
effective limit of ``0`` and are therefore always over budget,
so they get a full eviction (``effective_ratio=1.0``) — this
enforces the allowlist rule: only registered salts retain data.
Per-destination keys are batched across all over-budget salts
before invoking the adapter — one ``adapter.delete(...)`` call
per destination instead of one per (salt, destination) pair.
Adapters with non-trivial per-call overhead (NIXL handle setup,
FS sync, etc.) see this as a real win when many salts go over
budget in the same cycle.
"""
assert self._quota_manager is not None
watermark = state.eviction_config.trigger_watermark
eviction_ratio = state.eviction_config.eviction_ratio
usage = state.adapter.get_usage()
# destination -> accumulated keys across all over-budget salts.
pending: dict[EvictionDestination, list[ObjectKey]] = {}
for cache_salt, user_bytes in usage.bytes_by_cache_salt.items():
if user_bytes <= 0:
continue
limit = self._quota_manager.get_limit_bytes(cache_salt)
# Trigger on ``>=`` to match the global branch's ``usage <
# watermark`` short-circuit. Salts with no quota (limit=0)
# always land here because ``user_bytes > 0 >= 0``.
if user_bytes < watermark * limit:
continue
# Unregistered / zero-quota salts: wipe everything.
# Registered salts: evict the configured ratio of their list.
effective_ratio = 1.0 if limit == 0 else eviction_ratio
logger.info(
"cache_salt=%r over quota (bytes=%d, limit=%d, "
"watermark=%.2f); evicting ratio=%.2f.",
cache_salt,
user_bytes,
limit,
watermark,
effective_ratio,
)
actions = state.eviction_policy.get_eviction_actions(
effective_ratio, cache_salt=cache_salt
)
for action in actions:
pending.setdefault(action.destination, []).extend(action.keys)
for destination, keys in pending.items():
self._execute_eviction_action(
state.adapter,
EvictionAction(keys=keys, destination=destination),
)
def _execute_eviction_action(
self, adapter: L2AdapterInterface, action: EvictionAction
):
if action.destination == EvictionDestination.DISCARD:
adapter.delete(action.keys)
else:
logger.error("Unsupported eviction destination: %s", action.destination)
logger.error("Treating it as DISCARD.")
adapter.delete(action.keys)
if action.keys:
get_event_bus().publish(
Event(
event_type=EventType.L2_KEYS_EVICTED,
metadata={
"key_count": len(action.keys),
"key_count_per_salt": Counter(
k.cache_salt for k in action.keys
),
},
)
)