905 lines
32 KiB
Python
905 lines
32 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
Unit tests for StorageManager.
|
|
"""
|
|
|
|
# Standard
|
|
import time
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.v1.distributed.api import (
|
|
MemoryLayoutDesc,
|
|
ObjectKey,
|
|
PrefetchMode,
|
|
TrimPolicy,
|
|
)
|
|
from lmcache.v1.distributed.config import (
|
|
EvictionConfig,
|
|
L1ManagerConfig,
|
|
L1MemoryManagerConfig,
|
|
StorageManagerConfig,
|
|
)
|
|
from lmcache.v1.distributed.l2_adapters.config import (
|
|
L2AdaptersConfig,
|
|
)
|
|
from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import MockL2AdapterConfig
|
|
from lmcache.v1.mp_observability.event import Event, EventType
|
|
from lmcache.v1.mp_observability.event_bus import EventBusConfig, init_event_bus
|
|
|
|
try:
|
|
# First Party
|
|
from lmcache.v1.distributed.storage_manager import StorageManager
|
|
except ImportError:
|
|
# Skip tests if L1Manager cannot be imported
|
|
pytest.skip(
|
|
"Skipping because StorageManager cannot be imported", allow_module_level=True
|
|
)
|
|
|
|
# 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"
|
|
)
|
|
|
|
|
|
def should_use_lazy_alloc() -> bool:
|
|
"""Determine if lazy allocation should be used based on CUDA availability."""
|
|
return torch.cuda.is_available()
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_memory_config():
|
|
"""Create a basic L1MemoryManagerConfig for testing."""
|
|
return L1MemoryManagerConfig(
|
|
size_in_bytes=128 * 1024 * 1024, # 128MB
|
|
use_lazy=should_use_lazy_alloc(),
|
|
init_size_in_bytes=64 * 1024 * 1024, # 64MB
|
|
align_bytes=0x1000, # 4KB
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def small_memory_config():
|
|
"""Create a small L1MemoryManagerConfig to test memory exhaustion."""
|
|
return L1MemoryManagerConfig(
|
|
size_in_bytes=64 * 1024 * 1024, # 64MB
|
|
use_lazy=should_use_lazy_alloc(),
|
|
init_size_in_bytes=64 * 1024 * 1024, # 64MB
|
|
align_bytes=0x1000,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_l1_config(basic_memory_config):
|
|
"""Create a basic L1ManagerConfig for testing."""
|
|
return L1ManagerConfig(
|
|
memory_config=basic_memory_config,
|
|
write_ttl_seconds=600,
|
|
read_ttl_seconds=300,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def small_l1_config(small_memory_config):
|
|
"""Create a small L1ManagerConfig to test memory exhaustion."""
|
|
return L1ManagerConfig(
|
|
memory_config=small_memory_config,
|
|
write_ttl_seconds=600,
|
|
read_ttl_seconds=300,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_storage_manager_config(basic_l1_config):
|
|
"""Create a basic StorageManagerConfig for testing"""
|
|
return StorageManagerConfig(
|
|
l1_manager_config=basic_l1_config,
|
|
eviction_config=EvictionConfig(
|
|
eviction_policy="LRU",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def small_storage_manager_config(small_l1_config):
|
|
"""Create a small StorageManagerConfig to test memory exhaustion."""
|
|
return StorageManagerConfig(
|
|
l1_manager_config=small_l1_config,
|
|
eviction_config=EvictionConfig(
|
|
eviction_policy="LRU",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_layout():
|
|
"""Create a basic MemoryLayoutDesc for testing."""
|
|
return MemoryLayoutDesc(
|
|
shapes=[torch.Size([100, 2, 512])],
|
|
dtypes=[torch.bfloat16],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def large_layout():
|
|
"""Create a large MemoryLayoutDesc that will exhaust small memory.
|
|
|
|
Each allocation is 8MB (2M elements * 4 bytes).
|
|
"""
|
|
return MemoryLayoutDesc(
|
|
shapes=[torch.Size([2048, 1024])], # 2M elements * 4 bytes = 8MB
|
|
dtypes=[torch.float32],
|
|
)
|
|
|
|
|
|
def make_object_key(chunk_hash: int, model_name: str = "test_model", kv_rank: int = 0):
|
|
"""Helper to create ObjectKey instances."""
|
|
hash_bytes = ObjectKey.IntHash2Bytes(chunk_hash)
|
|
return ObjectKey(chunk_hash=hash_bytes, model_name=model_name, kv_rank=kv_rank)
|
|
|
|
|
|
def wait_for_condition(
|
|
predicate,
|
|
timeout: float = 5.0,
|
|
poll_interval: float = 0.05,
|
|
) -> bool:
|
|
"""Poll until a predicate returns True or timeout."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(poll_interval)
|
|
return False
|
|
|
|
|
|
def wait_for_prefetch_status(
|
|
sm: StorageManager,
|
|
handle,
|
|
timeout: float = 10.0,
|
|
poll_interval: float = 0.05,
|
|
) -> int | None:
|
|
"""Poll query_prefetch_status until it returns a non-None value.
|
|
|
|
Returns the contiguous prefix-hit count (``count_leading_ones``) of the
|
|
found bitmap, matching the dense semantics these tests assert on.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
result = sm.query_prefetch_status(handle)
|
|
if result is not None:
|
|
return result.count_leading_ones()
|
|
time.sleep(poll_interval)
|
|
return None
|
|
|
|
|
|
def wait_for_sparse_found(
|
|
sm: StorageManager,
|
|
handle,
|
|
timeout: float = 10.0,
|
|
poll_interval: float = 0.05,
|
|
) -> set[int] | None:
|
|
"""Poll query_prefetch_status; return the found-key index set.
|
|
|
|
For SPARSE prefetches the result bitmap is gap-tolerant, so callers read
|
|
the full set via ``get_indices_list`` rather than ``count_leading_ones``.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
result = sm.query_prefetch_status(handle)
|
|
if result is not None:
|
|
return set(result.get_indices_list())
|
|
time.sleep(poll_interval)
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestStorageManagerBasic:
|
|
"""Tests for basic functionality of StorageManager."""
|
|
|
|
def test_basic_reserve_write(self, basic_storage_manager_config, basic_layout):
|
|
"""Test basic reserve and write functionality."""
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
object_key = make_object_key(chunk_hash=12345)
|
|
|
|
# Reserve space for the object
|
|
ret = storage_manager.reserve_write([object_key], basic_layout, mode="new")
|
|
assert object_key in ret
|
|
assert ret[object_key] is not None
|
|
|
|
# Should not have any error
|
|
storage_manager.finish_write([object_key])
|
|
|
|
storage_manager.close()
|
|
|
|
def test_reserve_write_multiple_keys(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Test reserve_write with multiple keys."""
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
keys = [make_object_key(i) for i in range(5)]
|
|
|
|
ret = storage_manager.reserve_write(keys, basic_layout, mode="new")
|
|
|
|
# All keys should be allocated
|
|
assert len(ret) == len(keys)
|
|
for key in keys:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
|
|
storage_manager.close()
|
|
|
|
def test_reserve_write_oom(self, small_storage_manager_config, large_layout):
|
|
"""Test reserve_write raises L1Error on out-of-memory."""
|
|
storage_manager = StorageManager(small_storage_manager_config)
|
|
|
|
keys = [make_object_key(i) for i in range(20)]
|
|
|
|
ret = storage_manager.reserve_write(keys, large_layout, mode="new")
|
|
|
|
# At least some of the keys could be allocated
|
|
assert len(ret) < len(keys)
|
|
|
|
# If some keys were allocated, they should not be None
|
|
for key, obj in ret.items():
|
|
assert obj is not None
|
|
|
|
storage_manager.close()
|
|
|
|
def test_basic_prefetch(self, basic_storage_manager_config, basic_layout):
|
|
"""Test basic prefetch functionality."""
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
object_keys = [make_object_key(i) for i in range(5)]
|
|
|
|
# Write keys into storage manager
|
|
ret = storage_manager.reserve_write(object_keys, basic_layout, mode="new")
|
|
for key in object_keys:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
storage_manager.finish_write(list(ret.keys()))
|
|
|
|
# Prefetch all the objects
|
|
handle = storage_manager.submit_prefetch_task(object_keys, basic_layout)
|
|
|
|
hit_count = storage_manager.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit_count is not None
|
|
assert hit_count == len(object_keys)
|
|
|
|
storage_manager.close()
|
|
|
|
def test_prefetch_partial_prefix_hits(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Test prefetch with partial hits."""
|
|
# 5 keys: 0, 1, 3, 4 are written, 2 is missing
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
object_keys = [make_object_key(i) for i in range(5)]
|
|
|
|
# Write only some keys into storage manager
|
|
keys_to_write = [object_keys[0], object_keys[1], object_keys[3], object_keys[4]]
|
|
ret = storage_manager.reserve_write(keys_to_write, basic_layout, mode="new")
|
|
for key in keys_to_write:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
storage_manager.finish_write(list(ret.keys()))
|
|
|
|
# Prefetch all the objects
|
|
handle = storage_manager.submit_prefetch_task(object_keys, basic_layout)
|
|
|
|
hit_count = storage_manager.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit_count is not None
|
|
assert hit_count == 2 # Only 2 keys were written
|
|
|
|
# The last 2 keys should be "writable"
|
|
ret = storage_manager.reserve_write(
|
|
object_keys[3:], basic_layout, mode="update"
|
|
)
|
|
for key in object_keys[3:]:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
|
|
storage_manager.close()
|
|
|
|
def test_read_prefetched_basic(self, basic_storage_manager_config, basic_layout):
|
|
"""Test reading prefetched objects."""
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
object_keys = [make_object_key(i) for i in range(3)]
|
|
|
|
# Write keys into storage manager
|
|
ret = storage_manager.reserve_write(object_keys, basic_layout, mode="new")
|
|
for key in object_keys:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
storage_manager.finish_write(list(ret.keys()))
|
|
|
|
# Prefetch all the objects
|
|
handle = storage_manager.submit_prefetch_task(object_keys, basic_layout)
|
|
|
|
hit_count = storage_manager.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit_count is not None
|
|
assert hit_count == len(object_keys)
|
|
|
|
# Read the prefetched objects
|
|
with storage_manager.read_prefetched_results(object_keys) as retrieved_objects:
|
|
assert retrieved_objects is not None
|
|
assert len(retrieved_objects) == len(object_keys)
|
|
|
|
# Finish reading
|
|
storage_manager.finish_read_prefetched(object_keys)
|
|
|
|
# Now the objects should be writable again
|
|
ret = storage_manager.reserve_write(object_keys, basic_layout, mode="update")
|
|
for key in object_keys:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
|
|
storage_manager.close()
|
|
|
|
def test_read_prefetched_not_found(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Test reading prefetched objects that were not found."""
|
|
storage_manager = StorageManager(basic_storage_manager_config)
|
|
|
|
object_keys = [make_object_key(i) for i in range(5)]
|
|
|
|
# Write all objects into storage manager
|
|
ret = storage_manager.reserve_write(object_keys, basic_layout, mode="new")
|
|
for key in object_keys:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
storage_manager.finish_write(list(ret.keys()))
|
|
|
|
# Prefetch objects except the first one
|
|
handle = storage_manager.submit_prefetch_task(object_keys[1:], basic_layout)
|
|
hit_count = storage_manager.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit_count is not None
|
|
assert hit_count == len(object_keys) - 1
|
|
|
|
# Attempt to read all the objects, should get None
|
|
with storage_manager.read_prefetched_results(object_keys) as retrieved_objects:
|
|
assert retrieved_objects is None
|
|
|
|
# Remaining 4 objects should still be writable (i.e., no dangling read locks)
|
|
ret = storage_manager.reserve_write(
|
|
object_keys[1:], basic_layout, mode="update"
|
|
)
|
|
for key in object_keys[1:]:
|
|
assert key in ret
|
|
assert ret[key] is not None
|
|
storage_manager.close()
|
|
|
|
|
|
# =============================================================================
|
|
# Tests for multi-reader (count / num_readers) support
|
|
# =============================================================================
|
|
|
|
|
|
class TestStorageManagerMultiReader:
|
|
"""Tests for the num_readers / count parameters.
|
|
|
|
These parameters allow multiple workers (e.g. MLA with
|
|
TP > 1) to each hold an independent read lock on the same
|
|
prefetched object.
|
|
"""
|
|
|
|
def test_prefetch_with_extra_count(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""submit_prefetch_task(extra_count=N-1) acquires N locks."""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
keys = [make_object_key(i) for i in range(3)]
|
|
|
|
# Write keys
|
|
ret = sm.reserve_write(keys, basic_layout, mode="new")
|
|
assert len(ret) == len(keys)
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
extra_count = 2 # total = 1 + 2 = 3 locks
|
|
handle = sm.submit_prefetch_task(keys, basic_layout, extra_count=extra_count)
|
|
hit = sm.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit == len(keys)
|
|
|
|
# Release with matching extra_count
|
|
sm.finish_read_prefetched(keys, extra_count=extra_count)
|
|
|
|
# All locks released -> objects writable again
|
|
ret = sm.reserve_write(keys, basic_layout, mode="update")
|
|
assert len(ret) == len(keys)
|
|
|
|
sm.close()
|
|
|
|
def test_finish_read_prefetched_partial_extra_count(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Partial extra_count release leaves locks held."""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
keys = [make_object_key(i) for i in range(2)]
|
|
|
|
ret = sm.reserve_write(keys, basic_layout, mode="new")
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
extra_count = 3 # total = 1 + 3 = 4 locks
|
|
handle = sm.submit_prefetch_task(keys, basic_layout, extra_count=extra_count)
|
|
hit = sm.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit == len(keys)
|
|
|
|
# Release 2 of 4 (1 + extra_count=1)
|
|
sm.finish_read_prefetched(keys, extra_count=1)
|
|
|
|
# Objects should NOT be writable (2 locks remain)
|
|
ret = sm.reserve_write(keys, basic_layout, mode="update")
|
|
assert len(ret) == 0
|
|
|
|
# Release remaining 2 (1 + extra_count=1)
|
|
sm.finish_read_prefetched(keys, extra_count=1)
|
|
|
|
# Now writable
|
|
ret = sm.reserve_write(keys, basic_layout, mode="update")
|
|
assert len(ret) == len(keys)
|
|
|
|
sm.close()
|
|
|
|
def test_prefetch_skipped_keys_released_with_extra(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Non-prefix L1 hits are released with correct extra.
|
|
|
|
Keys {0,1,3,4} exist, key 2 is missing. The prefix
|
|
hits are {0,1}; keys {3,4} must have their N locks
|
|
released to avoid dangling locks.
|
|
"""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
existing = [all_keys[i] for i in [0, 1, 3, 4]]
|
|
|
|
ret = sm.reserve_write(existing, basic_layout, mode="new")
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
extra_count = 1 # total = 1 + 1 = 2 locks
|
|
handle = sm.submit_prefetch_task(
|
|
all_keys, basic_layout, extra_count=extra_count
|
|
)
|
|
hit = sm.query_prefetch_status(handle).count_leading_ones()
|
|
# Only prefix {0,1} count as hits
|
|
assert hit is not None
|
|
assert hit == 2
|
|
|
|
# Finish the prefix hits
|
|
sm.finish_read_prefetched(all_keys[:2], extra_count=extra_count)
|
|
|
|
# Keys {3,4} should be writable (skipped locks released)
|
|
ret = sm.reserve_write(
|
|
[all_keys[3], all_keys[4]],
|
|
basic_layout,
|
|
mode="update",
|
|
)
|
|
assert len(ret) == 2
|
|
|
|
sm.close()
|
|
|
|
def test_extra_count_default_is_zero(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Default extra_count=0 behaves same as before."""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
keys = [make_object_key(i) for i in range(3)]
|
|
|
|
ret = sm.reserve_write(keys, basic_layout, mode="new")
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
handle = sm.submit_prefetch_task(keys, basic_layout)
|
|
hit = sm.query_prefetch_status(handle).count_leading_ones()
|
|
assert hit == len(keys)
|
|
|
|
# Single finish is enough
|
|
sm.finish_read_prefetched(keys)
|
|
|
|
ret = sm.reserve_write(keys, basic_layout, mode="update")
|
|
assert len(ret) == len(keys)
|
|
|
|
sm.close()
|
|
|
|
|
|
# =============================================================================
|
|
# L2 Prefetch Integration Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def l2_storage_manager_config(basic_l1_config):
|
|
"""Create a StorageManagerConfig with one MockL2Adapter."""
|
|
return StorageManagerConfig(
|
|
l1_manager_config=basic_l1_config,
|
|
eviction_config=EvictionConfig(
|
|
eviction_policy="LRU",
|
|
),
|
|
l2_adapter_config=L2AdaptersConfig(
|
|
adapters=[
|
|
MockL2AdapterConfig(max_size_gb=0.01, mock_bandwidth_gb=10.0),
|
|
],
|
|
),
|
|
)
|
|
|
|
|
|
class TestStorageManagerL2Prefetch:
|
|
"""Tests for prefetching from L2 through StorageManager."""
|
|
|
|
def _write_keys_and_wait_for_l2(
|
|
self,
|
|
sm: StorageManager,
|
|
keys: list[ObjectKey],
|
|
layout: MemoryLayoutDesc,
|
|
) -> None:
|
|
"""Write keys to L1 via StorageManager and wait for L2 store."""
|
|
ret = sm.reserve_write(keys, layout, mode="new")
|
|
assert len(ret) == len(keys)
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
# Wait for StoreController to propagate all keys to L2
|
|
adapter = sm._l2_adapters[0]
|
|
ok = wait_for_condition(
|
|
lambda: all(adapter.debug_has_key(k) for k in keys), # type: ignore
|
|
timeout=10.0,
|
|
)
|
|
assert ok, "Keys should be stored in L2 by StoreController"
|
|
|
|
def test_prefetch_from_l2(self, l2_storage_manager_config, basic_layout):
|
|
"""Write to L1 → store to L2 → clear L1 → prefetch from L2."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
keys = [make_object_key(i) for i in range(5)]
|
|
|
|
self._write_keys_and_wait_for_l2(sm, keys, basic_layout)
|
|
|
|
# Brief sleep to let StoreController release read locks
|
|
# after L2 store completion, then clear L1
|
|
time.sleep(0.05)
|
|
sm.clear()
|
|
used, _ = sm._l1_manager.get_memory_usage()
|
|
assert used == 0, f"L1 should be empty after clear, but {used} bytes used"
|
|
|
|
# Prefetch — L1 has 0 hits, L2 should have all 5
|
|
handle = sm.submit_prefetch_task(keys, basic_layout)
|
|
hit_count = wait_for_prefetch_status(sm, handle)
|
|
|
|
assert hit_count is not None, "Prefetch should complete"
|
|
assert hit_count == 5, f"Expected 5 hits from L2, got {hit_count}"
|
|
|
|
# Verify keys are read-locked in L1 after prefetch
|
|
with sm.read_prefetched_results(keys) as objs:
|
|
assert objs is not None
|
|
assert len(objs) == len(keys)
|
|
|
|
sm.finish_read_prefetched(keys)
|
|
sm.close()
|
|
|
|
def test_prefetch_mixed_l1_l2(self, l2_storage_manager_config, basic_layout):
|
|
"""Some keys in L1, rest in L2 → combined prefix hits."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
|
|
# Use distinct hash ranges for L1-only vs L2 keys
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
l2_only_keys = all_keys[2:] # keys 2, 3, 4 only in L2
|
|
|
|
# Write all keys to L1 (StoreController will push to L2)
|
|
self._write_keys_and_wait_for_l2(sm, all_keys, basic_layout)
|
|
|
|
# Delete only keys 2, 3, 4 from L1 so they must come from L2
|
|
sm._l1_manager.delete(l2_only_keys)
|
|
|
|
# Prefetch all 5 keys: first 2 from L1, next 3 from L2
|
|
handle = sm.submit_prefetch_task(all_keys, basic_layout)
|
|
hit_count = wait_for_prefetch_status(sm, handle)
|
|
|
|
assert hit_count is not None, "Prefetch should complete"
|
|
assert hit_count == 5, (
|
|
f"Expected 5 combined hits (2 L1 + 3 L2), got {hit_count}"
|
|
)
|
|
|
|
# Clean up read locks
|
|
sm.finish_read_prefetched(all_keys)
|
|
sm.close()
|
|
|
|
def test_prefetch_nothing_in_l2(self, l2_storage_manager_config, basic_layout):
|
|
"""Prefetch keys not in L2 → returns 0 L2 hits."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
|
|
# Don't write anything — keys exist nowhere
|
|
keys = [make_object_key(i) for i in range(3)]
|
|
|
|
handle = sm.submit_prefetch_task(keys, basic_layout)
|
|
hit_count = wait_for_prefetch_status(sm, handle)
|
|
|
|
assert hit_count is not None, "Prefetch should complete"
|
|
assert hit_count == 0, f"Expected 0 hits, got {hit_count}"
|
|
|
|
sm.close()
|
|
|
|
def test_warm_skip_l2_is_noop(self, l2_storage_manager_config, basic_layout):
|
|
"""``mode=WARM`` + ``skip_l2=True`` submits no controller request.
|
|
|
|
Regression: the WARM branch must honor ``skip_l2`` (it previously
|
|
ignored it, issuing L2 lookup/load work and mutating L1). The returned
|
|
:class:`PrefetchHandle` is the public signal — no request id to track
|
|
and no L2-sourced indices; since the controller is the only path that
|
|
loads from L2, this also means L1 is left untouched.
|
|
"""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
keys = [make_object_key(i) for i in range(3)]
|
|
|
|
# Put the keys in L2 so a non-skip warm would have something to load,
|
|
# then clear L1 so a load would be the only way they could reappear.
|
|
self._write_keys_and_wait_for_l2(sm, keys, basic_layout)
|
|
sm.clear()
|
|
|
|
handle = sm.submit_prefetch_task(
|
|
keys, basic_layout, mode=PrefetchMode.WARM, skip_l2=True
|
|
)
|
|
|
|
# skip_l2 honored: the controller was never asked.
|
|
assert handle.prefetch_request_id == -1
|
|
assert handle.l2_orig_indices == ()
|
|
assert handle.total_requested_keys == len(keys)
|
|
|
|
sm.close()
|
|
|
|
def test_prefetch_l2_partial_prefix(self, l2_storage_manager_config, basic_layout):
|
|
"""L2 has keys {0,1,3,4} but not 2 → L2 returns prefix of 2."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
# Write only keys 0, 1, 3, 4 (skip key 2)
|
|
keys_to_write = [all_keys[i] for i in [0, 1, 3, 4]]
|
|
self._write_keys_and_wait_for_l2(sm, keys_to_write, basic_layout)
|
|
|
|
# Brief sleep to let StoreController release read locks
|
|
# after L2 store completion, then clear L1
|
|
time.sleep(0.05)
|
|
sm.clear()
|
|
used, _ = sm._l1_manager.get_memory_usage()
|
|
assert used == 0, f"L1 should be empty after clear, but {used} bytes used"
|
|
|
|
handle = sm.submit_prefetch_task(all_keys, basic_layout)
|
|
hit_count = wait_for_prefetch_status(sm, handle)
|
|
|
|
assert hit_count is not None, "Prefetch should complete"
|
|
assert hit_count == 2, (
|
|
f"Expected 2 prefix hits from L2 (gap at index 2), got {hit_count}"
|
|
)
|
|
|
|
# Only prefix keys {0, 1} should be readable
|
|
with sm.read_prefetched_results(all_keys[:2]) as objs:
|
|
assert objs is not None
|
|
assert len(objs) == 2
|
|
|
|
sm.finish_read_prefetched(all_keys[:2])
|
|
sm.close()
|
|
|
|
def test_prefetch_l1_prefix_plus_l2_continuation(
|
|
self, l2_storage_manager_config, basic_layout
|
|
):
|
|
"""L1 has keys {0,1}, L2 has {2,3,4} → combined prefix of 5."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
|
|
# Write all keys → StoreController stores all to L2
|
|
self._write_keys_and_wait_for_l2(sm, all_keys, basic_layout)
|
|
|
|
# Delete keys {2,3,4} from L1 only, keeping them in L2
|
|
sm._l1_manager.delete(all_keys[2:])
|
|
|
|
# Prefetch: L1 prefix hits = 2 (keys 0,1), L2 loads {2,3,4} → total = 5
|
|
handle = sm.submit_prefetch_task(all_keys, basic_layout)
|
|
hit_count = wait_for_prefetch_status(sm, handle)
|
|
|
|
assert hit_count is not None
|
|
assert hit_count == 5, f"Expected 5 total hits (2 L1 + 3 L2), got {hit_count}"
|
|
|
|
sm.finish_read_prefetched(all_keys)
|
|
sm.close()
|
|
|
|
|
|
# =============================================================================
|
|
# Tests for LM-291 failure event production
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def captured_events():
|
|
"""Enable the global event bus and capture all L1/L2 failure events.
|
|
|
|
Replaces the process-wide ``_global_bus`` with a fresh enabled bus,
|
|
subscribes a callback for the three failure event types, yields the
|
|
captured-events list, then resets the bus to disabled on teardown so
|
|
later tests don't see leftover state.
|
|
"""
|
|
bus = init_event_bus(EventBusConfig(enabled=True, max_queue_size=10_000))
|
|
events: list[Event] = []
|
|
|
|
def _capture(event: Event) -> None:
|
|
events.append(event)
|
|
|
|
for et in (
|
|
EventType.L1_ALLOCATION_FAILED,
|
|
EventType.L1_READ_FAILED,
|
|
EventType.L2_PREFETCH_FAILED,
|
|
):
|
|
bus.subscribe(et, _capture)
|
|
bus.start()
|
|
try:
|
|
yield events
|
|
finally:
|
|
bus.stop()
|
|
init_event_bus(EventBusConfig(enabled=False))
|
|
|
|
|
|
def _events_of_type(events: list, event_type: EventType) -> list:
|
|
return [e for e in events if e.event_type == event_type]
|
|
|
|
|
|
class TestFailureEventProduction:
|
|
"""Verifies LM-291 health-monitoring events are published at the
|
|
right producer call sites with the expected metadata."""
|
|
|
|
def test_reserve_write_oom_emits_l1_allocation_failed(
|
|
self, small_storage_manager_config, large_layout, captured_events
|
|
):
|
|
"""OOM during user store must publish L1_ALLOCATION_FAILED with
|
|
during=l1_store and the OOM keys."""
|
|
sm = StorageManager(small_storage_manager_config)
|
|
try:
|
|
keys = [make_object_key(i) for i in range(20)]
|
|
sm.reserve_write(keys, large_layout, mode="new")
|
|
|
|
# Allow drain thread to deliver the event.
|
|
assert wait_for_condition(
|
|
lambda: len(
|
|
_events_of_type(captured_events, EventType.L1_ALLOCATION_FAILED)
|
|
)
|
|
>= 1,
|
|
timeout=2.0,
|
|
)
|
|
|
|
alloc_events = _events_of_type(
|
|
captured_events, EventType.L1_ALLOCATION_FAILED
|
|
)
|
|
assert len(alloc_events) == 1
|
|
meta = alloc_events[0].metadata
|
|
assert meta["during"] == "l1_store"
|
|
assert len(meta["keys"]) > 0
|
|
# All emitted keys must be from the OOM subset of the request.
|
|
assert set(meta["keys"]).issubset(set(keys))
|
|
finally:
|
|
sm.close()
|
|
|
|
def test_unsafe_read_missing_key_emits_l1_read_failed(
|
|
self, basic_storage_manager_config, basic_layout, captured_events
|
|
):
|
|
"""Deleting a key between reserve_read and unsafe_read must publish
|
|
L1_READ_FAILED with during=l1_retrieve, reason=not_found."""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
try:
|
|
keys = [make_object_key(i) for i in range(3)]
|
|
|
|
# Write + finish_write so the keys are readable.
|
|
ret = sm.reserve_write(keys, basic_layout, mode="new")
|
|
assert len(ret) == len(keys)
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
# Prefetch to acquire read locks on all keys.
|
|
handle = sm.submit_prefetch_task(keys, basic_layout)
|
|
assert wait_for_prefetch_status(sm, handle) == len(keys)
|
|
|
|
# Force a mid-read race by removing the key from L1Manager's
|
|
# internal state dict, bypassing the lock check that
|
|
# ``L1Manager.delete`` enforces. This simulates the exact TOCTOU
|
|
# anomaly the metric is designed to catch: reserve_read acquired
|
|
# a lock, but the key vanished before unsafe_read.
|
|
del sm._l1_manager._objects[keys[1]]
|
|
|
|
# Now attempt to read — unsafe_read should find the middle key
|
|
# missing, emitting L1_READ_FAILED(during=l1_retrieve,
|
|
# reason=not_found).
|
|
with sm.read_prefetched_results(keys) as objs:
|
|
assert objs is None # all_good=False because middle key is gone
|
|
|
|
assert wait_for_condition(
|
|
lambda: len(_events_of_type(captured_events, EventType.L1_READ_FAILED))
|
|
>= 1,
|
|
timeout=2.0,
|
|
)
|
|
|
|
read_events = _events_of_type(captured_events, EventType.L1_READ_FAILED)
|
|
assert len(read_events) == 1
|
|
meta = read_events[0].metadata
|
|
assert meta["during"] == "l1_retrieve"
|
|
assert meta["reason"] == "not_found"
|
|
assert keys[1] in meta["keys"]
|
|
finally:
|
|
sm.close()
|
|
|
|
|
|
class TestStorageManagerSparsePrefetch:
|
|
"""SPARSE prefetch: retain a read lock on every found key, not just the
|
|
leading contiguous prefix."""
|
|
|
|
def test_sparse_keeps_all_found_not_just_prefix(
|
|
self, basic_storage_manager_config, basic_layout
|
|
):
|
|
"""Sparse L1 prefetch retains + read-locks every found key, including
|
|
those past a gap (unlike the contiguous-prefix default)."""
|
|
sm = StorageManager(basic_storage_manager_config)
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
# Write {0,1,3,4}; key 2 is the gap.
|
|
existing = [all_keys[i] for i in (0, 1, 3, 4)]
|
|
ret = sm.reserve_write(existing, basic_layout, mode="new")
|
|
sm.finish_write(list(ret.keys()))
|
|
|
|
handle = sm.submit_prefetch_task(
|
|
all_keys, basic_layout, policy=TrimPolicy.SPARSE
|
|
)
|
|
found = wait_for_sparse_found(sm, handle, timeout=10.0)
|
|
|
|
# Sparse: all four found indices, NOT just the prefix {0, 1}.
|
|
assert found == {0, 1, 3, 4}
|
|
|
|
# Every found key is read-locked (none write-reservable).
|
|
locked = sm.reserve_write(existing, basic_layout, mode="update")
|
|
assert len(locked) == 0
|
|
|
|
# Releasing the full found set frees them.
|
|
sm.finish_read_prefetched(existing)
|
|
freed = sm.reserve_write(existing, basic_layout, mode="update")
|
|
assert len(freed) == len(existing)
|
|
|
|
sm.close()
|
|
|
|
def test_sparse_from_l2_loads_all_found(
|
|
self, l2_storage_manager_config, basic_layout
|
|
):
|
|
"""Sparse prefetch from L2 loads every found key (controller skips the
|
|
prefix-only trim), not just the prefix before a gap."""
|
|
sm = StorageManager(l2_storage_manager_config)
|
|
all_keys = [make_object_key(i) for i in range(5)]
|
|
# L2 has {0,1,3,4}; gap at 2.
|
|
existing = [all_keys[i] for i in (0, 1, 3, 4)]
|
|
wret = sm.reserve_write(existing, basic_layout, mode="new")
|
|
sm.finish_write(list(wret.keys()))
|
|
adapter = sm._l2_adapters[0]
|
|
assert wait_for_condition(
|
|
lambda: all(adapter.debug_has_key(k) for k in existing), # type: ignore
|
|
timeout=10.0,
|
|
)
|
|
time.sleep(0.05)
|
|
sm.clear()
|
|
used, _ = sm._l1_manager.get_memory_usage()
|
|
assert used == 0
|
|
|
|
handle = sm.submit_prefetch_task(
|
|
all_keys, basic_layout, policy=TrimPolicy.SPARSE
|
|
)
|
|
found = wait_for_sparse_found(sm, handle, timeout=10.0)
|
|
|
|
# Sparse from L2: all found {0,1,3,4}, NOT the contiguous prefix {0,1}.
|
|
assert found == {0, 1, 3, 4}
|
|
|
|
sm.finish_read_prefetched(existing)
|
|
sm.close()
|