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

360 lines
14 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for Session and SessionManager."""
# Standard
import threading
import time
# Third Party
import pytest
# First Party
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
from lmcache.v1.multiprocess.session import Session, SessionManager
from lmcache.v1.multiprocess.token_hasher import TokenHasher
from lmcache.v1.periodic_thread import PeriodicThreadRegistry
@pytest.fixture
def hasher() -> TokenHasher:
"""TokenHasher with small chunk_size for testing."""
return TokenHasher(chunk_size=4, hash_algorithm="blake3")
@pytest.fixture
def session(hasher: TokenHasher) -> Session:
"""A fresh Session instance."""
return Session(request_id="req-1", hasher=hasher)
@pytest.fixture
def session_manager(hasher: TokenHasher) -> SessionManager:
"""A SessionManager with short TTL for testing expiry."""
return SessionManager(hasher, ttl=0.1, cleanup_interval=None)
class TestSession:
def test_set_tokens_replaces(self, session: Session) -> None:
session.set_tokens([1, 2, 3])
assert session.token_ids == [1, 2, 3]
session.set_tokens([4, 5, 6])
assert session.token_ids == [4, 5, 6]
def test_get_hashes_basic(self, session: Session) -> None:
"""8 tokens, chunk_size=4 produces 2 hashes."""
session.set_tokens(list(range(8)))
hashes = session.get_hashes(0, 8)
assert len(hashes) == 2
def test_get_hashes_incremental(self, session: Session) -> None:
"""Calling get_hashes incrementally should produce same results."""
tokens = list(range(12))
session.set_tokens(tokens)
# First call: compute chunks 0-1
h_first = session.get_hashes(0, 8)
assert len(h_first) == 2
assert session.num_chunks_processed == 2
# Second call: compute chunk 2 (chunks 0-1 already cached)
h_second = session.get_hashes(8, 12)
assert len(h_second) == 1
assert session.num_chunks_processed == 3
# Full range should match
h_all = session.get_hashes(0, 12)
assert h_all == h_first + h_second
def test_get_hashes_idempotent(self, session: Session) -> None:
"""Calling get_hashes twice with same range returns same result."""
session.set_tokens(list(range(8)))
h1 = session.get_hashes(0, 8)
h2 = session.get_hashes(0, 8)
assert h1 == h2
assert session.num_chunks_processed == 2 # not recomputed
def test_get_hashes_matches_hasher(
self, hasher: TokenHasher, session: Session
) -> None:
"""Session hashes should match standalone TokenHasher hashes."""
tokens = list(range(8))
session.set_tokens(tokens)
session_hashes = session.get_hashes(0, 8)
hasher_hashes = hasher.compute_chunk_hashes(tokens)
assert session_hashes == hasher_hashes
def test_rolling_state_across_calls(self, session: Session) -> None:
"""Simulates the lookup then store flow: lookup hashes all, store
hashes a subrange. The rolling state from lookup should be reused."""
tokens = list(range(12))
session.set_tokens(tokens)
# Lookup: hash everything
all_hashes = session.get_hashes(0, 12)
assert len(all_hashes) == 3
# Store: request hashes for chunk 1-2 (tokens 4:12)
# Should reuse already-computed hashes
store_hashes = session.get_hashes(4, 12)
assert store_hashes == all_hashes[1:]
assert session.num_chunks_processed == 3 # no extra computation
class TestSessionManager:
def test_get_or_create_new(self, session_manager: SessionManager) -> None:
s = session_manager.get_or_create("req-1")
assert s.request_id == "req-1"
def test_get_or_create_existing(self, session_manager: SessionManager) -> None:
s1 = session_manager.get_or_create("req-1")
s2 = session_manager.get_or_create("req-1")
assert s1 is s2
def test_get_or_create_different_ids(self, session_manager: SessionManager) -> None:
s1 = session_manager.get_or_create("req-1")
s2 = session_manager.get_or_create("req-2")
assert s1 is not s2
def test_remove(self, session_manager: SessionManager) -> None:
session_manager.get_or_create("req-1")
removed = session_manager.remove("req-1")
assert removed is not None
assert removed.request_id == "req-1"
# Should create a fresh session
s = session_manager.get_or_create("req-1")
assert s.num_chunks_processed == 0
def test_remove_nonexistent(self, session_manager: SessionManager) -> None:
"""Removing a non-existent session should return None."""
result = session_manager.remove("does-not-exist")
assert result is None
def test_cleanup_expired(self, session_manager: SessionManager) -> None:
"""Sessions older than TTL should be cleaned up."""
session_manager.get_or_create("req-1")
session_manager.get_or_create("req-2")
# Wait for TTL to expire (ttl=0.1s)
time.sleep(0.15)
removed = session_manager.cleanup_expired()
assert removed == 2
# New session should be fresh
s = session_manager.get_or_create("req-1")
assert s.num_chunks_processed == 0
def test_cleanup_keeps_fresh(self, session_manager: SessionManager) -> None:
"""Fresh sessions should not be cleaned up."""
session_manager.get_or_create("req-1")
removed = session_manager.cleanup_expired()
assert removed == 0
def test_periodic_cleanup_removes_expired_sessions(
self, hasher: TokenHasher
) -> None:
"""Background cleanup should remove expired sessions automatically."""
PeriodicThreadRegistry.reset()
manager = SessionManager(hasher, ttl=0.05, cleanup_interval=0.01)
try:
manager.get_or_create("req-1")
manager.get_or_create("req-2")
threads = PeriodicThreadRegistry.get_instance().get_all()
assert len(threads) == 1
assert threads[0].is_running
deadline = time.time() + 1.0
while manager.active_count() != 0 and time.time() < deadline:
time.sleep(0.01)
assert manager.active_count() == 0
finally:
manager.close()
assert PeriodicThreadRegistry.get_instance().get_all() == []
PeriodicThreadRegistry.reset()
class TestSessionThreadSafety:
"""Verify Session is safe under concurrent access
from multiple TP worker threads."""
def test_concurrent_get_hashes(self, hasher: TokenHasher) -> None:
"""Multiple threads calling get_hashes on the same Session
must produce correct hashes (no duplicates, no corruption).
"""
session = Session(request_id="req-mt", hasher=hasher)
tokens = list(range(20)) # 5 chunks of 4
session.set_tokens(tokens)
# Reference hashes computed single-threaded
expected = hasher.compute_chunk_hashes(tokens)
errors: list[str] = []
barrier = threading.Barrier(8)
def worker(tid: int) -> None:
try:
barrier.wait(timeout=5)
hashes = session.get_hashes(0, 20)
if hashes != expected:
errors.append(
"Thread %d: got %r, expected %r" % (tid, hashes, expected)
)
except Exception as exc:
errors.append("Thread %d: %s" % (tid, exc))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors, "\n".join(errors)
assert session.num_chunks_processed == 5
def test_concurrent_set_and_get(self, hasher: TokenHasher) -> None:
"""set_tokens and get_hashes called concurrently must not
corrupt internal state."""
session = Session(request_id="req-mt2", hasher=hasher)
tokens = list(range(8)) # 2 chunks
session.set_tokens(tokens)
expected = hasher.compute_chunk_hashes(tokens)
errors: list[str] = []
barrier = threading.Barrier(4)
def reader(tid: int) -> None:
try:
barrier.wait(timeout=5)
hashes = session.get_hashes(0, 8)
if hashes != expected:
errors.append("Reader %d: mismatch" % tid)
except Exception as exc:
errors.append("Reader %d: %s" % (tid, exc))
def writer() -> None:
try:
barrier.wait(timeout=5)
# Re-set same tokens (idempotent)
session.set_tokens(tokens)
except Exception as exc:
errors.append("Writer: %s" % exc)
threads = [threading.Thread(target=reader, args=(i,)) for i in range(3)]
threads.append(threading.Thread(target=writer))
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors, "\n".join(errors)
class TestSessionGetHashesOptionalEnd:
"""
Tests for Session.get_hashes with optional end (auto-align to last full chunk).
"""
def test_get_hashes_optional_end_empty(self, session: Session) -> None:
"""get_hashes(0) should return empty list when no tokens set."""
assert session.get_hashes(0) == []
def test_get_hashes_optional_end_after_compute(self, session: Session) -> None:
"""get_hashes(0) should return all computed hashes."""
session.set_tokens(list(range(12))) # 3 chunks of 4
all_hashes = session.get_hashes(0)
assert len(all_hashes) == 3
def test_get_hashes_optional_end_incremental(self, session: Session) -> None:
"""get_hashes(0) should accumulate hashes from incremental calls."""
session.set_tokens(list(range(12))) # 3 chunks of 4
# First compute 2 chunks
session.get_hashes(0, 8)
# get_hashes(0) should now compute all 3 chunks
assert len(session.get_hashes(0)) == 3
def test_get_hashes_optional_end_matches_explicit(self, session: Session) -> None:
"""get_hashes(0) should return the same as get_hashes(0, aligned_len)."""
session.set_tokens(list(range(12)))
expected = session.get_hashes(0, 12)
assert session.get_hashes(0) == expected
def test_get_hashes_optional_end_truncates_partial_chunk(
self, session: Session
) -> None:
"""get_hashes(0) should ignore trailing tokens that don't fill a chunk."""
# 14 tokens with chunk_size=4 -> 3 full chunks (12 tokens), 2 leftover
session.set_tokens(list(range(14)))
hashes = session.get_hashes(0)
assert len(hashes) == 3
def test_get_hashes_optional_end_matches_hasher(
self, hasher: TokenHasher, session: Session
) -> None:
"""get_hashes(0) should match standalone TokenHasher results."""
tokens = list(range(12))
session.set_tokens(tokens)
session_hashes = session.get_hashes(0)
hasher_hashes = hasher.compute_chunk_hashes(tokens)
# Convert session hashes to bytes for comparison
converted = [TokenHasher.hash_to_bytes(h) for h in session_hashes]
assert converted == hasher_hashes
class TestSessionLookupIpcKey:
"""Tests for Session.lookup_ipc_key field."""
def test_lookup_ipc_key_default_none(self, session: Session) -> None:
"""lookup_ipc_key should default to None."""
assert session.lookup_ipc_key is None
def test_lookup_ipc_key_set_and_get(self, session: Session) -> None:
"""lookup_ipc_key should be settable and retrievable."""
key = IPCCacheServerKey.from_token_ids(
model_name="test-model",
world_size=1,
worker_id=None,
token_ids=list(range(8)),
start=0,
end=8,
request_id="req-1",
)
session.lookup_ipc_key = key
assert session.lookup_ipc_key is key
assert session.lookup_ipc_key.model_name == "test-model"
assert session.lookup_ipc_key.worker_id is None
class TestSessionManagerRemoveReturnsSession:
"""Tests for SessionManager.remove returning the removed session."""
def test_remove_returns_session_with_state(self, hasher: TokenHasher) -> None:
"""remove() should return the session with all accumulated state."""
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)
key = IPCCacheServerKey.from_token_ids(
model_name="model",
world_size=1,
worker_id=None,
token_ids=list(range(8)),
request_id="req-1",
)
session.lookup_ipc_key = key
removed = mgr.remove("req-1")
assert removed is not None
assert removed.request_id == "req-1"
assert len(removed.get_hashes(0)) == 2
assert removed.lookup_ipc_key is key
def test_remove_clears_from_manager(self, hasher: TokenHasher) -> None:
"""After remove(), the session should no longer be in the manager."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
mgr.get_or_create("req-1")
mgr.remove("req-1")
assert mgr.active_count() == 0
def test_remove_nonexistent_returns_none(self, hasher: TokenHasher) -> None:
"""remove() on a non-existent request_id should return None."""
mgr = SessionManager(hasher, ttl=600, cleanup_interval=None)
assert mgr.remove("no-such-id") is None