# 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