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

336 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the unified touch feature in end_session.
This module tests:
1. L1EvictionPolicy.on_l1_keys_accessed bridges to policy.on_keys_touched
2. L1EvictionPolicy.on_l1_keys_read_finished also triggers touch
3. L1Manager.touch_keys dispatches to all registered listeners
4. MPCacheServer.end_session performs unified touch with correct keys
5. StorageManager.touch_l1_keys delegates to L1Manager
"""
# Standard
from unittest.mock import MagicMock
# Third Party
import pytest
# First Party
from lmcache.v1.distributed.api import ObjectKey, ipc_key_to_object_keys
from lmcache.v1.distributed.eviction import L1EvictionPolicy
from lmcache.v1.distributed.eviction_policy import LRUEvictionPolicy
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
from lmcache.v1.multiprocess.session import SessionManager
from lmcache.v1.multiprocess.token_hasher import TokenHasher
# =============================================================================
# Helper Functions
# =============================================================================
def make_key(chunk_hash: int, model: str = "model", kv_rank: int = 0) -> ObjectKey:
"""Create an ObjectKey for testing."""
hash_bytes = ObjectKey.IntHash2Bytes(chunk_hash)
return ObjectKey(chunk_hash=hash_bytes, model_name=model, kv_rank=kv_rank)
def make_ipc_key(
token_ids: list[int],
chunk_size: int = 4,
model_name: str = "model",
world_size: int = 1,
worker_id: int | None = None,
request_id: str = "req-1",
) -> IPCCacheServerKey:
"""Create an IPCCacheServerKey for testing."""
return IPCCacheServerKey.from_token_ids(
model_name=model_name,
world_size=world_size,
worker_id=worker_id,
token_ids=token_ids,
start=0,
end=len(token_ids),
request_id=request_id,
)
@pytest.fixture
def hasher() -> TokenHasher:
"""TokenHasher with small chunk_size for testing."""
return TokenHasher(chunk_size=4, hash_algorithm="blake3")
# =============================================================================
# L1EvictionPolicy Bridge Tests
# =============================================================================
class TestL1EvictionPolicyAccessedBridge:
"""Tests for L1EvictionPolicy.on_l1_keys_accessed bridging."""
def test_on_l1_keys_accessed_calls_policy_on_keys_touched(self):
"""on_l1_keys_accessed should delegate to policy.on_keys_touched."""
mock_policy = MagicMock()
listener = L1EvictionPolicy(mock_policy)
keys = [make_key(1), make_key(2)]
listener.on_l1_keys_accessed(keys)
mock_policy.on_keys_touched.assert_called_once_with(keys)
def test_on_l1_keys_read_finished_also_touches(self):
"""on_l1_keys_read_finished should call on_keys_touched."""
mock_policy = MagicMock()
listener = L1EvictionPolicy(mock_policy)
keys = [make_key(1), make_key(2)]
listener.on_l1_keys_read_finished(keys)
mock_policy.on_keys_touched.assert_called_once_with(keys)
def test_on_l1_keys_accessed_with_lru_policy(self):
"""on_l1_keys_accessed should update LRU order via real LRU policy."""
lru = LRUEvictionPolicy()
listener = L1EvictionPolicy(lru)
# Create keys in order: 1, 2, 3
keys = [make_key(1), make_key(2), make_key(3)]
lru.on_keys_created(keys)
# Touch key 1 via the listener bridge
listener.on_l1_keys_accessed([make_key(1)])
# Eviction order should now be: 3, 2, 1 (1 is most recent)
candidates = lru.get_eviction_candidates(3)
assert ObjectKey.Bytes2IntHash(candidates[0].chunk_hash) == 3
assert ObjectKey.Bytes2IntHash(candidates[1].chunk_hash) == 2
assert ObjectKey.Bytes2IntHash(candidates[2].chunk_hash) == 1
def test_on_l1_keys_read_finished_updates_lru(self):
"""on_l1_keys_read_finished should update LRU order."""
lru = LRUEvictionPolicy()
listener = L1EvictionPolicy(lru)
# Create keys in order: 1, 2, 3
keys = [make_key(1), make_key(2), make_key(3)]
lru.on_keys_created(keys)
# Call on_l1_keys_read_finished on key 1 - should touch it
listener.on_l1_keys_read_finished([make_key(1)])
# Key 1 is now most recently touched, eviction order: 2, 3, 1
candidates = lru.get_eviction_candidates(3)
assert ObjectKey.Bytes2IntHash(candidates[0].chunk_hash) == 3
assert ObjectKey.Bytes2IntHash(candidates[1].chunk_hash) == 2
assert ObjectKey.Bytes2IntHash(candidates[2].chunk_hash) == 1
class TestL1EvictionPolicyUnifiedTouchOrder:
"""Tests for the unified touch producing correct eviction order.
The key benefit of unified touch is that all chunks of a request
are touched in order, so eviction proceeds from the last chunk
backwards (most recent chunk evicted last).
"""
def test_unified_touch_produces_reverse_eviction_order(self):
"""Touching keys in order [1,2,3] should evict 1 first (LRU)."""
lru = LRUEvictionPolicy()
listener = L1EvictionPolicy(lru)
keys = [make_key(i) for i in range(1, 6)]
lru.on_keys_created(keys)
# Unified touch: touch all keys in order (simulating end_session)
listener.on_l1_keys_accessed(keys)
# Eviction should be in reverse order: 1 first (least recently touched)
# because on_keys_touched processes them sequentially,
# so key 5 is the most recently touched
candidates = lru.get_eviction_candidates(5)
evicted_ids = [ObjectKey.Bytes2IntHash(c.chunk_hash) for c in candidates]
# LRU evicts from the back of the ordered dict (least recent first)
# After touching [1,2,3,4,5] in order, 1 is least recent, 5 is most recent
assert evicted_ids == [5, 4, 3, 2, 1]
def test_per_read_touch_vs_unified_touch_order(self):
"""Compare per-read touch (old behavior) vs unified touch (new behavior).
Old behavior: each retrieve touches its keys immediately.
New behavior: all keys are touched together at end_session.
With unified touch, the eviction order reflects the full request's
chunk ordering, not the order of individual retrieve calls.
"""
# Scenario: request has chunks [1,2,3,4,5]
# Retrieve touches [1,2,3] (prefix hit), Store creates [4,5]
lru = LRUEvictionPolicy()
keys = [make_key(i) for i in range(1, 6)]
lru.on_keys_created(keys)
# Unified touch at end_session: touch all in order
lru.on_keys_touched(keys)
candidates = lru.get_eviction_candidates(5)
evicted_ids = [ObjectKey.Bytes2IntHash(c.chunk_hash) for c in candidates]
# Last chunk (5) should be evicted last (most recently touched)
assert evicted_ids[-1] == 1
# =============================================================================
# End Session Touch Integration Tests
# =============================================================================
class TestEndSessionTouchKeys:
"""Tests for the end_session unified touch key generation logic.
These tests verify that end_session correctly:
1. Retrieves the session with accumulated hashes
2. Generates the correct ObjectKeys using lookup_ipc_key
3. Handles edge cases (no session, no lookup key, empty hashes)
"""
def test_end_session_generates_correct_keys(self, hasher: TokenHasher):
"""end_session should generate ObjectKeys from session hashes and lookup key."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
session = mgr.get_or_create("req-1")
tokens = list(range(12)) # 3 chunks of 4
session.set_tokens(tokens)
session.get_hashes(0, 12)
ipc_key = make_ipc_key(tokens, chunk_size=4, worker_id=None, request_id="req-1")
session.lookup_ipc_key = ipc_key
# Simulate end_session logic
removed = mgr.remove("req-1")
assert removed is not None
assert removed.lookup_ipc_key is not None
chunk_hashes = [TokenHasher.hash_to_bytes(h) for h in removed.get_hashes(0)]
obj_keys = ipc_key_to_object_keys(removed.lookup_ipc_key, chunk_hashes, [0])[0]
# With world_size=1 and worker_id=None, should have 3 keys
assert len(obj_keys) == 3
def test_end_session_expands_keys_for_world_size(self, hasher: TokenHasher):
"""end_session should expand keys for all workers when world_size > 1."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
session = mgr.get_or_create("req-1")
tokens = list(range(8)) # 2 chunks of 4
session.set_tokens(tokens)
session.get_hashes(0, 8)
ipc_key = make_ipc_key(
tokens,
chunk_size=4,
world_size=2,
worker_id=None,
request_id="req-1",
)
session.lookup_ipc_key = ipc_key
removed = mgr.remove("req-1")
assert removed is not None
assert removed.lookup_ipc_key is not None
chunk_hashes = [TokenHasher.hash_to_bytes(h) for h in removed.get_hashes(0)]
obj_keys = ipc_key_to_object_keys(removed.lookup_ipc_key, chunk_hashes, [0])[0]
# 2 chunks * 2 workers = 4 keys
assert len(obj_keys) == 4
def test_end_session_no_session_skips_touch(self, hasher: TokenHasher):
"""end_session should skip touch when session doesn't exist."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
removed = mgr.remove("nonexistent")
assert removed is None
# No error should occur
def test_end_session_no_lookup_key_skips_touch(self, hasher: TokenHasher):
"""end_session should skip touch when session has no lookup_ipc_key."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
session = mgr.get_or_create("req-1")
session.set_tokens(list(range(8)))
session.get_hashes(0, 8)
# Don't set lookup_ipc_key
removed = mgr.remove("req-1")
assert removed is not None
assert removed.lookup_ipc_key is None
# No error should occur
def test_end_session_empty_hashes_produces_no_keys(self, hasher: TokenHasher):
"""end_session should produce no keys when session has no computed hashes."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
session = mgr.get_or_create("req-1")
ipc_key = make_ipc_key(
list(range(8)), chunk_size=4, worker_id=None, request_id="req-1"
)
session.lookup_ipc_key = ipc_key
# Don't compute any hashes
removed = mgr.remove("req-1")
assert removed is not None
assert removed.lookup_ipc_key is not None
chunk_hashes = [TokenHasher.hash_to_bytes(h) for h in removed.get_hashes(0)]
obj_keys = ipc_key_to_object_keys(removed.lookup_ipc_key, chunk_hashes, [0])[0]
assert len(obj_keys) == 0
def test_end_session_hashes_cover_retrieve_and_store(self, hasher: TokenHasher):
"""Session hashes should cover both retrieve and store ranges.
Simulates the typical flow:
1. lookup computes all hashes (but via TokenHasher, not Session)
2. retrieve calls session.get_hashes(0, hit_end)
3. store calls session.get_hashes(hit_end, total)
4. end_session uses session.get_hashes(0) to get all
"""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
session = mgr.get_or_create("req-1")
tokens = list(range(20)) # 5 chunks of 4
session.set_tokens(tokens)
# Simulate retrieve: first 3 chunks
retrieve_hashes = session.get_hashes(0, 12)
assert len(retrieve_hashes) == 3
# Simulate store: last 2 chunks
store_hashes = session.get_hashes(12, 20)
assert len(store_hashes) == 2
# All hashes should cover all 5 chunks
all_hashes = session.get_hashes(0)
assert len(all_hashes) == 5
assert all_hashes == retrieve_hashes + store_hashes
# =============================================================================
# StoreListener No-op Tests
# =============================================================================
class TestStoreListenerAccessedNoop:
"""Tests that StoreListener.on_l1_keys_accessed is a no-op."""
def test_store_listener_accessed_is_noop(self):
"""StoreListener.on_l1_keys_accessed should not raise."""
# Import here to avoid import errors if store_controller has
# heavy dependencies
# First Party
from lmcache.v1.distributed.storage_controllers.store_controller import (
StoreListener,
)
# StoreListener requires an event_fd; use a mock
listener = MagicMock(spec=StoreListener)
# Call the real method on the class
StoreListener.on_l1_keys_accessed(listener, [make_key(1)])
# Should not raise - that's the test