# SPDX-License-Identifier: Apache-2.0 # Standard from typing import Union import asyncio import uuid # Third Party import msgspec import zmq.asyncio # First Party from lmcache.logging import init_logger from lmcache.v1.cache_controller.message import ( # noqa: E501 CheckFinishMsg, CheckFinishRetMsg, ClearMsg, ClearRetMsg, ClearWorkerMsg, CompressMsg, CompressRetMsg, CompressWorkerMsg, DecompressMsg, DecompressRetMsg, DecompressWorkerMsg, ErrorMsg, HealthMsg, HealthRetMsg, HealthWorkerMsg, HealthWorkerRetMsg, MoveMsg, MoveRetMsg, MoveWorkerMsg, Msg, MsgBase, PinMsg, PinRetMsg, PinWorkerMsg, ) logger = init_logger(__name__) # NOTE (Jiayi): `LMCacheClusterExecutor` might need to be in different processes # in the future for the sake of performance. # NOTE (Jiayi): Also, consider scaling up the number of cluster executors # in the future. # TODO (Jiayi): need better error handling class LMCacheClusterExecutor: """ LMCache Cluster Executor class to handle the execution of cache operations. """ def __init__(self, reg_controller): """ Initialize the LMCache Executor with a cache instance. :param lmcache_instance_id: lmcache_instance_id """ self.reg_controller = reg_controller async def clear(self, msg: ClearMsg) -> Union[ClearRetMsg, ErrorMsg]: """ Execute a clear cache operation with error handling. """ instance_id = msg.instance_id location = msg.location worker_ids = self.reg_controller.get_workers(instance_id) assert worker_ids is not None sockets = [] serialized_msgs = [] for worker_id in worker_ids: socket = self.reg_controller.get_socket(instance_id, worker_id) if socket is None: return ErrorMsg( error=( f"Worker {worker_id} not registered for instance {instance_id}" ) ) sockets.append(socket) # TODO(Jiayi): Need a way to track event_id -> worker_event_id mapping # Also, we need to track worker_event_id status worker_event_id = f"Worker{worker_id}{msg.event_id}" serialized_msg = msgspec.msgpack.encode( ClearWorkerMsg( worker_event_id=worker_event_id, location=location, ) ) serialized_msgs.append(serialized_msg) serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) num_tokens_list = [] for i, serialized_result in enumerate(serialized_results): result = msgspec.msgpack.decode(serialized_result, type=Msg) num_tokens_list.append(result.num_tokens) # TODO(Jiayi): Need to ensure cache consistency across workers. assert len(set(num_tokens_list)) == 1, ( "The number of tokens cleared should be the same across all workers." ) return ClearRetMsg(event_id=msg.event_id, num_tokens=num_tokens_list[0]) async def pin(self, msg: PinMsg) -> Union[PinRetMsg, ErrorMsg]: """ Execute a pin cache operation with error handling. """ instance_id = msg.instance_id tokens = msg.tokens location = msg.location worker_ids = self.reg_controller.get_workers(instance_id) assert worker_ids is not None sockets = [] serialized_msgs = [] for worker_id in worker_ids: socket = self.reg_controller.get_socket(instance_id, worker_id) if socket is None: return ErrorMsg( error=( f"Worker {worker_id} not registered for instance {instance_id}" ) ) sockets.append(socket) # TODO(Jiayi): Need a way to track event_id -> worker_event_id mapping # Also, we need to track worker_event_id status worker_event_id = f"Worker{worker_id}{msg.event_id}" serialized_msg = msgspec.msgpack.encode( PinWorkerMsg( worker_event_id=worker_event_id, tokens=tokens, location=location, ) ) serialized_msgs.append(serialized_msg) serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) num_tokens_list = [] for i, serialized_result in enumerate(serialized_results): result = msgspec.msgpack.decode(serialized_result, type=Msg) num_tokens_list.append(result.num_tokens) # TODO(Jiayi): Need to ensure cache consistency across workers. assert len(set(num_tokens_list)) == 1, ( "The number of tokens pinned should be the same across all workers." ) return PinRetMsg(event_id=msg.event_id, num_tokens=num_tokens_list[0]) async def compress(self, msg: CompressMsg) -> Union[CompressRetMsg, ErrorMsg]: """ Execute a compress operation with error handling. """ event_id = msg.event_id instance_id = msg.instance_id method = msg.method location = msg.location tokens = msg.tokens worker_ids = self.reg_controller.get_workers(instance_id) assert worker_ids is not None # TODO(Jiayi): Currently, we do not support PP or heterogeneous TP. # NOTE(Jiayi): The TP ranks are already sorted in registration_controller. sockets = [] serialized_msgs = [] for worker_id in worker_ids: socket = self.reg_controller.get_socket(instance_id, worker_id) if socket is None: return ErrorMsg( error=( f"Worker {worker_id} not registered for " f"instance {instance_id} or " ) ) sockets.append(socket) worker_event_id = f"CompressWorker{worker_id}{str(uuid.uuid4())}" serialized_msg = msgspec.msgpack.encode( CompressWorkerMsg( worker_event_id=worker_event_id, method=method, location=location, tokens=tokens, ) ) serialized_msgs.append(serialized_msg) logger.debug( f"Sending compress operation to worker ({instance_id}, {worker_id})" ) serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) num_tokens_list = [] for serialized_result in serialized_results: result = msgspec.msgpack.decode(serialized_result, type=Msg) num_tokens_list.append(result.num_tokens) # TODO(Jiayi): Need to ensure cache consistency across workers. assert len(set(num_tokens_list)) == 1, ( "The number of tokens compressed should be the same across all workers." ) return CompressRetMsg( event_id=event_id, num_tokens=num_tokens_list[0], ) async def decompress(self, msg: DecompressMsg) -> Union[DecompressRetMsg, ErrorMsg]: """ Execute a decompress operation with error handling. """ event_id = msg.event_id instance_id = msg.instance_id method = msg.method location = msg.location tokens = msg.tokens worker_ids = self.reg_controller.get_workers(instance_id) assert worker_ids is not None sockets = [] serialized_msgs = [] for worker_id in worker_ids: socket = self.reg_controller.get_socket(instance_id, worker_id) if socket is None: return ErrorMsg( error=( f"Worker {worker_id} not registered for " f"instance {instance_id} or " ) ) sockets.append(socket) worker_event_id = f"DecompressWorker{worker_id}{str(uuid.uuid4())}" serialized_msg = msgspec.msgpack.encode( DecompressWorkerMsg( worker_event_id=worker_event_id, method=method, location=location, tokens=tokens, ) ) serialized_msgs.append(serialized_msg) logger.debug( f"Sending decompress operation to worker ({instance_id}, {worker_id})" ) serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) num_tokens_list = [] for serialized_result in serialized_results: result = msgspec.msgpack.decode(serialized_result, type=Msg) num_tokens_list.append(result.num_tokens) assert len(set(num_tokens_list)) == 1, ( "The number of tokens decompressed should be the same across all workers." ) return DecompressRetMsg( event_id=event_id, num_tokens=num_tokens_list[0], ) async def move(self, msg: MoveMsg) -> Union[MoveRetMsg, ErrorMsg]: """ Execute a move cache operation with error handling. """ # NOTE(Jiayi): Currently we assume the transfer is push-based. src_instance_id = msg.old_position[0] dst_instance_id = msg.new_position[0] src_worker_ids = self.reg_controller.get_workers(src_instance_id) assert src_worker_ids is not None dst_worker_ids = self.reg_controller.get_workers(dst_instance_id) assert dst_worker_ids is not None # TODO(Jiayi): Currently, we do not support PP or heterogeneous TP. # NOTE(Jiayi): The TP ranks are already sorted in registration_controller. sockets = [] serialized_msgs = [] for src_worker_id, dst_worker_id in zip( src_worker_ids, dst_worker_ids, strict=False ): socket = self.reg_controller.get_socket(src_instance_id, src_worker_id) dst_url = self.reg_controller.get_peer_init_url( dst_instance_id, dst_worker_id ) if socket is None or dst_url is None: return ErrorMsg( error=( f"Src worker {src_worker_id} not registered for " f"instance {src_instance_id} or " f"dst worker {dst_worker_id} not registered for " f"instance {dst_instance_id} or P2P is not enabled." ) ) sockets.append(socket) worker_event_id = f"MoveWorker{src_worker_id}{str(uuid.uuid4())}" serialized_msg = msgspec.msgpack.encode( MoveWorkerMsg( worker_event_id=worker_event_id, old_position=msg.old_position[1], new_position=(dst_url, msg.new_position[1]), tokens=msg.tokens, copy=msg.copy, ) ) serialized_msgs.append(serialized_msg) logger.debug( f"Sending move operation to worker ({src_instance_id}, {src_worker_id})" ) serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) num_tokens_list = [] for serialized_result in serialized_results: result = msgspec.msgpack.decode(serialized_result, type=Msg) num_tokens_list.append(result.num_tokens) # TODO(Jiayi): Need to ensure cache consistency across workers. assert len(set(num_tokens_list)) == 1, ( "The number of tokens moved should be the same across all workers." ) return MoveRetMsg( event_id=msg.event_id, num_tokens=num_tokens_list[0], ) async def health(self, msg: HealthMsg) -> Union[HealthRetMsg, ErrorMsg]: """ Execute a compress operation with error handling. """ instance_id = msg.instance_id worker_ids = self.reg_controller.get_workers(instance_id) if worker_ids is None: return ErrorMsg(error=f"No workers found for instance {instance_id}") # TODO(Jiayi): Currently, we do not support PP or heterogeneous TP. # NOTE(Jiayi): The TP ranks are already sorted in registration_controller. sockets = [] serialized_msgs = [] for worker_id in worker_ids: socket = self.reg_controller.get_socket(instance_id, worker_id) if socket is None: return ErrorMsg( error=( f"Worker {worker_id} not registered for " f"instance {instance_id} or socket not found" ) ) sockets.append(socket) worker_event_id = f"HealthWorker{worker_id}{str(uuid.uuid4())}" serialized_msg = msgspec.msgpack.encode( HealthWorkerMsg( worker_event_id=worker_event_id, ) ) serialized_msgs.append(serialized_msg) logger.debug( f"Sending health check operation to worker ({instance_id}, {worker_id})" ) # Collect results from all workers serialized_results = await self.execute_workers( sockets=sockets, serialized_msgs=serialized_msgs, ) # Process results error_codes = {} for i, serialized_result in enumerate(serialized_results): try: result = msgspec.msgpack.decode(serialized_result, type=Msg) if isinstance(result, HealthWorkerRetMsg): error_codes[worker_ids[i]] = result.error_code elif isinstance(result, ErrorMsg): error_codes[worker_ids[i]] = -1001 # Worker returned error else: error_codes[worker_ids[i]] = -1002 # Unexpected response except Exception as e: logger.error( f"Failed to parse health response from worker " f"{worker_ids[i]}: {str(e)}" ) error_codes[worker_ids[i]] = -1003 # Failed to parse response return HealthRetMsg( event_id=msg.event_id, error_codes=error_codes, ) async def check_finish( self, msg: CheckFinishMsg ) -> Union[CheckFinishRetMsg, ErrorMsg]: raise NotImplementedError # TODO(Jiayi): need to make the types more specific async def execute(self, operation: str, msg: MsgBase) -> MsgBase: """ Execute a cache operation with error handling. :param operation: The operation to execute (e.g., 'clear'). :param msg: The message containing the operation details. :return: The result of the operation or an error message. """ try: method = getattr(self, operation) return await method(msg) except AttributeError: return ErrorMsg(error=f"Operation '{operation}' is not supported.") except Exception as e: return ErrorMsg(error=str(e)) async def execute_workers( self, sockets: list[zmq.asyncio.Socket], serialized_msgs: list[bytes], ) -> list[bytes]: """ Execute a list of serialized messages on the given sockets. :param sockets: The list of sockets to send the messages to. :param serialized_msgs: The list of serialized messages to send. :return: A list of serialized results received from the sockets. """ tasks = [] for socket, serialized_msg in zip(sockets, serialized_msgs, strict=False): async def send_and_receive(s, msg): await s.send(msg) return await s.recv() tasks.append(send_and_receive(socket, serialized_msg)) serialized_results = await asyncio.gather(*tasks) return serialized_results