# SPDX-License-Identifier: Apache-2.0 """ Unit tests for PrefetchController. Tests verify the end-to-end prefetch flow: submit request → lookup in L2 → compute prefix-trimmed load plan → reserve L1 buffers → load from L2 → transition to read-locked → report prefix hits. Uses a real L1Manager and MockL2Adapter (with debug methods) to exercise the full integration without mocking internals. """ # Standard import time # Third Party import pytest import torch # First Party from lmcache.native_storage_ops import Bitmap from lmcache.v1.distributed.api import ( MemoryLayoutDesc, ObjectKey, PrefetchMode, TrimPolicy, ) from lmcache.v1.distributed.config import L1ManagerConfig, L1MemoryManagerConfig from lmcache.v1.distributed.error import L1Error from lmcache.v1.distributed.l1_manager import L1Manager from lmcache.v1.distributed.l2_adapters.fault_inject_l2_adapter import ( FaultInjectL2Adapter, ) from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import ( MockL2Adapter, MockL2AdapterConfig, ) from lmcache.v1.distributed.storage_controllers.prefetch_controller import ( PrefetchController, build_trim_mask, merge_bitmaps, ) from lmcache.v1.distributed.storage_controllers.prefetch_policy import ( DefaultPrefetchPolicy, ) from lmcache.v1.distributed.storage_controllers.store_policy import ( AdapterDescriptor, ) from lmcache.v1.memory_management import MemoryObjMetadata, TensorMemoryObj # 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: 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.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return True time.sleep(poll_interval) return False def wait_for_prefetch_result( ctrl: PrefetchController, req_id: int, timeout: float = 5.0, poll_interval: float = 0.05, ) -> int | None: """Poll query_prefetch_result until it returns a non-None value.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = ctrl.query_prefetch_result(req_id) if result is not None: return result.count_leading_ones() time.sleep(poll_interval) return None def wait_for_lookup_result( ctrl: PrefetchController, req_id: int, timeout: float = 5.0, poll_interval: float = 0.05, ) -> int | None: """Poll query_lookup_result until it returns a non-None value.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = ctrl.query_lookup_result(req_id) if result is not None: return result time.sleep(poll_interval) return None def wait_for_prefetch_result_bitmap( ctrl: PrefetchController, req_id: int, timeout: float = 5.0, poll_interval: float = 0.05, ): """Poll query_prefetch_result, returning the raw retained Bitmap. Unlike :func:`wait_for_prefetch_result`, this keeps the full bitmap so a caller can inspect non-contiguous retained sets (e.g. SEGMENTED_PREFIX). """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = ctrl.query_prefetch_result(req_id) if result is not None: return result time.sleep(poll_interval) return None 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) def store_keys_in_l2( adapter: MockL2Adapter, keys: list[ObjectKey], layout: MemoryLayoutDesc, ) -> None: """Store test data directly in L2 adapter and wait for completion.""" if not keys: return objs = [] for _ in keys: tensor = torch.randn(layout.shapes[0], dtype=layout.dtypes[0]) metadata = MemoryObjMetadata( shape=layout.shapes[0], dtype=layout.dtypes[0], address=0, phy_size=tensor.nelement() * tensor.element_size(), ref_count=0, ) obj = TensorMemoryObj(raw_data=tensor, metadata=metadata, parent_allocator=None) objs.append(obj) adapter.submit_store_task(keys, objs) # type: ignore ok = wait_for_condition( lambda: all(adapter.debug_has_key(k) for k in keys), timeout=5.0, ) assert ok, "Failed to store test data in L2 adapter" # ============================================================================= # 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() # ============================================================================= # Lifecycle Tests # ============================================================================= class TestPrefetchControllerLifecycle: """Test PrefetchController start/stop behavior.""" def test_start_stop(self, l1_manager): """Controller should start and stop cleanly.""" adapter = make_adapter() ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) 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 = PrefetchController( l1_manager=l1_manager, l2_adapters=[], adapter_descriptors=[], policy=DefaultPrefetchPolicy(), ) ctrl.start() ctrl.stop() # ============================================================================= # Single Adapter Prefetch # ============================================================================= class TestSingleAdapterPrefetch: """Test PrefetchController with one MockL2Adapter.""" def test_full_prefix_hit(self, l1_manager): """All keys in L2 → all loaded, prefix hits = total keys.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(5)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 5, f"Expected 5 prefix hits, got {result}" # Verify prefix keys are read-locked in L1 read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS # Cleanup: release read locks l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_prefix_with_gap(self, l1_manager): """L2 has keys {0,1,3,4} but not 2 → only prefix {0,1} loaded.""" adapter = make_adapter() layout = make_layout() all_keys = [make_object_key(i) for i in range(5)] # Store only keys 0, 1, 3, 4 (gap at index 2) stored_keys = [all_keys[i] for i in [0, 1, 3, 4]] store_keys_in_l2(adapter, stored_keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(all_keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2, f"Expected 2 prefix hits (gap at index 2), got {result}" # Verify prefix keys {0,1} are read-locked in L1 prefix_keys = all_keys[:2] read_results = l1_manager.unsafe_read(prefix_keys) for key in prefix_keys: assert read_results[key][0] == L1Error.SUCCESS # Verify keys beyond prefix {2,3,4} are NOT in L1 non_prefix_keys = all_keys[2:] read_results = l1_manager.reserve_read(non_prefix_keys) for key in non_prefix_keys: assert read_results[key][0] == L1Error.KEY_NOT_EXIST l1_manager.finish_read(prefix_keys) ctrl.stop() adapter.close() def test_segmented_prefix_with_gap(self, l1_manager): """SEGMENTED_PREFIX retains the post-gap keys: L2 {0,1,3,4} -> {0,1,3,4}. The PREFIX counterpart (test_prefix_with_gap) truncates the same gap to {0,1}; SEGMENTED_PREFIX keeps the post-gap chunks L1-resident so only the hole (index 2) needs recomputing. """ adapter = make_adapter() layout = make_layout() all_keys = [make_object_key(i) for i in range(5)] # Store only keys 0, 1, 3, 4 (gap at index 2). store_keys_in_l2(adapter, [all_keys[i] for i in [0, 1, 3, 4]], layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request( all_keys, layout, policy=TrimPolicy.SEGMENTED_PREFIX ) retained = wait_for_prefetch_result_bitmap(ctrl, req_id) assert retained is not None assert retained.get_indices_list() == [0, 1, 3, 4], ( "SEGMENTED_PREFIX should retain post-gap keys, got " f"{retained.get_indices_list()}" ) # Retained keys {0,1,3,4} are read-locked in L1; the gap (2) is absent. retained_keys = [all_keys[i] for i in [0, 1, 3, 4]] read = l1_manager.unsafe_read(retained_keys) for key in retained_keys: assert read[key][0] == L1Error.SUCCESS gap_read = l1_manager.reserve_read([all_keys[2]]) assert gap_read[all_keys[2]][0] == L1Error.KEY_NOT_EXIST l1_manager.finish_read(retained_keys) ctrl.stop() adapter.close() def test_fault_inject_load_gap_segmented_vs_prefix(self, l1_manager): """fault_inject (load fails at index 2) drives the segmented path. Lookup reports all 5 keys present; the *load* of index 2 fails (the L2 retrieve error the adapter simulates). SEGMENTED_PREFIX retains the post-gap keys {0,1,3,4}; PREFIX truncates at the hole to {0,1}. Distinct key ranges per policy keep the shared L1 from serving the second pass. """ layout = make_layout() for base, trim, expected in ( (0, TrimPolicy.SEGMENTED_PREFIX, [0, 1, 3, 4]), (10, TrimPolicy.PREFIX, [0, 1]), ): keys = [make_object_key(base + i) for i in range(5)] inner = make_adapter() store_keys_in_l2(inner, keys, layout) # all 5 present at lookup # Drop task-position 2 at load -> a mid-prefix L2 retrieve failure. fault = FaultInjectL2Adapter(inner, rate=0.0, seed=0, gap_indices=(2,)) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[fault], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout, policy=trim) retained = wait_for_prefetch_result_bitmap(ctrl, req_id) assert retained is not None assert retained.get_indices_list() == expected, ( f"{trim.name}: expected {expected}, got {retained.get_indices_list()}" ) held = [keys[i] for i in retained.get_indices_list()] if held: l1_manager.finish_read(held) ctrl.stop() fault.close() def test_key0_missing(self, l1_manager): """L2 has keys {1,2,3} but not 0 → prefix = 0, nothing loaded.""" adapter = make_adapter() layout = make_layout() all_keys = [make_object_key(i) for i in range(4)] # Store keys 1, 2, 3 but not 0 stored_keys = all_keys[1:] store_keys_in_l2(adapter, stored_keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(all_keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 0, f"Expected 0 prefix hits (key 0 missing), got {result}" # Verify no keys are in L1 read_results = l1_manager.reserve_read(all_keys) for key in all_keys: assert read_results[key][0] == L1Error.KEY_NOT_EXIST ctrl.stop() adapter.close() # ============================================================================= # Multi Adapter Prefetch # ============================================================================= class TestMultiAdapterPrefetch: """Test PrefetchController with multiple MockL2Adapters.""" def test_disjoint_adapters(self, l1_manager): """Adapter 0 has {0,1}, adapter 1 has {2,3} → full prefix of 4.""" adapters = [make_adapter(), make_adapter()] descriptors = [make_descriptor(i) for i in range(2)] layout = make_layout() keys = [make_object_key(i) for i in range(4)] store_keys_in_l2(adapters[0], keys[:2], layout) store_keys_in_l2(adapters[1], keys[2:], layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=adapters, adapter_descriptors=descriptors, policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 4, f"Expected 4 prefix hits, got {result}" read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS l1_manager.finish_read(keys) ctrl.stop() for a in adapters: a.close() def test_overlap_first_wins(self, l1_manager): """Both adapters have key 1. Adapter 0 (lower index) loads it.""" adapters = [make_adapter(), make_adapter()] descriptors = [make_descriptor(i) for i in range(2)] layout = make_layout() keys = [make_object_key(i) for i in range(3)] # Adapter 0 has keys {0, 1}, adapter 1 has keys {1, 2} store_keys_in_l2(adapters[0], keys[:2], layout) store_keys_in_l2(adapters[1], keys[1:], layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=adapters, adapter_descriptors=descriptors, policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3, f"Expected 3 prefix hits, got {result}" read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS l1_manager.finish_read(keys) ctrl.stop() for a in adapters: a.close() # ============================================================================= # No Hits # ============================================================================= class TestNoHits: """Test PrefetchController when no keys are found in L2.""" def test_no_keys_in_l2(self, l1_manager): """Prefetch keys not in any L2 → 0 prefix hits.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] # Don't store anything in L2 ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 0, f"Expected 0 prefix hits, got {result}" ctrl.stop() adapter.close() def test_no_adapters(self, l1_manager): """No adapters → 0 prefix hits immediately.""" layout = make_layout() keys = [make_object_key(i) for i in range(3)] ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[], adapter_descriptors=[], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 0, f"Expected 0 prefix hits, got {result}" ctrl.stop() # ============================================================================= # Query Result # ============================================================================= class TestQueryResult: """Test query_prefetch_result semantics.""" def test_query_returns_int_then_none(self, l1_manager): """Result is consumed on first query; second query returns None.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(2)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2 # Second query should return None (already consumed) assert ctrl.query_prefetch_result(req_id) is None l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_query_before_completion_returns_none(self, l1_manager): """Querying a nonexistent request ID returns None.""" ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[], adapter_descriptors=[], policy=DefaultPrefetchPolicy(), ) ctrl.start() assert ctrl.query_prefetch_result(999) is None ctrl.stop() # ============================================================================= # L2 Lock Release # ============================================================================= class TestPrefetchL2LockRelease: """Test that L2 locks are properly released after prefetch.""" def test_locks_released_after_full_hit(self, l1_manager): """All L2 locks should be released after a successful prefetch.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # Wait for L2 unlock operations to be processed ok = wait_for_condition( lambda: adapter.debug_get_locked_key_count() == 0, timeout=5.0, ) assert ok, "L2 locks should be released after prefetch completion" l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_locks_released_after_prefix_trim(self, l1_manager): """L2 locks released for both prefix and non-prefix keys.""" adapter = make_adapter() layout = make_layout() all_keys = [make_object_key(i) for i in range(5)] # Store keys 0, 1, 3, 4 (gap at index 2) stored_keys = [all_keys[i] for i in [0, 1, 3, 4]] store_keys_in_l2(adapter, stored_keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(all_keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2 # All L2 locks should be released (both prefix and trimmed keys) ok = wait_for_condition( lambda: adapter.debug_get_locked_key_count() == 0, timeout=5.0, ) assert ok, "All L2 locks should be released after prefix-trimmed prefetch" l1_manager.finish_read(all_keys[:2]) ctrl.stop() adapter.close() def test_locks_released_after_no_hits(self, l1_manager): """L2 locks released even when nothing is found.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] # Don't store anything ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 0 ok = wait_for_condition( lambda: adapter.debug_get_locked_key_count() == 0, timeout=5.0, ) assert ok, "L2 locks should be 0 when nothing was found" ctrl.stop() adapter.close() def test_multi_adapter_locks_released(self, l1_manager): """Both adapters' L2 locks released after overlapping prefetch.""" adapters = [make_adapter(), make_adapter()] descriptors = [make_descriptor(i) for i in range(2)] layout = make_layout() keys = [make_object_key(i) for i in range(3)] # Adapter 0 has {0, 1}, adapter 1 has {1, 2} store_keys_in_l2(adapters[0], keys[:2], layout) store_keys_in_l2(adapters[1], keys[1:], layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=adapters, adapter_descriptors=descriptors, policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # Both adapters should have all locks released for i, adapter in enumerate(adapters): ok = wait_for_condition( lambda a=adapter: a.debug_get_locked_key_count() == 0, timeout=5.0, ) assert ok, f"Adapter {i} should have all L2 locks released" l1_manager.finish_read(keys) ctrl.stop() for a in adapters: a.close() # ============================================================================= # Max In-Flight # ============================================================================= class TestMaxInFlight: """Test PrefetchController max in-flight request limiting.""" def test_queuing_beyond_max_in_flight(self, l1_manager): """Submit more requests than max_in_flight → all eventually complete.""" adapter = make_adapter() layout = make_layout() # Store keys for 4 separate requests (2 keys each) all_keys = [make_object_key(i) for i in range(8)] store_keys_in_l2(adapter, all_keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), max_in_flight=2, ) ctrl.start() # Submit 4 requests (max_in_flight=2, so 2 queued) req_ids = [] for i in range(4): batch_keys = all_keys[i * 2 : (i + 1) * 2] req_id = ctrl.submit_prefetch_request(batch_keys, layout) req_ids.append(req_id) # All 4 requests should eventually complete results = [] for req_id in req_ids: result = wait_for_prefetch_result(ctrl, req_id, timeout=10.0) assert result is not None, f"Request {req_id} should complete" results.append(result) assert results == [2, 2, 2, 2] # Release read locks for all keys l1_manager.finish_read(all_keys) ctrl.stop() adapter.close() # ============================================================================= # Multiple Sequential Requests # ============================================================================= class TestMultipleRequests: """Test multiple sequential prefetch requests.""" def test_two_sequential_requests(self, l1_manager): """Two back-to-back requests should both complete correctly.""" adapter = make_adapter() layout = make_layout() keys1 = [make_object_key(i) for i in range(3)] keys2 = [make_object_key(i) for i in range(10, 14)] store_keys_in_l2(adapter, keys1, layout) store_keys_in_l2(adapter, keys2, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req1 = ctrl.submit_prefetch_request(keys1, layout) result1 = wait_for_prefetch_result(ctrl, req1) assert result1 == 3 req2 = ctrl.submit_prefetch_request(keys2, layout) result2 = wait_for_prefetch_result(ctrl, req2) assert result2 == 4 l1_manager.finish_read(keys1) l1_manager.finish_read(keys2) ctrl.stop() adapter.close() # ============================================================================= # extra_count Path # ============================================================================= class TestExtraCountPrefetch: """Test that extra_count is correctly propagated through the prefetch path. When extra_count=N is passed to submit_prefetch_request, the controller must acquire 1 + N read locks per key (one for the prefetch controller itself, plus N for additional TP workers). Each consumer must call finish_read (or finish_read_prefetched) once to release its lock. These tests verify: 1. Keys remain accessible after the first finish_read when extra_count > 0. 2. Keys are evictable only after ALL 1 + N locks are released. 3. extra_count=0 (default) behaves identically to the original single-lock path. 4. Prefix trimming still works correctly with extra_count > 0. 5. Non-prefix loaded keys have all extra locks released by _finalize_load. """ def test_extra_count_zero_default_behavior(self, l1_manager): """extra_count=0 (default): single read lock, key freed after one finish_read.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=0) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # Keys should be readable immediately after prefetch read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS # Release the single read lock — keys should become unlocked finish_results = l1_manager.finish_read(keys, extra_count=0) for key in keys: assert finish_results[key] == L1Error.SUCCESS ctrl.stop() adapter.close() def test_extra_count_one_requires_two_finish_reads(self, l1_manager): """extra_count=1: two read locks acquired; key stays locked after first finish_read and is released after second.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() # extra_count=1 → 2 read locks per key req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=1) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # Keys must be readable right after prefetch read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS # Release lock #1 (the "prefetch controller" lock, extra_count=0) finish_results = l1_manager.finish_read(keys, extra_count=0) for key in keys: assert finish_results[key] == L1Error.SUCCESS # Keys must STILL be readable — lock #2 (the TP worker lock) is held read_results2 = l1_manager.unsafe_read(keys) for key in keys: assert read_results2[key][0] == L1Error.SUCCESS, ( f"Key {key} should still be read-locked after first finish_read" ) # Release lock #2 (the TP worker lock, extra_count=0) finish_results2 = l1_manager.finish_read(keys, extra_count=0) for key in keys: assert finish_results2[key] == L1Error.SUCCESS ctrl.stop() adapter.close() def test_extra_count_three_requires_four_finish_reads(self, l1_manager): """extra_count=3 (TP=4): four read locks; key stays locked until all four are released.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(2)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() # extra_count=3 → 4 read locks per key req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=3) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2 # Release locks one by one; key must remain readable until the last for release_idx in range(3): finish_results = l1_manager.finish_read(keys, extra_count=0) for key in keys: assert finish_results[key] == L1Error.SUCCESS # Still readable — remaining locks are held read_results = l1_manager.unsafe_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS, ( f"Key {key} should still be locked after {release_idx + 1} " f"finish_read calls" ) # Release the final lock finish_results = l1_manager.finish_read(keys, extra_count=0) for key in keys: assert finish_results[key] == L1Error.SUCCESS ctrl.stop() adapter.close() def test_extra_count_with_prefix_trim(self, l1_manager): """extra_count=1 with a gap in L2: only prefix keys get 2 locks; non-prefix keys are never loaded.""" adapter = make_adapter() layout = make_layout() all_keys = [make_object_key(i) for i in range(5)] # Store keys 0, 1, 3, 4 — gap at index 2 stored_keys = [all_keys[i] for i in [0, 1, 3, 4]] store_keys_in_l2(adapter, stored_keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(all_keys, layout, extra_count=1) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2, f"Expected 2 prefix hits (gap at index 2), got {result}" prefix_keys = all_keys[:2] # Prefix keys must be readable read_results = l1_manager.unsafe_read(prefix_keys) for key in prefix_keys: assert read_results[key][0] == L1Error.SUCCESS # Release lock #1 — prefix keys still held by lock #2 l1_manager.finish_read(prefix_keys, extra_count=0) read_results2 = l1_manager.unsafe_read(prefix_keys) for key in prefix_keys: assert read_results2[key][0] == L1Error.SUCCESS, ( f"Prefix key {key} should still be locked after first finish_read" ) # Release lock #2 l1_manager.finish_read(prefix_keys, extra_count=0) # Non-prefix keys must NOT be in L1 non_prefix_keys = all_keys[2:] reserve_results = l1_manager.reserve_read(non_prefix_keys) for key in non_prefix_keys: assert reserve_results[key][0] == L1Error.KEY_NOT_EXIST, ( f"Non-prefix key {key} should not be in L1" ) ctrl.stop() adapter.close() def test_extra_count_non_prefix_loaded_keys_fully_released(self, l1_manager): """Keys loaded beyond the prefix (due to partial load failure) must have ALL extra locks released by _finalize_load so they can be evicted. We simulate this by storing keys {0, 1, 2} in L2 but making key 1 fail to reserve in L1 (by pre-occupying it), creating a gap so that key 2 is loaded but lies beyond the prefix. _finalize_load must release 1 + extra_count locks for key 2. """ adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) # Pre-occupy key[1] in L1 with a write lock so reserve_write fails for it. # This forces a gap: key 0 is prefix (hit), key 1 fails reservation # (gap), key 2 is loaded but beyond the prefix. pre_write_results = l1_manager.reserve_write( keys=[keys[1]], is_temporary=[False], layout_desc=layout, mode="new", ) assert pre_write_results[keys[1]][0] == L1Error.SUCCESS ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout, extra_count=1) result = wait_for_prefetch_result(ctrl, req_id) # Only key 0 is in the prefix (key 1 reservation failed → gap) assert result == 1, f"Expected 1 prefix hit, got {result}" # key[0] should be in L1 with 2 read locks (1 + extra_count=1) read_results = l1_manager.unsafe_read([keys[0]]) assert read_results[keys[0]][0] == L1Error.SUCCESS # key[2] was loaded but is beyond the prefix; _finalize_load must have # released all 1 + extra_count=2 locks, so it should be gone from L1 # (it's a temporary object and its lock count should be 0). reserve_results = l1_manager.reserve_read([keys[2]]) assert reserve_results[keys[2]][0] == L1Error.KEY_NOT_EXIST, ( "Non-prefix loaded key[2] should have all locks released and be " "evicted from L1" ) # Clean up: release key[0]'s 2 locks and key[1]'s write lock l1_manager.finish_read([keys[0]], extra_count=0) l1_manager.finish_read([keys[0]], extra_count=0) l1_manager.finish_write([keys[1]]) l1_manager.delete([keys[1]]) ctrl.stop() adapter.close() # ============================================================================= # Query Lookup Result # ============================================================================= class TestQueryLookupResult: """Test query_lookup_result semantics.""" def test_lookup_result_available_before_prefetch_completes(self, l1_manager): """Lookup result is available while load is still in progress.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) # Lookup result should be available before or at the same time as # the full prefetch result. lookup_hits = wait_for_lookup_result(ctrl, req_id) assert lookup_hits is not None assert lookup_hits == 3 # Wait for full prefetch to complete and clean up result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_lookup_result_with_prefix_gap(self, l1_manager): """Lookup result reflects prefix-only hits (gap breaks prefix).""" adapter = make_adapter() layout = make_layout() # Store keys 0 and 2 (gap at 1) keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, [keys[0], keys[2]], layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) lookup_hits = wait_for_lookup_result(ctrl, req_id) assert lookup_hits is not None # Only key 0 is in the prefix (gap at key 1 breaks it) assert lookup_hits == 1 result = wait_for_prefetch_result(ctrl, req_id) assert result == 1 l1_manager.finish_read([keys[0]]) ctrl.stop() adapter.close() def test_lookup_result_zero_hits(self, l1_manager): """Lookup result is 0 when no keys are found in L2.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] # Don't store anything in L2 ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) lookup_hits = wait_for_lookup_result(ctrl, req_id) assert lookup_hits is not None assert lookup_hits == 0 result = wait_for_prefetch_result(ctrl, req_id) assert result == 0 ctrl.stop() adapter.close() def test_lookup_result_not_popped_by_query(self, l1_manager): """query_lookup_result does not consume the result (idempotent reads).""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(2)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) lookup_hits = wait_for_lookup_result(ctrl, req_id) assert lookup_hits == 2 # Second query should still return the same value (not consumed) assert ctrl.query_lookup_result(req_id) == 2 result = wait_for_prefetch_result(ctrl, req_id) assert result == 2 l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_lookup_result_cleaned_up_by_prefetch_result(self, l1_manager): """query_prefetch_result cleans up the lookup result entry.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(2)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) lookup_hits = wait_for_lookup_result(ctrl, req_id) assert lookup_hits == 2 # Consume the prefetch result (should also clean up lookup entry) result = wait_for_prefetch_result(ctrl, req_id) assert result == 2 # Lookup result should now be gone assert ctrl.query_lookup_result(req_id) is None l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_lookup_result_nonexistent_request(self, l1_manager): """Querying a nonexistent request ID returns None.""" ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[], adapter_descriptors=[], policy=DefaultPrefetchPolicy(), ) ctrl.start() assert ctrl.query_lookup_result(999) is None ctrl.stop() class TestBuildTrimMask: """build_trim_mask picks the retained subset per policy: PREFIX trims at the first gap; SEGMENTED_PREFIX and SPARSE keep every set bit (gaps and all). The retained bitmap is consumed unchanged at the controller's load sites, so testing the mask directly covers the policy semantics.""" @staticmethod def _bm(n, idxs): bm = Bitmap(n) for i in idxs: bm.set(i) return bm def test_prefix_trims_at_first_gap(self): found = self._bm(5, [0, 1, 3, 4]) # gap at index 2 assert build_trim_mask(found, 5, TrimPolicy.PREFIX).get_indices_list() == [ 0, 1, ] def test_segmented_prefix_keeps_gaps(self): # Models an L2 hit whose L1 load failed mid-prefix (e.g. OOM at index # 2): the keys that did load are kept, not trimmed to the first gap. found = self._bm(5, [0, 1, 3, 4]) assert build_trim_mask( found, 5, TrimPolicy.SEGMENTED_PREFIX ).get_indices_list() == [0, 1, 3, 4] def test_sparse_keeps_all_found(self): found = self._bm(5, [0, 2, 4]) assert build_trim_mask(found, 5, TrimPolicy.SPARSE).get_indices_list() == [ 0, 2, 4, ] class TestMergeBitmaps: """merge_bitmaps always returns a num_keys-sized bitmap.""" def test_empty_input_returns_sized_bitmap(self): """Empty input -> num_keys-sized all-zeros bitmap (not Bitmap(0)), so a downstream ``&`` with a same-sized mask never hits a size mismatch.""" merged = merge_bitmaps([], 5) assert merged.popcount() == 0 mask = Bitmap(5) mask.set(2) assert (merged & mask).popcount() == 0 # would raise on size mismatch def test_empty_generator_returns_sized_bitmap(self): """A generator is truthy even when empty; the result is still size-5.""" merged = merge_bitmaps((b for b in []), 5) assert merged.popcount() == 0 assert (merged & Bitmap(5)).popcount() == 0 def test_union_of_bitmaps(self): """Non-empty inputs are OR-merged into one num_keys-sized bitmap.""" a, b = Bitmap(5), Bitmap(5) a.set(0) b.set(3) assert merge_bitmaps([a, b], 5).get_indices_list() == [0, 3] class TestWaitPrefetchResult: """Test the blocking wait_prefetch_result interface.""" def test_wait_blocks_until_result_ready(self, l1_manager): """wait_prefetch_result blocks until the background result is published, returns True, and does not consume the result.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(5)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) # Blocks until the background thread publishes the result. assert ctrl.wait_prefetch_result(req_id, timeout=10.0) is True # wait_prefetch_result must not consume the result. result = ctrl.query_prefetch_result(req_id) assert result is not None assert result.count_leading_ones() == 5 l1_manager.finish_read(keys) ctrl.stop() adapter.close() def test_wait_times_out_for_unknown_request(self, l1_manager): """wait_prefetch_result returns False, after genuinely waiting, when no result arrives within the timeout.""" ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[], adapter_descriptors=[], policy=DefaultPrefetchPolicy(), ) ctrl.start() start = time.monotonic() assert ctrl.wait_prefetch_result(999999, timeout=0.2) is False assert time.monotonic() - start >= 0.2 ctrl.stop() # ============================================================================= # Retention Policy # ============================================================================= class TestPrefetchMode: """``mode=WARM`` (the warm path) loads keys **permanent** and **without a read lock**, vs ``LOOKUP`` which read-locks temporary objects that vanish on release. Both tests use ``DefaultPrefetchPolicy`` so the only difference is the per-request ``mode`` argument. """ def test_warm_loads_unlocked_and_permanent(self, l1_manager): """WARM loads keys permanent and with NO read lock: immediately ready (reserve_read SUCCEEDS), holding no lock (unsafe_read NOT_READABLE), and not deleted on a reserve_read/finish_read cycle (permanent, not temp).""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout, mode=PrefetchMode.WARM) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # No warming lock: unsafe_read (which needs an active read lock) reports # NOT_READABLE even though the keys are present and ready. unsafe = l1_manager.unsafe_read(keys) for key in keys: assert unsafe[key][0] == L1Error.KEY_NOT_READABLE # They are ready and re-lookupable: reserve_read SUCCEEDS... read_results = l1_manager.reserve_read(keys) for key in keys: assert read_results[key][0] == L1Error.SUCCESS # ...and releasing that probe lock does NOT delete them (permanent). l1_manager.finish_read(keys) again = l1_manager.reserve_read(keys) for key in keys: assert again[key][0] == L1Error.SUCCESS l1_manager.finish_read(keys) l1_manager.delete(keys) ctrl.stop() adapter.close() def test_warm_sparse_skips_write_locked_prefix_and_loads_later_keys( self, l1_manager ): """SPARSE WARM does not let a write-locked earlier key suppress later keys that can be reserved and loaded.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) existing = l1_manager.reserve_write( [keys[0]], is_temporary=[False], layout_desc=layout, mode="new" ) assert existing[keys[0]][0] == L1Error.SUCCESS ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request( keys, layout, policy=TrimPolicy.SPARSE, mode=PrefetchMode.WARM, ) result = wait_for_prefetch_result_bitmap(ctrl, req_id) assert result is not None assert result.get_indices_list() == [1, 2] l1_manager.finish_write([keys[0]]) read_results = l1_manager.reserve_read(keys[1:]) for key in keys[1:]: assert read_results[key][0] == L1Error.SUCCESS l1_manager.finish_read(keys[1:]) l1_manager.delete(keys) ctrl.stop() adapter.close() def test_default_deletes_keys_after_finish_read(self, l1_manager): """LOOKUP defers to ``DefaultPrefetchPolicy`` (temporary), so the keys are deleted from L1 once the read-lock is released.""" adapter = make_adapter() layout = make_layout() keys = [make_object_key(i) for i in range(3)] store_keys_in_l2(adapter, keys, layout) ctrl = PrefetchController( l1_manager=l1_manager, l2_adapters=[adapter], adapter_descriptors=[make_descriptor(0)], policy=DefaultPrefetchPolicy(), ) ctrl.start() req_id = ctrl.submit_prefetch_request(keys, layout) result = wait_for_prefetch_result(ctrl, req_id) assert result == 3 # Releasing the read-lock deletes the temporary objects, so a # subsequent lookup (reserve_read) misses with KEY_NOT_EXIST. l1_manager.finish_read(keys) read_results = l1_manager.reserve_read(keys) for key in keys: assert read_results[key][0] == L1Error.KEY_NOT_EXIST ctrl.stop() adapter.close()