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

725 lines
22 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for StoreController.
Tests verify the end-to-end flow: L1 write -> listener notification ->
L2 store submission -> completion handling -> L1 read lock release.
Uses a real L1Manager and MockL2Adapter (with debug methods) to exercise
the full integration without mocking internals.
"""
# Standard
import select
import time
# Third Party
import pytest
import torch
# First Party
from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey
from lmcache.v1.distributed.config import L1ManagerConfig, L1MemoryManagerConfig
from lmcache.v1.distributed.eviction_policy.noop import (
NoOpEvictionPolicy,
)
from lmcache.v1.distributed.l1_manager import L1Manager
from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import (
MockL2Adapter,
MockL2AdapterConfig,
)
from lmcache.v1.distributed.storage_controllers.store_controller import (
StoreController,
StoreListener,
)
from lmcache.v1.distributed.storage_controllers.store_policy import (
AdapterDescriptor,
BufferOnlyStorePolicy,
DefaultStorePolicy,
StorePolicy,
)
# Skip all tests in this module if CUDA is not available
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="CUDA is not available"
)
# =============================================================================
# Helpers
# =============================================================================
def make_object_key(chunk_id: int) -> ObjectKey:
"""Create a test ObjectKey with the given chunk ID."""
return ObjectKey(
chunk_hash=ObjectKey.IntHash2Bytes(chunk_id),
model_name="test_model",
kv_rank=0,
)
def make_layout() -> MemoryLayoutDesc:
"""Create a small MemoryLayoutDesc for testing."""
return MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16],
)
def should_use_lazy_alloc() -> bool:
"""Determine if lazy allocation should be used based on CUDA availability."""
return torch.cuda.is_available()
def wait_for_condition(
predicate,
timeout: float = 5.0,
poll_interval: float = 0.05,
) -> bool:
"""
Poll until a predicate returns True or timeout.
Args:
predicate: Callable returning bool.
timeout: Max wait time in seconds.
poll_interval: Time between polls in seconds.
Returns:
True if predicate was satisfied, False on timeout.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(poll_interval)
return False
def write_keys_to_l1(
l1_manager: L1Manager,
keys: list[ObjectKey],
layout: MemoryLayoutDesc,
) -> list[ObjectKey]:
"""
Write keys to L1 (reserve_write + finish_write).
Args:
l1_manager: The L1 manager to write to.
keys: Object keys to write.
layout: Memory layout description.
Returns:
List of keys that were successfully written.
"""
results = l1_manager.reserve_write(
keys=keys,
is_temporary=[False] * len(keys),
layout_desc=layout,
mode="new",
)
written = [k for k, (e, m) in results.items() if m is not None]
if written:
l1_manager.finish_write(written)
return written
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def l1_manager():
"""Create an L1Manager with a reasonable memory config."""
config = L1ManagerConfig(
memory_config=L1MemoryManagerConfig(
size_in_bytes=128 * 1024 * 1024,
use_lazy=should_use_lazy_alloc(),
init_size_in_bytes=64 * 1024 * 1024,
align_bytes=0x1000,
),
write_ttl_seconds=600,
read_ttl_seconds=300,
)
mgr = L1Manager(config)
yield mgr
mgr.close()
def make_adapter() -> MockL2Adapter:
"""Create a MockL2Adapter with fast bandwidth."""
config = MockL2AdapterConfig(
max_size_gb=0.01,
mock_bandwidth_gb=10.0,
)
return MockL2Adapter(config)
def make_descriptor(index: int) -> AdapterDescriptor:
"""Create an AdapterDescriptor for testing."""
config = MockL2AdapterConfig(max_size_gb=0.01, mock_bandwidth_gb=10.0)
return AdapterDescriptor(index=index, config=config)
# =============================================================================
# StoreListener Tests
# =============================================================================
class TestStoreListener:
"""Test StoreListener as a standalone component."""
def test_pop_pending_keys_returns_empty_initially(self):
"""A fresh listener should have no pending keys."""
listener = StoreListener()
assert listener.pop_pending_keys() == []
listener.close()
def test_on_l1_keys_write_finished_enqueues_keys(self):
"""Keys passed to on_l1_keys_write_finished should be retrievable via pop."""
listener = StoreListener()
keys = [make_object_key(i) for i in range(3)]
listener.on_l1_keys_write_finished(keys)
popped = listener.pop_pending_keys()
assert popped == keys
listener.close()
def test_pop_pending_keys_clears_queue(self):
"""pop_pending_keys should drain the queue."""
listener = StoreListener()
listener.on_l1_keys_write_finished([make_object_key(0)])
listener.pop_pending_keys()
assert listener.pop_pending_keys() == []
listener.close()
def test_on_l1_keys_write_finished_signals_eventfd(self):
"""The eventfd should become readable after on_l1_keys_write_finished."""
listener = StoreListener()
efd = listener.get_event_fd()
poller = select.poll()
poller.register(efd, select.POLLIN)
listener.on_l1_keys_write_finished([make_object_key(0)])
events = poller.poll(1000)
assert len(events) > 0
listener.close()
def test_multiple_writes_accumulate(self):
"""Multiple on_l1_keys_write_finished calls should accumulate keys."""
listener = StoreListener()
listener.on_l1_keys_write_finished([make_object_key(0)])
listener.on_l1_keys_write_finished([make_object_key(1), make_object_key(2)])
popped = listener.pop_pending_keys()
assert len(popped) == 3
listener.close()
def test_finish_write_and_reserve_read_does_not_enqueue(self):
"""on_l1_keys_finish_write_and_reserve_read should not enqueue keys."""
listener = StoreListener()
keys = [make_object_key(i) for i in range(3)]
listener.on_l1_keys_finish_write_and_reserve_read(keys)
assert listener.pop_pending_keys() == []
assert listener.pending_count() == 0
listener.close()
# =============================================================================
# StoreController Integration Tests
# =============================================================================
class TestStoreControllerLifecycle:
"""Test StoreController start/stop behavior."""
def test_start_stop(self, l1_manager):
"""StoreController should start and stop cleanly."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultStorePolicy(),
)
ctrl.start()
ctrl.stop()
adapter.close()
def test_start_stop_no_adapters(self, l1_manager):
"""Controller should start and stop cleanly with no adapters."""
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultStorePolicy(),
)
ctrl.start()
ctrl.stop()
class TestStoreControllerSingleAdapter:
"""Test StoreController with one MockL2Adapter."""
def test_l1_write_triggers_l2_store(self, l1_manager):
"""Writing to L1 should cause the object to appear in L2."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for the object to appear in L2
ok = wait_for_condition(
lambda: adapter.debug_get_stored_object_count() == 1,
timeout=5.0,
)
assert ok, "Object should be stored in L2 after L1 write"
assert adapter.debug_has_key(keys[0])
ctrl.stop()
adapter.close()
def test_multiple_keys_stored_to_l2(self, l1_manager):
"""Multiple L1 writes should result in all keys in L2."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
write_keys_to_l1(l1_manager, keys, layout)
ok = wait_for_condition(
lambda: adapter.debug_get_stored_object_count() == 5,
timeout=5.0,
)
assert ok, "All 5 objects should be stored in L2"
for key in keys:
assert adapter.debug_has_key(key)
ctrl.stop()
adapter.close()
def test_read_lock_released_after_store(self, l1_manager):
"""After L2 store completes, L1 read locks should be released."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for L2 store to complete
ok = wait_for_condition(
lambda: adapter.debug_has_key(keys[0]),
timeout=5.0,
)
assert ok
# Verify read lock is released: the key should be updatable
ok = wait_for_condition(
lambda: l1_manager.reserve_write(
keys=keys,
is_temporary=[False],
layout_desc=layout,
mode="update",
)[keys[0]][1]
is not None,
timeout=5.0,
)
assert ok, "Key should be updatable after store controller releases read lock"
ctrl.stop()
adapter.close()
def test_multiple_concurrent_requests_same_adapter(self, l1_manager):
"""Each L1-write batch produces an independent in-flight store request.
When two batches target the same adapter, the adapter's single
eventfd may batch-signal both completions. All in-flight requests
should be advanced and their L1 read locks released.
"""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
batch_a = [make_object_key(i) for i in range(3)]
batch_b = [make_object_key(i) for i in range(3, 6)]
write_keys_to_l1(l1_manager, batch_a, layout)
write_keys_to_l1(l1_manager, batch_b, layout)
all_keys = batch_a + batch_b
ok = wait_for_condition(
lambda: all(adapter.debug_has_key(k) for k in all_keys),
timeout=5.0,
)
assert ok, "Both batches should reach L2"
# Every key must be updatable: read locks from both requests released.
# ``reserve_write`` acquires a write lock on success, so release it
# via ``finish_write`` after each successful check — otherwise the
# second poll would find already-locked keys and time out.
def all_keys_updatable() -> bool:
for k in all_keys:
result = l1_manager.reserve_write(
keys=[k],
is_temporary=[False],
layout_desc=layout,
mode="update",
)
if result[k][1] is None:
return False
l1_manager.finish_write([k])
return True
ok = wait_for_condition(all_keys_updatable, timeout=5.0)
assert ok, "Read locks must be released for every concurrent request"
ctrl.stop()
adapter.close()
class TestStoreControllerMultipleAdapters:
"""Test StoreController with multiple L2 adapters."""
def test_stores_to_all_adapters(self, l1_manager):
"""DefaultStorePolicy sends keys to all adapters."""
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for both adapters to have the object
ok = wait_for_condition(
lambda: all(a.debug_has_key(keys[0]) for a in adapters),
timeout=5.0,
)
assert ok, "Object should be stored in both L2 adapters"
ctrl.stop()
for a in adapters:
a.close()
def test_read_lock_released_after_all_adapters_complete(self, l1_manager):
"""Read locks should be released after all adapter stores complete."""
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for stores to both adapters, then check updatability
ok = wait_for_condition(
lambda: all(a.debug_has_key(keys[0]) for a in adapters),
timeout=5.0,
)
assert ok
ok = wait_for_condition(
lambda: l1_manager.reserve_write(
keys=keys,
is_temporary=[False],
layout_desc=layout,
mode="update",
)[keys[0]][1]
is not None,
timeout=5.0,
)
assert ok, "Key should be updatable after all adapter stores complete"
ctrl.stop()
for a in adapters:
a.close()
class TestStoreControllerNoAdapters:
"""Test StoreController with no L2 adapters."""
def test_write_with_no_adapters_no_read_lock(self, l1_manager):
"""With no adapters, no read locks should be held after L1 write."""
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[],
adapter_descriptors=[],
policy=DefaultStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Give the controller time to process the listener event
time.sleep(0.3)
# Key should be updatable (no read locks)
result = l1_manager.reserve_write(
keys=keys,
is_temporary=[False],
layout_desc=layout,
mode="update",
)
assert result[keys[0]][1] is not None
ctrl.stop()
class TestStoreControllerCustomPolicy:
"""Test StoreController with custom StorePolicy implementations."""
def test_policy_that_skips_all_adapters(self, l1_manager):
"""A policy returning empty targets should not trigger L2 stores."""
class SkipAllPolicy(StorePolicy):
"""Never store to L2."""
def select_store_targets(self, keys, adapters):
return {}
def select_l1_deletions(self, keys):
return []
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=SkipAllPolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Give the controller time to process
time.sleep(0.5)
assert adapter.debug_get_stored_object_count() == 0, (
"No objects should be stored when policy skips all adapters"
)
ctrl.stop()
adapter.close()
def test_policy_that_deletes_from_l1(self, l1_manager):
"""A policy that requests L1 deletion should result in deleted keys."""
class DeleteAfterStorePolicy(StorePolicy):
"""Delete all keys from L1 after storing to L2."""
def select_store_targets(self, keys, adapters):
return {ad.index: list(keys) for ad in adapters}
def select_l1_deletions(self, keys):
return list(keys)
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=DeleteAfterStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for L2 store and L1 deletion
ok = wait_for_condition(
lambda: adapter.debug_has_key(keys[0]),
timeout=5.0,
)
assert ok, "Object should be stored in L2"
# After deletion, reserve_write with mode="new" should succeed
ok = wait_for_condition(
lambda: l1_manager.reserve_write(
keys=keys,
is_temporary=[False],
layout_desc=layout,
mode="new",
)[keys[0]][1]
is not None,
timeout=5.0,
)
assert ok, "Key should be re-creatable after L1 deletion by policy"
ctrl.stop()
adapter.close()
def test_policy_selects_subset_of_adapters(self, l1_manager):
"""A policy that targets only some adapters should skip others."""
class FirstAdapterOnlyPolicy(StorePolicy):
"""Only store to adapter index 0."""
def select_store_targets(self, keys, adapters):
for ad in adapters:
if ad.index == 0:
return {0: list(keys)}
return {}
def select_l1_deletions(self, keys):
return []
adapters = [make_adapter(), make_adapter()]
descriptors = [make_descriptor(i) for i in range(2)]
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=adapters,
adapter_descriptors=descriptors,
policy=FirstAdapterOnlyPolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
ok = wait_for_condition(
lambda: adapters[0].debug_has_key(keys[0]),
timeout=5.0,
)
assert ok, "Object should be stored in first adapter"
# Give some time and verify second adapter is empty
time.sleep(0.5)
assert adapters[1].debug_get_stored_object_count() == 0, (
"Second adapter should have no objects"
)
ctrl.stop()
for a in adapters:
a.close()
class TestBufferOnlyMode:
"""Test buffer-only mode: BufferOnlyStorePolicy + NoOpEvictionPolicy."""
def test_l1_cleaned_after_l2_store(self, l1_manager):
"""L1 data should be deleted after successful L2 store."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=BufferOnlyStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(0)]
write_keys_to_l1(l1_manager, keys, layout)
# Wait for L2 store
ok = wait_for_condition(
lambda: adapter.debug_has_key(keys[0]),
timeout=5.0,
)
assert ok, "Object should be stored in L2"
# L1 should be cleaned: check key is gone
ok = wait_for_condition(
lambda: l1_manager.get_object_state(keys[0]) is None,
timeout=5.0,
)
assert ok, "Key should be deleted from L1 in buffer-only mode"
ctrl.stop()
adapter.close()
def test_noop_eviction_never_evicts(self):
"""NoOpEvictionPolicy should never return eviction actions."""
noop = NoOpEvictionPolicy()
noop.on_keys_created([make_object_key(i) for i in range(10)])
assert noop.get_eviction_actions(1.0) == []
def test_buffer_only_multiple_keys(self, l1_manager):
"""Multiple keys should all flow to L2 and be removed
from L1."""
adapter = make_adapter()
ctrl = StoreController(
l1_manager=l1_manager,
l2_adapters=[adapter],
adapter_descriptors=[make_descriptor(0)],
policy=BufferOnlyStorePolicy(),
)
ctrl.start()
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
write_keys_to_l1(l1_manager, keys, layout)
ok = wait_for_condition(
lambda: adapter.debug_get_stored_object_count() == 5,
timeout=5.0,
)
assert ok, "All 5 objects should be stored in L2"
# All keys should be gone from L1
ok = wait_for_condition(
lambda: all(l1_manager.get_object_state(k) is None for k in keys),
timeout=5.0,
)
assert ok, "All keys should be deleted from L1 after buffer-only cleanup"
ctrl.stop()
adapter.close()