# SPDX-License-Identifier: Apache-2.0 """ Unit tests for MockL2Adapter. Tests are written based on the L2AdapterInterface contract defined in base.py. Tests only use public methods and do not access private fields. """ # 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.internal_api import L2AdapterListener from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import ( MockL2Adapter, MockL2AdapterConfig, ) from lmcache.v1.memory_management import ( MemoryFormat, MemoryObjMetadata, TensorMemoryObj, ) from lmcache.v1.platform import consume_fd _EMPTY_LAYOUT = MemoryLayoutDesc(shapes=[], dtypes=[]) class _RecordingListener(L2AdapterListener): """Listener that records all events for inspection in tests.""" def __init__(self): self.stored: list[list[ObjectKey]] = [] self.accessed: list[list[ObjectKey]] = [] self.deleted: list[list[ObjectKey]] = [] def on_l2_keys_stored(self, keys: list[ObjectKey], sizes: list[int]): self.stored.append(list(keys)) def on_l2_keys_accessed(self, keys: list[ObjectKey]): self.accessed.append(list(keys)) def on_l2_keys_deleted(self, keys: list[ObjectKey]): self.deleted.append(list(keys)) # ============================================================================= # Test Fixtures # ============================================================================= def create_object_key(chunk_id: int, model_name: str = "test_model") -> ObjectKey: """Create a test ObjectKey with the given chunk ID.""" return ObjectKey( chunk_hash=ObjectKey.IntHash2Bytes(chunk_id), model_name=model_name, kv_rank=0, ) def create_memory_obj(size: int = 1024, fill_value: float = 1.0) -> TensorMemoryObj: """Create a test TensorMemoryObj with the given size.""" raw_data = torch.empty(size, dtype=torch.float32) raw_data.fill_(fill_value) metadata = MemoryObjMetadata( shape=torch.Size([size]), dtype=torch.float32, address=0, phy_size=size * 4, fmt=MemoryFormat.KV_2LTD, ref_count=1, ) return TensorMemoryObj(raw_data, metadata, parent_allocator=None) def wait_for_event_fd(event_fd: int, timeout: float = 5.0) -> bool: """ Wait for an event fd to be signaled. Returns True if signaled within timeout, False otherwise. """ poll = select.poll() poll.register(event_fd, select.POLLIN) events = poll.poll(timeout * 1000) # timeout in milliseconds if events: # Read and consume the event try: consume_fd(event_fd) except BlockingIOError: pass return True return False @pytest.fixture def adapter(): """Create a MockL2Adapter with reasonable defaults for testing.""" config = MockL2AdapterConfig( max_size_gb=0.001, # 1 MB mock_bandwidth_gb=10.0, # 10 GB/s (fast for tests) ) adapter = MockL2Adapter(config) yield adapter adapter.close() @pytest.fixture def slow_adapter(): """Create a MockL2Adapter with slow bandwidth for timing tests.""" config = MockL2AdapterConfig( max_size_gb=0.001, # 1 MB mock_bandwidth_gb=0.0001, # Very slow bandwidth ) adapter = MockL2Adapter(config) yield adapter adapter.close() # ============================================================================= # Event Fd Interface Tests # ============================================================================= class TestEventFdInterface: """Test the event fd interface methods.""" def test_get_store_event_fd_returns_valid_fd(self, adapter): """get_store_event_fd should return a valid file descriptor.""" fd = adapter.get_store_event_fd() assert isinstance(fd, int) assert fd >= 0 def test_get_lookup_and_lock_event_fd_returns_valid_fd(self, adapter): """get_lookup_and_lock_event_fd should return a valid file descriptor.""" fd = adapter.get_lookup_and_lock_event_fd() assert isinstance(fd, int) assert fd >= 0 def test_get_load_event_fd_returns_valid_fd(self, adapter): """get_load_event_fd should return a valid file descriptor.""" fd = adapter.get_load_event_fd() assert isinstance(fd, int) assert fd >= 0 def test_event_fds_are_different(self, adapter): """Each operation should have a distinct event fd.""" store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() load_fd = adapter.get_load_event_fd() assert store_fd != lookup_fd assert store_fd != load_fd assert lookup_fd != load_fd # ============================================================================= # Store Interface Tests # ============================================================================= class TestStoreInterface: """Test the store operation interface.""" def test_submit_store_task_returns_task_id(self, adapter): """submit_store_task should return a valid task ID.""" key = create_object_key(1) obj = create_memory_obj() task_id = adapter.submit_store_task([key], [obj]) assert isinstance(task_id, int) def test_submit_store_task_signals_event_fd(self, adapter): """Store completion should signal the store event fd.""" key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() adapter.submit_store_task([key], [obj]) # Wait for the event fd to be signaled assert wait_for_event_fd(store_fd, timeout=5.0), ( "Store event fd was not signaled within timeout" ) def test_pop_completed_store_tasks_returns_completed(self, adapter): """pop_completed_store_tasks should return completed tasks with success status. """ key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() task_id = adapter.submit_store_task([key], [obj]) # Wait for completion assert wait_for_event_fd(store_fd, timeout=5.0) completed = adapter.pop_completed_store_tasks() assert task_id in completed assert completed[task_id].is_successful() # Should be successful def test_pop_completed_store_tasks_clears_completed(self, adapter): """pop_completed_store_tasks should clear the completed tasks.""" key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() adapter.submit_store_task([key], [obj]) wait_for_event_fd(store_fd, timeout=5.0) # First pop should return the task completed1 = adapter.pop_completed_store_tasks() assert len(completed1) == 1 # Second pop should return empty completed2 = adapter.pop_completed_store_tasks() assert len(completed2) == 0 def test_submit_multiple_store_tasks(self, adapter): """Multiple store tasks should each get unique task IDs.""" keys = [create_object_key(i) for i in range(3)] objs = [create_memory_obj(fill_value=float(i)) for i in range(3)] store_fd = adapter.get_store_event_fd() task_ids = [] for key, obj in zip(keys, objs, strict=False): task_id = adapter.submit_store_task([key], [obj]) task_ids.append(task_id) # All task IDs should be unique assert len(set(task_ids)) == 3 # Wait for all completions completed = {} while len(completed) < 3: wait_for_event_fd(store_fd, timeout=5.0) completed.update(adapter.pop_completed_store_tasks()) # All tasks should be completed for task_id in task_ids: assert task_id in completed assert completed[task_id].is_successful() def test_store_batch_of_objects(self, adapter): """A single store task can store multiple key-object pairs.""" keys = [create_object_key(i) for i in range(5)] objs = [create_memory_obj(fill_value=float(i)) for i in range(5)] store_fd = adapter.get_store_event_fd() task_id = adapter.submit_store_task(keys, objs) assert wait_for_event_fd(store_fd, timeout=5.0) completed = adapter.pop_completed_store_tasks() assert task_id in completed assert completed[task_id].is_successful() # ============================================================================= # Lookup and Lock Interface Tests # ============================================================================= class TestLookupAndLockInterface: """Test the lookup and lock operation interface.""" def test_submit_lookup_returns_task_id(self, adapter): """submit_lookup_and_lock_task should return a valid task ID.""" key = create_object_key(1) task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) assert isinstance(task_id, int) def test_lookup_signals_event_fd(self, adapter): """Lookup completion should signal the lookup event fd.""" key = create_object_key(1) lookup_fd = adapter.get_lookup_and_lock_event_fd() adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) assert wait_for_event_fd(lookup_fd, timeout=5.0), ( "Lookup event fd was not signaled within timeout" ) def test_lookup_nonexistent_key_returns_bitmap_with_zeros(self, adapter): """Looking up a non-existent key should return a bitmap with 0.""" key = create_object_key(999) # Never stored lookup_fd = adapter.get_lookup_and_lock_event_fd() task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) wait_for_event_fd(lookup_fd, timeout=5.0) bitmap = adapter.query_lookup_and_lock_result(task_id) assert bitmap is not None assert bitmap.test(0) is False # Key not found def test_lookup_existing_key_returns_bitmap_with_ones(self, adapter): """Looking up an existing key should return a bitmap with 1.""" key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() # First store the object adapter.submit_store_task([key], [obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Now lookup task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) wait_for_event_fd(lookup_fd, timeout=5.0) bitmap = adapter.query_lookup_and_lock_result(task_id) assert bitmap is not None assert bitmap.test(0) is True # Key found def test_lookup_mixed_keys(self, adapter): """Lookup of mixed existing/non-existing keys returns correct bitmap.""" existing_key = create_object_key(1) nonexistent_key = create_object_key(999) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() # Store only one key adapter.submit_store_task([existing_key], [obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Lookup both keys task_id = adapter.submit_lookup_and_lock_task( [existing_key, nonexistent_key], _EMPTY_LAYOUT ) wait_for_event_fd(lookup_fd, timeout=5.0) bitmap = adapter.query_lookup_and_lock_result(task_id) assert bitmap is not None assert bitmap.test(0) is True # existing_key found assert bitmap.test(1) is False # nonexistent_key not found def test_query_lookup_result_returns_none_for_unknown_task(self, adapter): """Querying an unknown task ID should return None.""" result = adapter.query_lookup_and_lock_result(99999) assert result is None def test_query_lookup_result_clears_result(self, adapter): """Querying lookup result should remove it (can only query once).""" key = create_object_key(1) lookup_fd = adapter.get_lookup_and_lock_event_fd() task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) wait_for_event_fd(lookup_fd, timeout=5.0) # First query returns result result1 = adapter.query_lookup_and_lock_result(task_id) assert result1 is not None # Second query returns None result2 = adapter.query_lookup_and_lock_result(task_id) assert result2 is None # ============================================================================= # Unlock Interface Tests # ============================================================================= class TestUnlockInterface: """Test the unlock operation interface.""" def test_submit_unlock_does_not_raise(self, adapter): """submit_unlock should not raise an exception.""" key = create_object_key(1) # Should not raise even for non-existent key adapter.submit_unlock([key]) def test_unlock_after_lock(self, adapter): """Unlocking after locking should work without error.""" key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() # Store adapter.submit_store_task([key], [obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Lookup and lock task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) wait_for_event_fd(lookup_fd, timeout=5.0) adapter.query_lookup_and_lock_result(task_id) # Unlock should not raise adapter.submit_unlock([key]) # ============================================================================= # Load Interface Tests # ============================================================================= class TestLoadInterface: """Test the load operation interface.""" def test_submit_load_task_returns_task_id(self, adapter): """submit_load_task should return a valid task ID.""" key = create_object_key(1) obj = create_memory_obj() task_id = adapter.submit_load_task([key], [obj]) assert isinstance(task_id, int) def test_load_signals_event_fd(self, adapter): """Load completion should signal the load event fd.""" key = create_object_key(1) obj = create_memory_obj() load_fd = adapter.get_load_event_fd() adapter.submit_load_task([key], [obj]) assert wait_for_event_fd(load_fd, timeout=5.0), ( "Load event fd was not signaled within timeout" ) def test_load_nonexistent_key_returns_bitmap_with_zeros(self, adapter): """Loading a non-existent key should return a bitmap with 0.""" key = create_object_key(999) # Never stored obj = create_memory_obj() load_fd = adapter.get_load_event_fd() task_id = adapter.submit_load_task([key], [obj]) wait_for_event_fd(load_fd, timeout=5.0) bitmap = adapter.query_load_result(task_id) assert bitmap is not None assert bitmap.test(0) is False # Load failed def test_load_existing_key_copies_data(self, adapter): """Loading an existing key should copy data to the provided buffer.""" key = create_object_key(1) store_obj = create_memory_obj(size=100, fill_value=42.0) load_obj = create_memory_obj(size=100, fill_value=0.0) store_fd = adapter.get_store_event_fd() load_fd = adapter.get_load_event_fd() # Store adapter.submit_store_task([key], [store_obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Load task_id = adapter.submit_load_task([key], [load_obj]) wait_for_event_fd(load_fd, timeout=5.0) bitmap = adapter.query_load_result(task_id) assert bitmap is not None assert bitmap.test(0) is True # Load succeeded # Data should be copied assert torch.all(load_obj.tensor == 42.0) def test_query_load_result_returns_none_for_unknown_task(self, adapter): """Querying an unknown task ID should return None.""" result = adapter.query_load_result(99999) assert result is None def test_query_load_result_clears_result(self, adapter): """Querying load result should remove it (can only query once).""" key = create_object_key(1) obj = create_memory_obj() load_fd = adapter.get_load_event_fd() task_id = adapter.submit_load_task([key], [obj]) wait_for_event_fd(load_fd, timeout=5.0) # First query returns result result1 = adapter.query_load_result(task_id) assert result1 is not None # Second query returns None result2 = adapter.query_load_result(task_id) assert result2 is None # ============================================================================= # End-to-End Workflow Tests # ============================================================================= class TestEndToEndWorkflow: """Test complete store-lookup-load workflows.""" def test_store_lookup_load_workflow(self, adapter): """Test the complete workflow: store -> lookup -> load.""" key = create_object_key(1) store_obj = create_memory_obj(size=256, fill_value=123.0) load_obj = create_memory_obj(size=256, fill_value=0.0) store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() load_fd = adapter.get_load_event_fd() # Step 1: Store store_task_id = adapter.submit_store_task([key], [store_obj]) assert wait_for_event_fd(store_fd, timeout=5.0) completed = adapter.pop_completed_store_tasks() assert completed[store_task_id].is_successful() # Step 2: Lookup and lock lookup_task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) assert wait_for_event_fd(lookup_fd, timeout=5.0) lookup_bitmap = adapter.query_lookup_and_lock_result(lookup_task_id) assert lookup_bitmap.test(0) is True # Step 3: Load load_task_id = adapter.submit_load_task([key], [load_obj]) assert wait_for_event_fd(load_fd, timeout=5.0) load_bitmap = adapter.query_load_result(load_task_id) assert load_bitmap.test(0) is True # Verify data assert torch.all(load_obj.tensor == 123.0) # Step 4: Unlock adapter.submit_unlock([key]) def test_multiple_objects_workflow(self, adapter): """Test workflow with multiple objects.""" num_objects = 5 keys = [create_object_key(i) for i in range(num_objects)] store_objs = [ create_memory_obj(size=64, fill_value=float(i * 10)) for i in range(num_objects) ] load_objs = [ create_memory_obj(size=64, fill_value=0.0) for _ in range(num_objects) ] store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() load_fd = adapter.get_load_event_fd() # Store all store_task_id = adapter.submit_store_task(keys, store_objs) assert wait_for_event_fd(store_fd, timeout=5.0) completed = adapter.pop_completed_store_tasks() assert completed[store_task_id].is_successful() # Lookup all lookup_task_id = adapter.submit_lookup_and_lock_task(keys, _EMPTY_LAYOUT) assert wait_for_event_fd(lookup_fd, timeout=5.0) lookup_bitmap = adapter.query_lookup_and_lock_result(lookup_task_id) for i in range(num_objects): assert lookup_bitmap.test(i) is True # Load all load_task_id = adapter.submit_load_task(keys, load_objs) assert wait_for_event_fd(load_fd, timeout=5.0) load_bitmap = adapter.query_load_result(load_task_id) for i in range(num_objects): assert load_bitmap.test(i) is True assert torch.all(load_objs[i].tensor == float(i * 10)) # ============================================================================= # Close Interface Tests # ============================================================================= class TestCloseInterface: """Test the close operation.""" def test_close_does_not_raise(self): """close() should not raise an exception.""" config = MockL2AdapterConfig(max_size_gb=0.001, mock_bandwidth_gb=10.0) adapter = MockL2Adapter(config) # Should not raise adapter.close() def test_close_after_operations(self): """close() should work after store/lookup/load operations.""" config = MockL2AdapterConfig(max_size_gb=0.001, mock_bandwidth_gb=10.0) adapter = MockL2Adapter(config) key = create_object_key(1) obj = create_memory_obj() store_fd = adapter.get_store_event_fd() adapter.submit_store_task([key], [obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Should not raise adapter.close() # ============================================================================= # Bandwidth Simulation Tests # ============================================================================= class TestBandwidthSimulation: """Test that bandwidth simulation affects completion timing.""" def test_store_completion_is_delayed_by_bandwidth(self, slow_adapter): """Store completion should be delayed based on object size and bandwidth.""" key = create_object_key(1) # Create a larger object to see noticeable delay obj = create_memory_obj(size=10000) # ~40KB store_fd = slow_adapter.get_store_event_fd() start_time = time.time() slow_adapter.submit_store_task([key], [obj]) # Should take some time due to bandwidth simulation assert wait_for_event_fd(store_fd, timeout=60.0) elapsed = time.time() - start_time # With very slow bandwidth, should take noticeable time # (exact time depends on bandwidth config) assert elapsed > 0.1 # At least some delay def test_fast_bandwidth_completes_quickly(self, adapter): """With fast bandwidth, completion should be quick.""" key = create_object_key(1) obj = create_memory_obj(size=1000) store_fd = adapter.get_store_event_fd() start_time = time.time() adapter.submit_store_task([key], [obj]) assert wait_for_event_fd(store_fd, timeout=5.0) elapsed = time.time() - start_time # With fast bandwidth, should complete quickly assert elapsed < 1.0 # ============================================================================= # Eviction Interface Tests # ============================================================================= def _store_and_wait(adapter, key, obj): """Helper: store one key and wait for the store event fd to fire.""" store_fd = adapter.get_store_event_fd() adapter.submit_store_task([key], [obj]) assert wait_for_event_fd(store_fd, timeout=5.0), "store timed out" adapter.pop_completed_store_tasks() class TestEvictionInterface: """Tests for delete(), get_usage(), and listener notifications.""" def test_delete_removes_key(self, adapter): """delete() should make the key invisible to subsequent lookups.""" key = create_object_key(1) obj = create_memory_obj() lookup_fd = adapter.get_lookup_and_lock_event_fd() _store_and_wait(adapter, key, obj) adapter.delete([key]) task_id = adapter.submit_lookup_and_lock_task([key], _EMPTY_LAYOUT) assert wait_for_event_fd(lookup_fd, timeout=5.0) bitmap = adapter.query_lookup_and_lock_result(task_id) assert bitmap.test(0) is False def test_delete_nonexistent_key_does_not_raise(self, adapter): """delete() on a key that was never stored should not raise.""" adapter.delete([create_object_key(999)]) def test_delete_multiple_keys(self, adapter): """delete() on a batch of keys removes all of them.""" keys = [create_object_key(i) for i in range(3)] objs = [create_memory_obj() for _ in range(3)] store_fd = adapter.get_store_event_fd() lookup_fd = adapter.get_lookup_and_lock_event_fd() adapter.submit_store_task(keys, objs) assert wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() adapter.delete(keys) task_id = adapter.submit_lookup_and_lock_task(keys, _EMPTY_LAYOUT) assert wait_for_event_fd(lookup_fd, timeout=5.0) bitmap = adapter.query_lookup_and_lock_result(task_id) for i in range(len(keys)): assert bitmap.test(i) is False def test_get_usage_empty_adapter_is_zero(self, adapter): """get_usage() on a fresh adapter should report 0 bytes.""" usage = adapter.get_usage() assert usage.total_bytes_used == 0 assert usage.usage_fraction == 0.0 assert usage.bytes_by_cache_salt == {} def test_get_usage_increases_after_store(self, adapter): """get_usage() should report positive bytes after a store.""" key = create_object_key(1) obj = create_memory_obj(size=1024) _store_and_wait(adapter, key, obj) usage = adapter.get_usage() assert usage.total_bytes_used > 0 assert 0.0 < usage.usage_fraction <= 1.0 def test_get_usage_decreases_after_delete(self, adapter): """get_usage() should drop back to 0 after deleting the only stored key.""" key = create_object_key(1) obj = create_memory_obj(size=1024) _store_and_wait(adapter, key, obj) assert adapter.get_usage().total_bytes_used > 0 adapter.delete([key]) usage = adapter.get_usage() assert usage.total_bytes_used == 0 assert usage.usage_fraction == 0.0 def test_bytes_by_cache_salt_populated_from_cache_salt(self, adapter): """End-to-end: storing keys with different ``cache_salt`` values should drive the byte buckets in ``AdapterUsage`` — proves the salt actually flows through the real adapter into the base-class accounting (not just verified by the stub tests).""" # Two keys per cache_salt so we know the totals are summed, not # just per-key-overwritten. alice_keys = [ ObjectKey( chunk_hash=ObjectKey.IntHash2Bytes(i), model_name="m", kv_rank=0, cache_salt="alice", ) for i in (1, 2) ] bob_key = ObjectKey( chunk_hash=ObjectKey.IntHash2Bytes(3), model_name="m", kv_rank=0, cache_salt="bob", ) obj = create_memory_obj(size=128) # 128 floats * 4 bytes = 512 B for k in alice_keys + [bob_key]: _store_and_wait(adapter, k, obj) usage = adapter.get_usage() assert usage.bytes_by_cache_salt == {"alice": 1024, "bob": 512} assert usage.total_bytes_used == 1536 # Deleting one of alice's keys should shrink alice's bucket but # leave bob's untouched. adapter.delete([alice_keys[0]]) usage = adapter.get_usage() assert usage.bytes_by_cache_salt == {"alice": 512, "bob": 512} assert usage.total_bytes_used == 1024 # Deleting alice's last key should drop the bucket entirely so # the snapshot stays compact. adapter.delete([alice_keys[1]]) usage = adapter.get_usage() assert "alice" not in usage.bytes_by_cache_salt assert usage.bytes_by_cache_salt == {"bob": 512} def test_listener_notified_on_store(self, adapter): """Listener.on_l2_keys_stored should be called after a store completes.""" listener = _RecordingListener() adapter.register_listener(listener) key = create_object_key(1) obj = create_memory_obj() _store_and_wait(adapter, key, obj) assert len(listener.stored) == 1 assert key in listener.stored[0] assert listener.deleted == [] def test_listener_notified_on_delete(self, adapter): """Listener.on_l2_keys_deleted should be called after delete().""" listener = _RecordingListener() adapter.register_listener(listener) key = create_object_key(1) obj = create_memory_obj() _store_and_wait(adapter, key, obj) adapter.delete([key]) assert len(listener.deleted) == 1 assert key in listener.deleted[0] def test_listener_delete_skips_missing_keys(self, adapter): """on_l2_keys_deleted should only include keys that were actually removed.""" listener = _RecordingListener() adapter.register_listener(listener) real_key = create_object_key(1) missing_key = create_object_key(999) obj = create_memory_obj() _store_and_wait(adapter, real_key, obj) adapter.delete([real_key, missing_key]) assert len(listener.deleted) == 1 notified = listener.deleted[0] assert real_key in notified assert missing_key not in notified def test_listener_notified_on_load(self, adapter): """Listener.on_l2_keys_accessed should be called after a load completes.""" listener = _RecordingListener() adapter.register_listener(listener) key = create_object_key(1) store_obj = create_memory_obj(size=100, fill_value=42.0) load_obj = create_memory_obj(size=100, fill_value=0.0) store_fd = adapter.get_store_event_fd() load_fd = adapter.get_load_event_fd() # Store adapter.submit_store_task([key], [store_obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Load adapter.submit_load_task([key], [load_obj]) wait_for_event_fd(load_fd, timeout=5.0) assert len(listener.accessed) == 1 assert key in listener.accessed[0] def test_listener_load_skips_missing_keys(self, adapter): """on_l2_keys_accessed should only include keys that were actually loaded.""" listener = _RecordingListener() adapter.register_listener(listener) real_key = create_object_key(1) missing_key = create_object_key(999) store_obj = create_memory_obj(size=100, fill_value=42.0) load_obj1 = create_memory_obj(size=100, fill_value=0.0) load_obj2 = create_memory_obj(size=100, fill_value=0.0) store_fd = adapter.get_store_event_fd() load_fd = adapter.get_load_event_fd() # Store only real_key adapter.submit_store_task([real_key], [store_obj]) wait_for_event_fd(store_fd, timeout=5.0) adapter.pop_completed_store_tasks() # Load both keys adapter.submit_load_task([real_key, missing_key], [load_obj1, load_obj2]) wait_for_event_fd(load_fd, timeout=5.0) assert len(listener.accessed) == 1 assert real_key in listener.accessed[0] assert missing_key not in listener.accessed[0] def test_multiple_listeners_all_notified(self, adapter): """All registered listeners should receive the same store event.""" l1, l2 = _RecordingListener(), _RecordingListener() adapter.register_listener(l1) adapter.register_listener(l2) key = create_object_key(1) obj = create_memory_obj() _store_and_wait(adapter, key, obj) assert len(l1.stored) == 1 assert len(l2.stored) == 1