Files
lmcache--lmcache/lmcache/v1/cache_controller/utils.py
T
2026-07-13 12:24:33 +08:00

680 lines
25 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, NamedTuple, Optional, Set
import threading
# Third Party
import zmq.asyncio
# First Party
from lmcache.logging import init_logger
from lmcache.v1.cache_controller.locks import FastLockWithTimeout, RWLockWithTimeout
from lmcache.v1.cache_controller.message import BatchedKVOperationMsg, WorkerInfo
from lmcache.v1.utils.cache_utils import TTLListCache
logger = init_logger(__name__)
class KVChunkInfo(NamedTuple):
"""
Represents the location information of a KV chunk in the cluster.
This class is immutable and can be used as a dictionary key.
"""
instance_id: str
worker_id: int
location: str
class FullSyncState(Enum):
"""State of full sync for a worker"""
IDLE = "idle" # Not in sync
SYNCING = "syncing" # Sync in progress
COMPLETED = "completed" # Sync completed
FAILED = "failed" # Sync failed/timeout
@dataclass
class WorkerSyncInfo:
"""Information about a worker's sync state"""
sync_id: str
state: FullSyncState
start_time: float
expected_total_keys: int
expected_batch_count: int
received_batches: Set[int] = field(default_factory=set)
received_keys_count: int = 0
last_activity_time: float = 0.0
def __post_init__(self):
if self.last_activity_time == 0.0:
self.last_activity_time = self.start_time
@dataclass
class WorkerNode:
"""
Represents a single worker with all its associated metadata.
"""
worker_id: int
ip: str
port: int
peer_init_url: Optional[str]
socket: Optional[zmq.asyncio.Socket]
registration_time: float
last_heartbeat_time: float
# Guarded by _lock
seq_tracker: dict[str, int] = field(default_factory=dict) # location -> seq_num
kv_store: dict[str, set[int]] = field(
default_factory=dict
) # location -> set[chunk_hash]
sync_info: Optional[WorkerSyncInfo] = None # Full sync state
def __post_init__(self):
# Fast lock with timeout for WorkerNode operations
self._lock = FastLockWithTimeout()
def handle_batched_kv_operations(
self,
msg: BatchedKVOperationMsg,
on_seq_num_out_of_order: Optional[Callable[[], None]] = None,
is_full_sync: bool = False,
) -> bool:
"""
Handle batched KV operations with single lock acquisition.
Logs warning and calls callback if sequence out of order is detected.
Args:
msg: The batched KV operation message.
on_seq_num_out_of_order: Callback when sequence out of order detected.
is_full_sync: If True, this is a full sync batch operation.
During full sync, sequence check is skipped.
When worker is syncing, non-full-sync operations are rejected.
Returns:
True if operations were processed, False if rejected
(e.g., during syncing when is_full_sync=False).
"""
seq_warning: Optional[tuple[int, int, int]] = None
with self._lock:
# During syncing, reject non-full-sync operations
if (
self.sync_info is not None
and self.sync_info.state == FullSyncState.SYNCING
and not is_full_sync
):
return False
location = msg.location
if location not in self.kv_store:
self.kv_store[location] = set()
for op in msg.operations:
# Apply operation first, then check sequence
# (sequence check is skipped during full sync)
if op.op_type.value == "admit":
self.kv_store[location].add(op.key)
elif op.op_type.value == "evict":
self.kv_store[location].discard(op.key)
else:
logger.error(f"Unknown op_type: {op.op_type}")
# Skip sequence check during full sync
if is_full_sync:
continue
# Sequence check
last_seq_num = self.seq_tracker.get(location)
if last_seq_num is not None:
expected_seq = last_seq_num + 1
if op.seq_num != expected_seq and seq_warning is None:
seq_warning = (
expected_seq,
op.seq_num,
op.seq_num - expected_seq,
)
self.seq_tracker[location] = op.seq_num
# Clean up empty set
if not self.kv_store[location]:
self.kv_store.pop(location, None)
# Log warning and call callback outside lock
if seq_warning is not None:
if on_seq_num_out_of_order is not None:
on_seq_num_out_of_order()
logger.warning(
"KV batch sequence out of order detected: "
"key=%s, expected_seq=%s, actual_seq=%s, gap=%s",
(msg.instance_id, msg.worker_id, msg.location),
seq_warning[0],
seq_warning[1],
seq_warning[2],
)
return True
def has_kv(self, location: str, key: int) -> bool:
"""Check if a KV chunk exists in this worker."""
with self._lock:
return location in self.kv_store and key in self.kv_store[location]
def get_kv_keys(self, location: str) -> set[int]:
"""Get all keys for a location."""
with self._lock:
keys = self.kv_store.get(location)
if keys is None:
return set()
# Return a shallow copy for thread safety
return set(keys)
def clear_kv_store(self) -> None:
"""Clear all KV data for this worker."""
with self._lock:
self.kv_store.clear()
self.seq_tracker.clear()
def get_kv_count(self) -> int:
"""Get total count of KV chunks."""
with self._lock:
return sum(len(keys) for keys in self.kv_store.values())
def update_seq_num(self, location: str, seq_num: int) -> None:
"""Update sequence number for a location."""
with self._lock:
self.seq_tracker[location] = seq_num
def get_seq_num(self, location: str) -> Optional[int]:
"""Get sequence number for a location."""
with self._lock:
return self.seq_tracker.get(location)
def find_key(
self, key: int
) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
"""
Find a key in this worker's kv_store.
Returns: (KVChunkInfo, peer_init_url, keys) if found, None otherwise.
KVChunkInfo will have instance_id="" since WorkerNode doesn't know
its instance.
"""
with self._lock:
for location, keys in self.kv_store.items():
if key in keys:
# WorkerNode doesn't know its instance_id, so we leave it empty
# The caller should fill in the instance_id
return (
KVChunkInfo("", self.worker_id, location),
self.peer_init_url,
keys,
)
return None
def find_key_simple(self, key: int) -> Optional[KVChunkInfo]:
"""
Find a key in this worker's kv_store, returning only KVChunkInfo.
KVChunkInfo will have instance_id="" since WorkerNode doesn't know
its instance.
"""
with self._lock:
for location, keys in self.kv_store.items():
if key in keys:
return KVChunkInfo("", self.worker_id, location)
return None
def to_worker_info(self, instance_id: str) -> WorkerInfo:
"""Convert to WorkerInfo for backward compatibility."""
# No need to lock here
return WorkerInfo(
instance_id=instance_id,
worker_id=self.worker_id,
ip=self.ip,
port=self.port,
peer_init_url=self.peer_init_url,
registration_time=self.registration_time,
last_heartbeat_time=self.last_heartbeat_time,
)
@dataclass
class InstanceNode:
"""
Represents an instance with all its workers.
Tree structure: InstanceNode -> WorkerNode
Each InstanceNode has its own lock for thread-safe worker operations.
"""
instance_id: str
# Guarded by _rwlock
workers: dict[int, WorkerNode] = field(
default_factory=dict
) # worker_id -> WorkerNode
def __post_init__(self):
# RW lock for protecting workers dict access
self._rwlock = RWLockWithTimeout()
def add_worker(self, worker_node: WorkerNode) -> None:
"""Add a worker to this instance."""
with self._rwlock.write_lock(timeout=10):
self.workers[worker_node.worker_id] = worker_node
def remove_worker(self, worker_id: int) -> Optional[WorkerNode]:
"""Remove and return a worker from this instance."""
with self._rwlock.write_lock(timeout=10):
return self.workers.pop(worker_id, None)
def get_worker(self, worker_id: int) -> Optional[WorkerNode]:
"""Get a worker by worker_id. Uses optimistic locking (lock-free read)."""
# Optimistic read: workers dict rarely changes
return self.workers.get(worker_id)
def get_worker_ids(self) -> list[int]:
"""Get sorted list of worker IDs."""
# Snapshot keys to avoid RuntimeError if dict changes during iteration
return sorted(list(self.workers.keys()))
def has_workers(self) -> bool:
"""Check if instance has any workers."""
# Optimistic read: workers dict rarely changes
return len(self.workers) > 0
def get_all_worker_infos(self) -> list[WorkerInfo]:
"""Get WorkerInfo for all workers in this instance."""
# Snapshot values to avoid RuntimeError if dict changes during iteration
workers_snapshot = list(self.workers.values())
return [worker.to_worker_info(self.instance_id) for worker in workers_snapshot]
def find_key(
self, key: int
) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
"""
Find a key in any worker within this instance.
Returns: (KVChunkInfo, peer_init_url, keys) if found, None otherwise.
"""
# Snapshot workers to avoid RuntimeError if dict changes during iteration
workers_snapshot = list(self.workers.items())
for worker_id, worker_node in workers_snapshot:
if result := worker_node.find_key(key):
# Fill in the instance_id in KVChunkInfo
kv_info, peer_init_url, keys = result
return (
KVChunkInfo(self.instance_id, worker_id, kv_info.location),
peer_init_url,
keys,
)
return None
def find_key_simple(self, key: int) -> Optional[KVChunkInfo]:
"""
Find a key in any worker within this instance, returning only KVChunkInfo.
"""
# Snapshot workers to avoid RuntimeError if dict changes during iteration
workers_snapshot = list(self.workers.items())
for worker_id, worker_node in workers_snapshot:
if kv_info := worker_node.find_key_simple(key):
return KVChunkInfo(self.instance_id, worker_id, kv_info.location)
return None
def has_worker_with_ip(self, ip: str) -> bool:
"""
Check if any worker in this instance has the specified IP address.
"""
# Snapshot workers to avoid RuntimeError if dict changes during iteration
return any(worker.ip == ip for worker in list(self.workers.values()))
def get_total_kv_count(self) -> int:
"""
Get total count of KV chunks across all workers in this instance.
"""
# Snapshot workers to avoid RuntimeError if dict changes during iteration
return sum(worker.get_kv_count() for worker in list(self.workers.values()))
class RegistryTree:
"""
Central registry managing the tree structure of instances and workers.
Structure: instance_id -> InstanceNode -> WorkerNode
Lock hierarchy (from coarse to fine):
1. RegistryTree._rwlock: protects instances dict access
2. InstanceNode._rwlock: protects workers dict access
3. WorkerNode._lock: protects kv_store and seq_tracker access
This fine-grained locking allows concurrent operations on different
instances/workers, improving throughput significantly.
"""
def __init__(self):
# Guarded by _rwlock
# instance_id -> InstanceNode
self.instances: dict[str, InstanceNode] = {}
# RW lock only for protecting instances dict access
self._rwlock = RWLockWithTimeout()
# Atomic counter for sequence discontinuity (protected by _counter_lock)
self._seq_discontinuity_count = 0
self._counter_lock = threading.Lock()
# Cache for get_all_worker_infos (for Prometheus metrics)
self._worker_info_cache: TTLListCache[WorkerInfo] = TTLListCache[WorkerInfo]()
# Cache for get_all_worker_nodes (for FullSyncTracker and other use cases)
self._worker_node_cache: TTLListCache[tuple[str, WorkerNode]] = TTLListCache[
tuple[str, WorkerNode]
]()
def get_seq_discontinuity_count(self) -> int:
"""Get the count of sequence discontinuities (thread-safe)."""
# Lock-free read, no need for lock
return self._seq_discontinuity_count
def _incr_seq_discontinuity_count(self) -> None:
"""Increment the sequence discontinuity counter (thread-safe)."""
with self._counter_lock:
self._seq_discontinuity_count += 1
def _get_or_create_instance(self, instance_id: str) -> InstanceNode:
"""Get or create an instance node. Internal use only."""
# Optimistic read first: instances dict rarely changes
instance_node = self.instances.get(instance_id)
if instance_node is not None:
return instance_node
# Need to create, use write lock
with self._rwlock.write_lock(timeout=10):
# Double-check after acquiring write lock
instance_node = self.instances.get(instance_id)
if instance_node is None:
instance_node = InstanceNode(instance_id=instance_id)
self.instances[instance_id] = instance_node
return instance_node
def _get_instance(self, instance_id: str) -> Optional[InstanceNode]:
"""Get an instance node. Uses optimistic locking (lock-free read)."""
# Optimistic read: instances dict rarely changes
return self.instances.get(instance_id)
def register_worker(
self,
instance_id: str,
worker_id: int,
ip: str,
port: int,
peer_init_url: Optional[str],
socket: zmq.asyncio.Socket,
registration_time: float,
) -> WorkerNode:
"""Register a new worker, creating instance if needed."""
# Get or create instance (locks instances dict)
instance_node = self._get_or_create_instance(instance_id)
# Create worker node
worker_node = WorkerNode(
worker_id=worker_id,
ip=ip,
port=port,
peer_init_url=peer_init_url,
socket=socket,
registration_time=registration_time,
last_heartbeat_time=registration_time,
)
# Add worker (locks workers dict in instance_node)
instance_node.add_worker(worker_node)
return worker_node
def deregister_worker(
self, instance_id: str, worker_id: int
) -> Optional[WorkerNode]:
"""Deregister a worker and clean up empty instances."""
instance_node = self._get_instance(instance_id)
if instance_node is None:
return None
# Remove worker (locks workers dict in instance_node)
worker_node = instance_node.remove_worker(worker_id)
# Clean up empty instance (need write lock on instances)
if not instance_node.has_workers():
# TODO(baoloongmao): Move timeout values to configuration
with self._rwlock.write_lock(timeout=100):
# Double-check after acquiring write lock
# Use pop to avoid KeyError if another thread already removed it
if not instance_node.has_workers() and instance_id in self.instances:
self.instances.pop(instance_id, None)
return worker_node
def get_worker(self, instance_id: str, worker_id: int) -> Optional[WorkerNode]:
"""Get a specific worker."""
instance_node = self._get_instance(instance_id)
if instance_node is None:
return None
return instance_node.get_worker(worker_id)
def get_instance(self, instance_id: str) -> Optional[InstanceNode]:
"""Get an instance by instance_id."""
return self._get_instance(instance_id)
def get_instance_by_ip(self, ip: str) -> Optional[InstanceNode]:
"""Get an instance by IP address. Returns first instance if multiple exist."""
# Snapshot to avoid RuntimeError if dict changes during iteration
for instance_node in list(self.instances.values()):
if instance_node.has_worker_with_ip(ip):
return instance_node
return None
def get_instances_by_ip(self, ip: str) -> list[InstanceNode]:
"""Get all instances by IP address."""
# Snapshot to avoid RuntimeError if dict changes during iteration
result = []
for instance_node in list(self.instances.values()):
if instance_node.has_worker_with_ip(ip):
result.append(instance_node)
return result
def get_worker_ids(self, instance_id: str) -> list[int]:
"""Get sorted list of worker IDs for an instance."""
instance_node = self._get_instance(instance_id)
if instance_node is None:
return []
return instance_node.get_worker_ids()
def get_all_worker_infos_cached(
self, timeout_seconds: Optional[float] = None
) -> list[WorkerInfo]:
"""Get WorkerInfo for all workers across all instances.
Args:
timeout_seconds: Cache timeout in seconds.
- If None (default): use the cache's default timeout.
- If 0: cache immediately expires, always get fresh data
and update cache.
- If > 0: use cache with specified timeout.
Returns:
List of WorkerInfo, either from cache or fresh data
"""
return self._worker_info_cache.get_cached(
lambda: self._get_all_worker_infos_fresh(), timeout_override=timeout_seconds
)
def _get_all_worker_infos_fresh(self) -> list[WorkerInfo]:
"""Internal method to get fresh WorkerInfo without cache."""
# Snapshot to avoid RuntimeError if dict changes during iteration
result = []
for instance_node in list(self.instances.values()):
result.extend(instance_node.get_all_worker_infos())
return result
def get_all_worker_nodes_cached(
self, timeout_seconds: Optional[float] = None
) -> list[tuple[str, WorkerNode]]:
"""Get all (instance_id, WorkerNode) tuples across all instances, with caching.
Args:
timeout_seconds: Cache timeout in seconds.
- If None (default): use the cache's default timeout.
- If 0: cache immediately expires, always get fresh data
and update cache.
- If > 0: use cache with specified timeout.
Returns:
List of (instance_id, WorkerNode) tuples, either from cache or fresh data
"""
return self._worker_node_cache.get_cached(
lambda: self._get_all_worker_nodes_fresh(), timeout_override=timeout_seconds
)
def _get_all_worker_nodes_fresh(self) -> list[tuple[str, WorkerNode]]:
"""Internal method to get fresh (instance_id, WorkerNode) tuples
without cache."""
# Snapshot to avoid RuntimeError if dict changes during iteration
result = []
for instance_id, instance_node in self.instances.items():
for worker_node in instance_node.workers.values():
result.append((instance_id, worker_node))
return result
def update_heartbeat(
self, instance_id: str, worker_id: int, timestamp: float
) -> bool:
"""Update worker heartbeat timestamp. Returns True if successful."""
instance_node = self._get_instance(instance_id)
if instance_node is None:
return False
worker_node = instance_node.get_worker(worker_id)
if worker_node is None:
return False
worker_node.last_heartbeat_time = timestamp
return True
def handle_batched_kv_operations(
self, msg: BatchedKVOperationMsg, is_full_sync: bool = False
) -> bool:
"""
Handle batched KV operations by forwarding to WorkerNode.
Args:
msg: The batched KV operation message.
is_full_sync: If True, this is a full sync batch operation.
Returns:
True if operations were processed successfully.
False if worker not found or operations were rejected.
"""
instance_node = self._get_instance(msg.instance_id)
if instance_node is None:
return False
worker_node = instance_node.get_worker(msg.worker_id)
if worker_node is None:
return False
return worker_node.handle_batched_kv_operations(
msg,
on_seq_num_out_of_order=self._incr_seq_discontinuity_count,
is_full_sync=is_full_sync,
)
def find_kv(
self,
key: int,
exclude_instance_id: Optional[str] = None,
) -> Optional[KVChunkInfo]:
"""
Find a KV chunk across all workers.
Args:
key: The KV chunk key to find.
exclude_instance_id: Instance ID to exclude
(all workers in this instance will be excluded).
Returns: KVChunkInfo if found, None otherwise.
"""
with self._rwlock.read_lock(timeout=1):
for instance_id, instance_node in self.instances.items():
# Exclude all workers in the specified instance
if (
exclude_instance_id is not None
and instance_id == exclude_instance_id
):
continue
result = instance_node.find_key_simple(key)
if result is not None:
return result
return None
def find_kv_with_worker_info(
self,
key: int,
exclude_instance_id: Optional[str] = None,
) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
"""
Find a KV chunk and return worker info in one lookup.
Optimized for batched_p2p_lookup to avoid multiple lookups.
Returns: (KVChunkInfo, peer_init_url, kv_keys) if found, None otherwise.
"""
# Snapshot instances to avoid RuntimeError if dict changes during iteration
for instance_id, instance_node in list(self.instances.items()):
if exclude_instance_id is not None and instance_id == exclude_instance_id:
continue
result = instance_node.find_key(key)
if result is not None:
return result
return None
def get_total_kv_count(self) -> int:
"""Get total count of KV chunks across all workers."""
# Snapshot to avoid RuntimeError if dict changes during iteration
total = 0
for instance_node in list(self.instances.values()):
total += instance_node.get_total_kv_count()
return total
def get_worker_kv_keys(
self, instance_id: str, worker_id: int, location: str
) -> set[int]:
"""Get all KV keys for a specific worker and location."""
instance_node = self._get_instance(instance_id)
if instance_node is None:
return set()
worker_node = instance_node.get_worker(worker_id)
if worker_node is None:
return set()
return worker_node.get_kv_keys(location)
def clear_worker_kv(
self, instance_id: str, worker_id: int, location: Optional[str] = None
) -> bool:
"""
Clear all KV chunks for a specific worker and location.
Returns True if successful, False if worker not found.
"""
worker_node = self.get_worker(instance_id, worker_id)
if worker_node is None:
return False
with worker_node._lock:
if location is None:
worker_node.kv_store.clear()
worker_node.seq_tracker.clear()
return True
if location in worker_node.kv_store:
del worker_node.kv_store[location]
if location in worker_node.seq_tracker:
del worker_node.seq_tracker[location]
return True
def clear_all_worker_kv(self, instance_id: str, worker_id: int) -> bool:
"""
Clear all KV chunks for a worker across all locations.
Returns True if successful, False if worker not found.
"""
worker_node = self.get_worker(instance_id, worker_id)
if worker_node is None:
return False
worker_node.clear_kv_store()
return True