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

476 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
import asyncio
import random
import uuid
# First Party
from lmcache.logging import init_logger
from lmcache.v1.cache_controller.message import (
FullSyncBatchMsg,
FullSyncEndMsg,
FullSyncStartMsg,
FullSyncStartRetMsg,
FullSyncStatusMsg,
FullSyncStatusRetMsg,
)
if TYPE_CHECKING:
# First Party
from lmcache.v1.cache_controller.worker import LMCacheWorker
from lmcache.v1.cache_engine import LMCacheEngine
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.storage_backend.local_cpu_backend import LocalCPUBackend
logger = init_logger(__name__)
@dataclass
class SyncInitResult:
"""Result of sync initialization"""
sync_id: str
keys: List[int]
total_keys: int
batch_count: int
@dataclass
class BatchInfo:
"""Information about a batch for resending"""
batch_id: int
start_idx: int
end_idx: int
class FullSyncSender:
"""
Handles full sync of hot_cache keys to the Controller.
This class manages the process of sending all keys from the local hot_cache
to the Controller when a full sync is requested (e.g., after Controller restart).
The sync process:
1. Enter freeze mode (prevent allocations)
2. Add random startup delay (avoid thundering herd)
3. Send FullSyncStartMsg and wait for confirmation
4. Send keys in batches via FullSyncBatchMsg
5. Send FullSyncEndMsg
6. Poll for completion status
7. Exit freeze mode when threshold is reached
"""
def __init__(
self,
config: "LMCacheEngineConfig",
worker: "LMCacheWorker",
lmcache_engine: "LMCacheEngine",
local_cpu_backend: "LocalCPUBackend",
):
# Configuration
self.batch_size = config.get_extra_config_value("full_sync_batch_size", 2000)
self.batch_interval_ms = config.get_extra_config_value(
"full_sync_batch_interval_ms", 5
)
self.startup_delay_range_s = config.get_extra_config_value(
"full_sync_startup_delay_s", 5.0
)
self.status_poll_interval_s = config.get_extra_config_value(
"full_sync_status_poll_interval_s", 5.0
)
self.max_retry_count = config.get_extra_config_value(
"full_sync_max_retry_count", 3
)
self.retry_delay_s = config.get_extra_config_value(
"full_sync_retry_delay_s", 1.0
)
self.max_poll_attempts = config.get_extra_config_value(
"full_sync_max_poll_attempts", 60
)
# Dependencies
self.worker = worker
self.lmcache_engine = lmcache_engine
self.local_cpu_backend = local_cpu_backend
self.config = config
# State
self._is_syncing = False
self._current_sync_id: Optional[str] = None
@property
def instance_id(self) -> str:
return self.config.lmcache_instance_id
@property
def worker_id(self) -> int:
return self.worker.worker_id
@property
def location(self) -> str:
return str(self.local_cpu_backend)
def _generate_sync_id(self) -> str:
"""Generate a unique sync session ID"""
return f"{self.instance_id}_{self.worker_id}_{uuid.uuid4().hex[:8]}"
def _get_all_hot_cache_keys(self) -> List[int]:
"""Get all chunk hashes from the hot cache"""
keys = self.local_cpu_backend.get_keys()
return [key.chunk_hash for key in keys]
async def _send_sync_start(
self, sync_id: str, total_keys: int, batch_count: int
) -> Optional[FullSyncStartRetMsg]:
"""Send FullSyncStartMsg and wait for confirmation"""
msg = FullSyncStartMsg(
instance_id=self.instance_id,
worker_id=self.worker_id,
location=self.location,
sync_id=sync_id,
total_keys=total_keys,
batch_count=batch_count,
)
try:
ret_msg = await self.worker.async_put_and_wait_msg(msg)
if isinstance(ret_msg, FullSyncStartRetMsg):
return ret_msg
else:
logger.error(
"Unexpected response type for FullSyncStartMsg: %s", type(ret_msg)
)
return None
except Exception as e:
logger.error("Error sending FullSyncStartMsg: %s", e)
return None
def _send_sync_batch(self, sync_id: str, batch_id: int, keys: List[int]) -> None:
"""Send a batch of keys via PUSH mode"""
msg = FullSyncBatchMsg(
instance_id=self.instance_id,
worker_id=self.worker_id,
location=self.location,
sync_id=sync_id,
batch_id=batch_id,
keys=keys,
)
self.worker.put_msg(msg)
def _send_sync_end(self, sync_id: str, actual_total_keys: int) -> None:
"""Send FullSyncEndMsg via PUSH mode"""
msg = FullSyncEndMsg(
instance_id=self.instance_id,
worker_id=self.worker_id,
location=self.location,
sync_id=sync_id,
actual_total_keys=actual_total_keys,
)
self.worker.put_msg(msg)
async def _query_sync_status(self, sync_id: str) -> Optional[FullSyncStatusRetMsg]:
"""Query sync status from controller"""
msg = FullSyncStatusMsg(
instance_id=self.instance_id,
worker_id=self.worker_id,
sync_id=sync_id,
)
try:
ret_msg = await self.worker.async_put_and_wait_msg(msg)
if isinstance(ret_msg, FullSyncStatusRetMsg):
return ret_msg
else:
logger.error(
"Unexpected response type for FullSyncStatusMsg: %s", type(ret_msg)
)
return None
except Exception as e:
logger.error("Error querying sync status: %s", e)
return None
async def _initialize_sync(
self, reason: Optional[str] = None
) -> Optional[SyncInitResult]:
"""
Initialize the sync process.
Handles startup delay, entering freeze mode, getting keys,
and sending the start message with retry.
Args:
reason: The reason for full sync
Returns:
SyncInitResult if initialization succeeded, None otherwise
"""
# Step 1: Random startup delay to avoid thundering herd
delay = random.uniform(0, self.startup_delay_range_s)
logger.info("Full sync startup delay: %.2fs", delay)
await asyncio.sleep(delay)
# Step 2: Enter freeze mode
logger.info("Entering freeze mode for full sync.")
self.lmcache_engine.freeze(True)
# Step 3: Get all keys from hot cache
keys = self._get_all_hot_cache_keys()
total_keys = len(keys)
batch_count = (total_keys + self.batch_size - 1) // self.batch_size
batch_count = max(batch_count, 1) # At least 1 batch even if empty
logger.info(
"Full sync: total_keys=%d, batch_size=%d, batch_count=%d",
total_keys,
self.batch_size,
batch_count,
)
# Step 4: Generate sync ID and send start message with retry
sync_id = self._generate_sync_id()
self._current_sync_id = sync_id
start_accepted = False
for attempt in range(self.max_retry_count):
ret_msg = await self._send_sync_start(sync_id, total_keys, batch_count)
if ret_msg is not None and ret_msg.accepted:
start_accepted = True
break
logger.warning(
"FullSyncStart not accepted, attempt %d/%d, error: %s",
attempt + 1,
self.max_retry_count,
ret_msg.error_msg if ret_msg else "No response",
)
await asyncio.sleep(self.retry_delay_s)
if not start_accepted:
logger.error(
"Failed to start full sync after %d attempts", self.max_retry_count
)
return None
return SyncInitResult(
sync_id=sync_id,
keys=keys,
total_keys=total_keys,
batch_count=batch_count,
)
async def _send_key_batches(
self, sync_id: str, keys: List[int], batch_count: int
) -> int:
"""
Send keys in batches to the controller.
Args:
sync_id: The sync session ID
keys: List of all keys to send
batch_count: Number of batches to send
Returns:
Total number of keys sent
"""
total_keys = len(keys)
for batch_id in range(batch_count):
start_idx = batch_id * self.batch_size
end_idx = min(start_idx + self.batch_size, total_keys)
batch_keys = keys[start_idx:end_idx]
self._send_sync_batch(sync_id, batch_id, batch_keys)
logger.debug(
"Sent batch %d/%d with %d keys",
batch_id + 1,
batch_count,
len(batch_keys),
)
# Small delay between batches to avoid overwhelming controller
if self.batch_interval_ms > 0 and batch_id < batch_count - 1:
await asyncio.sleep(self.batch_interval_ms / 1000.0)
# Send end message
self._send_sync_end(sync_id, total_keys)
logger.info("Full sync batches sent, total_keys=%d", total_keys)
return total_keys
async def _resend_missing_batches(
self, sync_id: str, keys: List[int], missing_batches: List[int]
) -> None:
"""
Resend missing batches to the controller.
Args:
sync_id: The sync session ID
keys: List of all keys
missing_batches: List of missing batch IDs to resend
"""
total_keys = len(keys)
logger.info(
"Resending %d missing batches: %s",
len(missing_batches),
missing_batches,
)
for batch_id in missing_batches:
start_idx = batch_id * self.batch_size
end_idx = min(start_idx + self.batch_size, total_keys)
batch_keys = keys[start_idx:end_idx]
self._send_sync_batch(sync_id, batch_id, batch_keys)
logger.debug(
"Resent batch %d with %d keys",
batch_id,
len(batch_keys),
)
# Small delay between batches
if self.batch_interval_ms > 0:
await asyncio.sleep(self.batch_interval_ms / 1000.0)
logger.info("Finished resending %d missing batches", len(missing_batches))
async def _poll_for_completion(
self, sync_id: str, keys: List[int], total_keys: int
) -> bool:
"""
Poll for sync completion status and resend missing batches if needed.
Args:
sync_id: The sync session ID
keys: List of all keys (needed for resending missing batches)
total_keys: Total number of keys
Returns:
True if sync completed and can exit freeze mode, False on timeout
"""
resend_count = 0
# TODO(baoloongmao): This can be an individual config
max_resend_attempts = self.max_retry_count
for poll_attempt in range(self.max_poll_attempts):
await asyncio.sleep(self.status_poll_interval_s)
status = await self._query_sync_status(sync_id)
if status is None:
logger.warning(
"Failed to query sync status, attempt %d", poll_attempt + 1
)
continue
logger.info(
"Sync status: is_complete=%s, global_progress=%.1f%%, "
"can_exit_freeze=%s, missing_batches=%s",
status.is_complete,
status.global_progress * 100,
status.can_exit_freeze,
status.missing_batches if status.missing_batches else "none",
)
if status.can_exit_freeze:
return True
# Handle missing batches - resend them
if status.missing_batches and resend_count < max_resend_attempts:
resend_count += 1
logger.warning(
"Controller reported missing batches, resending "
"(attempt %d/%d): %s",
resend_count,
max_resend_attempts,
status.missing_batches,
)
await self._resend_missing_batches(
sync_id, keys, status.missing_batches
)
# Resend end message after resending missing batches
self._send_sync_end(sync_id, total_keys)
elif status.missing_batches and resend_count >= max_resend_attempts:
logger.error(
"Max resend attempts reached (%d), "
"giving up on missing batches: %s",
max_resend_attempts,
status.missing_batches,
)
# TODO(baoloongmao): Use heartbeat to detect controller failure
# and exit freeze mode if necessary
logger.warning("Full sync status poll timeout, exiting freeze mode anyway")
return False
async def start_full_sync(self, reason: Optional[str] = None) -> bool:
"""
Start the full sync process.
This method orchestrates the full sync by delegating to helper methods:
1. _initialize_sync: Startup delay, freeze mode, get keys, send start msg
2. _send_key_batches: Send all keys in batches
3. _poll_for_completion: Poll for sync completion status
Args:
reason: The reason for full sync (e.g., "controller_restart")
Returns:
True if sync completed successfully, False otherwise
"""
if self._is_syncing:
logger.warning("Full sync already in progress, skipping")
return False
self._is_syncing = True
self._current_sync_id = None
logger.info(
"Starting full sync for worker %s:%s, reason: %s",
self.instance_id,
self.worker_id,
reason,
)
try:
# Step 1: Initialize sync (delay, freeze, get keys, send start)
init_result = await self._initialize_sync(reason)
if init_result is None:
return False
# Step 2: Send keys in batches
await self._send_key_batches(
init_result.sync_id,
init_result.keys,
init_result.batch_count,
)
# Step 3: Poll for completion status (with resend support)
await self._poll_for_completion(
init_result.sync_id,
init_result.keys,
init_result.total_keys,
)
logger.info("Full sync completed successfully")
return True
except Exception as e:
logger.error("Error during full sync: %s", e)
return False
finally:
# Always clean up state, regardless of success or failure
logger.info("Exiting freeze mode after full sync.")
self.lmcache_engine.freeze(False)
self._is_syncing = False
self._current_sync_id = None
@property
def is_syncing(self) -> bool:
"""Check if full sync is currently in progress"""
return self._is_syncing