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

312 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Unit tests for server-side worker liveness tracking and reaping.
Cover the public liveness interfaces of the transfer modules, the
management reaper wiring, the blend reap listener, and config validation.
No GPU or live server is required; module-level construction dependencies
are stubbed.
"""
# Standard
from typing import cast
from unittest.mock import MagicMock
import threading
import time
# Third Party
import pytest
# First Party
from lmcache.v1.multiprocess.config import MPServerConfig
from lmcache.v1.multiprocess.modules import engine_driven_transfer as non_gpu_mod
from lmcache.v1.multiprocess.modules import lmcache_driven_transfer as gpu_mod
from lmcache.v1.multiprocess.modules.engine_driven_transfer import (
EngineDrivenTransferModule,
)
from lmcache.v1.multiprocess.modules.lmcache_driven_transfer import (
ContextEntry,
LMCacheDrivenTransferModule,
)
from lmcache.v1.multiprocess.modules.management import ManagementModule
from lmcache.v1.periodic_thread import PeriodicThreadRegistry
def _bare_gpu_module() -> LMCacheDrivenTransferModule:
"""A LMCacheDrivenTransferModule with only the liveness state initialized.
Bypasses __init__ (which starts a CUDA host-func dispatcher) so the
liveness methods can be exercised without GPU hardware.
"""
module = LMCacheDrivenTransferModule.__new__(LMCacheDrivenTransferModule)
module._ctx = MagicMock(name="ctx")
module._cache_contexts = {}
module._lock = threading.Lock()
return module
def _bare_non_gpu_module() -> EngineDrivenTransferModule:
"""A EngineDrivenTransferModule with only the liveness state initialized."""
module = EngineDrivenTransferModule.__new__(EngineDrivenTransferModule)
module._ctx = MagicMock(name="ctx")
module._engine_driven_contexts = {}
module._strategies = {}
module._lock = threading.Lock()
module._pending_shm_writes = {}
module._pending_shm_reads = {}
module._pending_shm_lock = threading.Lock()
return module
def test_gpu_register_inserts_unlatched_entry(monkeypatch) -> None:
"""register_kv_cache inserts an entry that is not yet ping-proven."""
monkeypatch.setattr(
gpu_mod, "create_cache_context", lambda *a, **kw: MagicMock(num_layers=2)
)
monkeypatch.setattr(gpu_mod, "get_layout_desc", lambda *a, **kw: MagicMock())
module = _bare_gpu_module()
module.register_kv_cache(1, MagicMock(), "model", 1, MagicMock(), MagicMock(), [])
assert module.tracked_instance_count() == 1
entry = module.get_and_touch_context_entry(1)
assert entry is not None and entry.has_liveness_signal is False
def test_gpu_noop_register_refreshes_without_latching(monkeypatch) -> None:
"""Re-registering a known instance refreshes last_seen but does not
rebuild the context or latch the ping-proven flag."""
create = MagicMock(return_value=MagicMock(num_layers=2))
monkeypatch.setattr(gpu_mod, "create_cache_context", create)
monkeypatch.setattr(gpu_mod, "get_layout_desc", lambda *a, **kw: MagicMock())
module = _bare_gpu_module()
module.register_kv_cache(1, MagicMock(), "model", 1, MagicMock(), MagicMock(), [])
module._cache_contexts[1].last_seen = 0.0
module.register_kv_cache(1, MagicMock(), "model", 1, MagicMock(), MagicMock(), [])
assert create.call_count == 1 # not rebuilt
assert module._cache_contexts[1].last_seen > 0.0 # refreshed
assert module._cache_contexts[1].has_liveness_signal is False
def test_gpu_touch_latches_get_does_not() -> None:
"""touch_instance marks ping-proven; get_and_touch_context_entry only refreshes."""
module = _bare_gpu_module()
module._cache_contexts[1] = ContextEntry(MagicMock(), "m", 1, last_seen=0.0)
module.get_and_touch_context_entry(1)
assert module._cache_contexts[1].last_seen > 0.0
assert module._cache_contexts[1].has_liveness_signal is False
module.touch_instance(1)
assert module._cache_contexts[1].has_liveness_signal is True
module.touch_instance(999) # absent -> no error
def test_gpu_reap_two_tier_windows() -> None:
"""Ping-proven entries reap at the timeout; never-pinged ones survive
until the larger registration grace."""
module = _bare_gpu_module()
old = time.monotonic() - 1000.0
module._cache_contexts[1] = ContextEntry(MagicMock(), "m", 1, old, True)
module._cache_contexts[2] = ContextEntry(MagicMock(), "m", 1, old, False)
module._cache_contexts[3] = ContextEntry(
MagicMock(), "m", 1, time.monotonic(), True
)
reaped = module.reap_stale_instances(120.0, 3600.0)
assert reaped == [1] # only the ping-proven stale entry
assert module.tracked_instance_count() == 2
cast(
MagicMock, module._ctx.layout_desc_registry.unregister
).assert_called_once_with("m", 1)
def test_gpu_unregister_cleans_up() -> None:
"""unregister_kv_cache pops and releases; missing id is a no-op."""
module = _bare_gpu_module()
module._cache_contexts[1] = ContextEntry(MagicMock(), "m", 1, time.monotonic())
module.unregister_kv_cache(1)
assert module.tracked_instance_count() == 0
cast(
MagicMock, module._ctx.layout_desc_registry.unregister
).assert_called_once_with("m", 1)
module.unregister_kv_cache(1) # already gone -> no exception
def test_non_gpu_reap_pops_strategy_as_pair() -> None:
"""Reaping a non-GPU entry pops its strategy in the same scan, keeping
'strategy present iff entry present'."""
module = _bare_non_gpu_module()
old = time.monotonic() - 1000.0
module._engine_driven_contexts[1] = non_gpu_mod.EngineDrivenContextEntry(
MagicMock(), "m", 1, old, True
)
module._strategies[1] = MagicMock()
module._engine_driven_contexts[2] = non_gpu_mod.EngineDrivenContextEntry(
MagicMock(), "m", 1, old, False
)
module._strategies[2] = MagicMock()
reaped = module.reap_stale_instances(120.0, 3600.0)
assert reaped == [1]
assert 1 not in module._strategies # strategy popped with the entry
assert 2 in module._strategies # never-pinged survives on grace
def test_non_gpu_resolve_for_transfer_refreshes_and_raises() -> None:
"""_resolve_for_transfer returns (entry, strategy) and refreshes
last_seen; an unknown id raises ValueError."""
module = _bare_non_gpu_module()
module._engine_driven_contexts[1] = non_gpu_mod.EngineDrivenContextEntry(
MagicMock(), "m", 1, 0.0, False
)
strategy = MagicMock()
module._strategies[1] = strategy
entry, resolved = module._resolve_for_transfer(1)
assert resolved is strategy
assert entry.last_seen > 0.0
assert entry.has_liveness_signal is False # traffic does not latch
with pytest.raises(ValueError, match="not registered"):
module._resolve_for_transfer(999)
class _FakeTarget:
"""Liveness target double recording touches/drops and scripted reaps."""
def __init__(self) -> None:
self.touched: list[int] = []
self.to_reap: list[int] = []
self.dropped: list[int] = []
self.count = 0
def touch_instance(self, instance_id: int) -> None:
self.touched.append(instance_id)
def reap_stale_instances(
self, reap_timeout_s: float, registration_grace_s: float
) -> list[int]:
reaped = self.to_reap[:]
self.to_reap.clear()
return reaped
def tracked_instance_count(self) -> int:
return self.count
def drop_instance_state(self, instance_id: int) -> None:
self.dropped.append(instance_id)
@pytest.fixture(autouse=True)
def _reset_periodic_registry():
"""Keep the reaper out of the global registry across tests."""
PeriodicThreadRegistry.reset()
yield
PeriodicThreadRegistry.reset()
def test_management_ping_touches_targets() -> None:
"""ping refreshes every target for a real id; None is ignored."""
target = _FakeTarget()
mgmt = ManagementModule(MagicMock(), liveness_targets=[target])
assert mgmt.ping(42) is True
assert mgmt.ping(None) is True
assert target.touched == [42]
def test_management_reaper_reaps_and_drops() -> None:
"""The reaper scans targets and calls drop_instance_state for reaped ids."""
target = _FakeTarget()
mgmt = ManagementModule(
MagicMock(),
liveness_targets=[target],
worker_reap_timeout_seconds=0.4,
worker_registration_grace_seconds=0.8,
)
try:
target.to_reap = [7]
deadline = time.monotonic() + 2.0
while target.dropped != [7] and time.monotonic() < deadline:
time.sleep(0.02)
assert target.dropped == [7]
finally:
mgmt.close()
def test_management_reaper_disabled_when_timeout_zero() -> None:
"""timeout == 0 starts no reaper thread."""
mgmt = ManagementModule(
MagicMock(),
liveness_targets=[_FakeTarget()],
worker_reap_timeout_seconds=0.0,
)
assert mgmt._reaper is None
assert mgmt.ping(1) is True
def test_management_report_status_summarizes_liveness() -> None:
"""report_status reports a worker_liveness summary when targets exist."""
target = _FakeTarget()
target.count = 3
mgmt = ManagementModule(
MagicMock(),
liveness_targets=[target],
worker_reap_timeout_seconds=120.0,
worker_registration_grace_seconds=3600.0,
)
try:
status = mgmt.report_status()["worker_liveness"]
assert status["enabled"] is True
assert status["tracked_instances"] == 3
assert status["reap_timeout_seconds"] == 120.0
finally:
mgmt.close()
assert ManagementModule(MagicMock()).report_status() == {}
def test_blend_drop_instance_state_drops_rope_state() -> None:
"""drop_instance_state pops the reaped instance's CB rope state.
The GPU context is no longer mirrored in BlendV3Module (reaping the GPU
entry frees it directly), so only the rope state is dropped here.
"""
# First Party
from lmcache.v1.multiprocess.modules.blend_v3 import BlendV3Module
module = BlendV3Module.__new__(BlendV3Module)
module._cb_rope_state = {5: MagicMock()}
module.drop_instance_state(5)
assert 5 not in module._cb_rope_state
module.drop_instance_state(999) # nothing held -> no error
def test_config_rejects_bad_reap_timeouts() -> None:
"""Validation rejects sub-floor reap timeouts and undersized grace."""
with pytest.raises(ValueError, match="reap timeout"):
MPServerConfig(worker_reap_timeout_seconds=10.0)
with pytest.raises(ValueError, match="registration grace"):
MPServerConfig(
worker_reap_timeout_seconds=120.0,
worker_registration_grace_seconds=60.0,
)
def test_config_accepts_disabled_and_defaults() -> None:
"""0 disables reaping; defaults satisfy the grace >= timeout invariant."""
MPServerConfig(
worker_reap_timeout_seconds=0.0, worker_registration_grace_seconds=0.0
)
default = MPServerConfig()
assert default.worker_reap_timeout_seconds == 120.0
assert default.worker_registration_grace_seconds == 3600.0