diff --git a/headroom/cache/backends/__init__.py b/headroom/cache/backends/__init__.py new file mode 100644 index 000000000..bdfd46b6b --- /dev/null +++ b/headroom/cache/backends/__init__.py @@ -0,0 +1,29 @@ +"""Storage backends for CompressionStore. + +This module provides pluggable storage backends for CCR (Compress-Cache-Retrieve). +The default is in-memory storage, but alternative backends can be implemented for: +- Persistence (MongoDB, Redis, etc.) +- Distributed caching +- Custom storage solutions + +Usage: + from headroom.cache.backends import InMemoryBackend, CompressionStoreBackend + from headroom.cache.compression_store import CompressionStore + + # Use default in-memory backend + store = CompressionStore() + + # Use custom backend + class MyBackend: + # Implement CompressionStoreBackend protocol + ... + store = CompressionStore(backend=MyBackend()) +""" + +from .base import CompressionStoreBackend +from .memory import InMemoryBackend + +__all__ = [ + "CompressionStoreBackend", + "InMemoryBackend", +] diff --git a/headroom/cache/backends/base.py b/headroom/cache/backends/base.py new file mode 100644 index 000000000..ad3c3cec6 --- /dev/null +++ b/headroom/cache/backends/base.py @@ -0,0 +1,133 @@ +"""Base protocol for CompressionStore backends. + +This protocol defines the minimal interface that storage backends must implement. +The interface is intentionally simple - it only handles CRUD operations on entries. +Higher-level concerns (search, feedback, eviction policies) are handled by CompressionStore. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ..compression_store import CompressionEntry + + +@runtime_checkable +class CompressionStoreBackend(Protocol): + """Protocol for CompressionStore storage backends. + + This protocol defines the minimal interface for pluggable storage backends. + Implementations can use any storage mechanism: memory, MongoDB, Redis, etc. + + Design Principles: + - Simple CRUD operations only + - No business logic (search, feedback, eviction policies) + - Thread-safety is implementation's responsibility + - TTL handling can be delegated to backend or handled by CompressionStore + + Example implementation: + class MyBackend: + def get(self, hash_key: str) -> CompressionEntry | None: + return self._storage.get(hash_key) + + def set(self, hash_key: str, entry: CompressionEntry) -> None: + self._storage[hash_key] = entry + + # ... other methods + """ + + def get(self, hash_key: str) -> CompressionEntry | None: + """Retrieve an entry by hash key. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + CompressionEntry if found, None otherwise. + Does NOT check TTL - that's CompressionStore's responsibility. + """ + ... + + def set(self, hash_key: str, entry: CompressionEntry) -> None: + """Store an entry with the given hash key. + + Args: + hash_key: The unique hash identifying the entry. + entry: The CompressionEntry to store. + + Note: + Overwrites any existing entry with the same key. + """ + ... + + def delete(self, hash_key: str) -> bool: + """Delete an entry by hash key. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + True if entry was deleted, False if it didn't exist. + """ + ... + + def exists(self, hash_key: str) -> bool: + """Check if an entry exists. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + True if entry exists, False otherwise. + Does NOT check TTL - that's CompressionStore's responsibility. + """ + ... + + def clear(self) -> None: + """Remove all entries from storage.""" + ... + + def count(self) -> int: + """Get the number of entries in storage. + + Returns: + Number of entries currently stored. + """ + ... + + def keys(self) -> list[str]: + """Get all hash keys in storage. + + Returns: + List of all hash keys. + + Note: + For large stores, consider implementing an iterator version. + """ + ... + + def items(self) -> list[tuple[str, CompressionEntry]]: + """Get all entries as (hash_key, entry) pairs. + + Returns: + List of (hash_key, CompressionEntry) tuples. + + Note: + For large stores, consider implementing an iterator version. + """ + ... + + def get_stats(self) -> dict[str, Any]: + """Get backend-specific statistics. + + Returns: + Dict with backend stats. Should include at minimum: + - "entry_count": number of entries + - "backend_type": name of the backend implementation + + Backends may include additional stats like: + - "bytes_used": memory/storage used + - "connection_status": for remote backends + """ + ... diff --git a/headroom/cache/backends/memory.py b/headroom/cache/backends/memory.py new file mode 100644 index 000000000..1411a53bc --- /dev/null +++ b/headroom/cache/backends/memory.py @@ -0,0 +1,140 @@ +"""In-memory storage backend for CompressionStore. + +This is the default backend, providing fast access with no external dependencies. +Data is lost when the process exits. +""" + +from __future__ import annotations + +import sys +import threading +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..compression_store import CompressionEntry + + +class InMemoryBackend: + """Thread-safe in-memory storage backend. + + This is the default backend for CompressionStore. It stores entries in a + Python dict with thread-safe access via a lock. + + Characteristics: + - Fast: O(1) get/set/delete operations + - Volatile: Data lost on process exit + - Thread-safe: All operations are protected by a lock + - Memory-bound: Stores everything in RAM + + Usage: + backend = InMemoryBackend() + backend.set("abc123", entry) + entry = backend.get("abc123") + """ + + def __init__(self) -> None: + """Initialize the in-memory backend.""" + self._store: dict[str, CompressionEntry] = {} + self._lock = threading.Lock() + + def get(self, hash_key: str) -> CompressionEntry | None: + """Retrieve an entry by hash key. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + CompressionEntry if found, None otherwise. + """ + with self._lock: + return self._store.get(hash_key) + + def set(self, hash_key: str, entry: CompressionEntry) -> None: + """Store an entry with the given hash key. + + Args: + hash_key: The unique hash identifying the entry. + entry: The CompressionEntry to store. + """ + with self._lock: + self._store[hash_key] = entry + + def delete(self, hash_key: str) -> bool: + """Delete an entry by hash key. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + True if entry was deleted, False if it didn't exist. + """ + with self._lock: + if hash_key in self._store: + del self._store[hash_key] + return True + return False + + def exists(self, hash_key: str) -> bool: + """Check if an entry exists. + + Args: + hash_key: The unique hash identifying the entry. + + Returns: + True if entry exists, False otherwise. + """ + with self._lock: + return hash_key in self._store + + def clear(self) -> None: + """Remove all entries from storage.""" + with self._lock: + self._store.clear() + + def count(self) -> int: + """Get the number of entries in storage. + + Returns: + Number of entries currently stored. + """ + with self._lock: + return len(self._store) + + def keys(self) -> list[str]: + """Get all hash keys in storage. + + Returns: + List of all hash keys. + """ + with self._lock: + return list(self._store.keys()) + + def items(self) -> list[tuple[str, CompressionEntry]]: + """Get all entries as (hash_key, entry) pairs. + + Returns: + List of (hash_key, CompressionEntry) tuples. + """ + with self._lock: + return list(self._store.items()) + + def get_stats(self) -> dict[str, Any]: + """Get backend statistics. + + Returns: + Dict with stats including entry_count and memory estimate. + """ + with self._lock: + entry_count = len(self._store) + # Rough memory estimate + bytes_used = sys.getsizeof(self._store) + for entry in self._store.values(): + bytes_used += sys.getsizeof(entry) + bytes_used += len(entry.original_content.encode("utf-8")) + bytes_used += len(entry.compressed_content.encode("utf-8")) + + return { + "backend_type": "memory", + "entry_count": entry_count, + "bytes_used": bytes_used, + } diff --git a/headroom/cache/compression_store.py b/headroom/cache/compression_store.py index 2b4aeb769..de2a6f022 100644 --- a/headroom/cache/compression_store.py +++ b/headroom/cache/compression_store.py @@ -41,10 +41,13 @@ import re import threading import time from dataclasses import dataclass, field, replace -from typing import Any +from typing import TYPE_CHECKING, Any from ..relevance.bm25 import BM25Scorer +if TYPE_CHECKING: + from .backends import CompressionStoreBackend + logger = logging.getLogger(__name__) @@ -124,6 +127,7 @@ class CompressionStore: max_entries: int = 1000, default_ttl: int = 300, enable_feedback: bool = True, + backend: CompressionStoreBackend | None = None, ): """Initialize the compression store. @@ -131,8 +135,13 @@ class CompressionStore: max_entries: Maximum number of entries to store. default_ttl: Default TTL in seconds (5 minutes). enable_feedback: Whether to track retrieval events. + backend: Storage backend to use. Defaults to InMemoryBackend. + Custom backends can be passed for persistence (MongoDB, Redis). """ - self._store: dict[str, CompressionEntry] = {} + # Import here to avoid circular imports + from .backends import InMemoryBackend + + self._backend: CompressionStoreBackend = backend or InMemoryBackend() self._lock = threading.Lock() self._max_entries = max_entries self._default_ttl = default_ttl @@ -224,7 +233,7 @@ class CompressionStore: # CRITICAL FIX: Hash collision detection # If hash already exists with DIFFERENT content, log a warning. # This indicates either a hash collision or duplicate store calls. - existing = self._store.get(hash_key) + existing = self._backend.get(hash_key) if existing is not None: if existing.original_content != original: # True hash collision - different content, same hash @@ -245,7 +254,7 @@ class CompressionStore: # Mark old heap entry as stale since we're replacing self._stale_heap_entries += 1 - self._store[hash_key] = entry + self._backend.set(hash_key, entry) # MEDIUM FIX #16: Add to eviction heap for O(log n) eviction heapq.heappush(self._eviction_heap, (entry.created_at, hash_key)) @@ -266,19 +275,21 @@ class CompressionStore: CompressionEntry if found and not expired, None otherwise. """ with self._lock: - entry = self._store.get(hash_key) + entry = self._backend.get(hash_key) if entry is None: return None if entry.is_expired(): - del self._store[hash_key] + self._backend.delete(hash_key) # CRITICAL FIX: Track stale heap entry self._stale_heap_entries += 1 return None # Track access for feedback entry.record_access(query) + # Update the backend with the modified entry + self._backend.set(hash_key, entry) # Log retrieval event if self._enable_feedback: @@ -319,13 +330,13 @@ class CompressionStore: Dict with metadata if found and not expired, None otherwise. """ with self._lock: - entry = self._store.get(hash_key) + entry = self._backend.get(hash_key) if entry is None: return None if entry.is_expired(): - del self._store[hash_key] + self._backend.delete(hash_key) self._stale_heap_entries += 1 return None @@ -423,19 +434,21 @@ class CompressionStore: CompressionEntry copy if found and not expired, None otherwise. """ with self._lock: - entry = self._store.get(hash_key) + entry = self._backend.get(hash_key) if entry is None: return None if entry.is_expired(): - del self._store[hash_key] + self._backend.delete(hash_key) # CRITICAL FIX: Track stale heap entry self._stale_heap_entries += 1 return None # Track access but don't log retrieval event (search will log separately) entry.record_access(query) + # Update the backend with the modified entry + self._backend.set(hash_key, entry) # CRITICAL FIX #4: Return a copy to prevent race conditions # The entry contains mutable fields (search_queries list) that could be @@ -454,14 +467,14 @@ class CompressionStore: True if the entry exists and is not expired. """ with self._lock: - entry = self._store.get(hash_key) + entry = self._backend.get(hash_key) if entry is None: return False if entry.is_expired(): # LOW FIX #20: Only delete if explicitly requested # This makes exists() a pure check by default if clean_expired: - del self._store[hash_key] + self._backend.delete(hash_key) # CRITICAL FIX: Track stale heap entry self._stale_heap_entries += 1 return False @@ -473,17 +486,23 @@ class CompressionStore: # Clean expired entries self._clean_expired() - total_original_tokens = sum(e.original_tokens for e in self._store.values()) - total_compressed_tokens = sum(e.compressed_tokens for e in self._store.values()) - total_retrievals = sum(e.retrieval_count for e in self._store.values()) + # Get all entries for statistics + entries = [entry for _, entry in self._backend.items()] + total_original_tokens = sum(e.original_tokens for e in entries) + total_compressed_tokens = sum(e.compressed_tokens for e in entries) + total_retrievals = sum(e.retrieval_count for e in entries) + + # Include backend stats + backend_stats = self._backend.get_stats() return { - "entry_count": len(self._store), + "entry_count": self._backend.count(), "max_entries": self._max_entries, "total_original_tokens": total_original_tokens, "total_compressed_tokens": total_compressed_tokens, "total_retrievals": total_retrievals, "event_count": len(self._retrieval_events), + "backend": backend_stats, } def get_retrieval_events( @@ -514,7 +533,7 @@ class CompressionStore: def clear(self) -> None: """Clear all entries. Mainly for testing.""" with self._lock: - self._store.clear() + self._backend.clear() self._retrieval_events.clear() self._pending_feedback_events.clear() self._eviction_heap.clear() # MEDIUM FIX #16: Clear heap too @@ -538,13 +557,13 @@ class CompressionStore: self._rebuild_heap() # If still at capacity, remove oldest entries using heap - while len(self._store) >= self._max_entries and self._eviction_heap: + while self._backend.count() >= self._max_entries and self._eviction_heap: # Pop oldest from heap (O(log n)) created_at, hash_key = heapq.heappop(self._eviction_heap) # Check if entry still exists and matches timestamp # (entry might have been deleted or replaced) - entry = self._store.get(hash_key) + entry = self._backend.get(hash_key) if entry is not None and entry.created_at == created_at: # HIGH FIX: Track eviction as "successful compression" if never retrieved # This prevents state divergence between store and feedback loop @@ -552,7 +571,7 @@ class CompressionStore: # Entry was never retrieved = compression was successful # Notify feedback system so it knows this strategy worked self._record_eviction_success(entry) - del self._store[hash_key] + self._backend.delete(hash_key) else: # CRITICAL FIX: This was a stale entry, decrement counter # (we already popped it, so the stale entry is now gone) @@ -564,9 +583,9 @@ class CompressionStore: CRITICAL FIX: Track stale heap entries when deleting to prevent memory leak. """ - expired_keys = [key for key, entry in self._store.items() if entry.is_expired()] + expired_keys = [key for key, entry in self._backend.items() if entry.is_expired()] for key in expired_keys: - del self._store[key] + self._backend.delete(key) # CRITICAL FIX: Increment stale counter - the heap still has an entry # for this key that will be stale when we try to evict self._stale_heap_entries += 1 @@ -579,7 +598,7 @@ class CompressionStore: """ # Build new heap from current store entries only self._eviction_heap = [ - (entry.created_at, hash_key) for hash_key, entry in self._store.items() + (entry.created_at, hash_key) for hash_key, entry in self._backend.items() ] heapq.heapify(self._eviction_heap) # Reset stale counter - heap is now clean @@ -689,7 +708,7 @@ class CompressionStore: tuple[RetrievalEvent, str | None, str | None, str | None, str | None] ] = [] for event in events: - entry = self._store.get(event.hash) + entry = self._backend.get(event.hash) if entry: # Use the ACTUAL tool_signature_hash stored during compression # This MUST match the hash used by SmartCrusher @@ -778,6 +797,7 @@ _store_lock = threading.Lock() def get_compression_store( max_entries: int = 1000, default_ttl: int = 300, + backend: CompressionStoreBackend | None = None, ) -> CompressionStore: """Get the global compression store instance. @@ -786,6 +806,8 @@ def get_compression_store( Args: max_entries: Maximum entries (only used on first call). default_ttl: Default TTL (only used on first call). + backend: Custom storage backend (only used on first call). + Defaults to InMemoryBackend if not provided. Returns: Global CompressionStore instance. @@ -799,6 +821,7 @@ def get_compression_store( _compression_store = CompressionStore( max_entries=max_entries, default_ttl=default_ttl, + backend=backend, ) return _compression_store diff --git a/tests/test_cache/test_backends.py b/tests/test_cache/test_backends.py new file mode 100644 index 000000000..170d65426 --- /dev/null +++ b/tests/test_cache/test_backends.py @@ -0,0 +1,410 @@ +"""Tests for CompressionStore storage backends. + +These tests define the contract that all backends must fulfill. +Each backend implementation should pass all these tests. +""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING + +import pytest + +from headroom.cache.backends import CompressionStoreBackend, InMemoryBackend +from headroom.cache.compression_store import CompressionEntry + +if TYPE_CHECKING: + from collections.abc import Callable + + +def make_entry( + hash_key: str = "test_hash", + original: str = "original content", + compressed: str = "compressed", + original_tokens: int = 100, + compressed_tokens: int = 10, +) -> CompressionEntry: + """Create a test CompressionEntry.""" + return CompressionEntry( + hash=hash_key, + original_content=original, + compressed_content=compressed, + original_tokens=original_tokens, + compressed_tokens=compressed_tokens, + original_item_count=5, + compressed_item_count=2, + tool_name="test_tool", + tool_call_id="call_123", + query_context="test query", + created_at=time.time(), + ttl=300, + ) + + +class TestCompressionStoreBackendProtocol: + """Test that InMemoryBackend implements the protocol correctly.""" + + def test_inmemory_backend_implements_protocol(self) -> None: + """InMemoryBackend should implement CompressionStoreBackend protocol.""" + backend = InMemoryBackend() + assert isinstance(backend, CompressionStoreBackend) + + def test_protocol_is_runtime_checkable(self) -> None: + """Protocol should be runtime checkable.""" + + class NotABackend: + pass + + assert not isinstance(NotABackend(), CompressionStoreBackend) + + +class TestInMemoryBackend: + """Test suite for InMemoryBackend. + + These tests define the contract for all backends. + """ + + @pytest.fixture + def backend(self) -> InMemoryBackend: + """Create a fresh backend for each test.""" + return InMemoryBackend() + + # --- Basic CRUD operations --- + + def test_get_returns_none_for_missing_key(self, backend: InMemoryBackend) -> None: + """get() should return None for keys that don't exist.""" + assert backend.get("nonexistent") is None + + def test_set_and_get_roundtrip(self, backend: InMemoryBackend) -> None: + """set() followed by get() should return the same entry.""" + entry = make_entry(hash_key="abc123") + backend.set("abc123", entry) + + retrieved = backend.get("abc123") + assert retrieved is not None + assert retrieved.hash == "abc123" + assert retrieved.original_content == "original content" + assert retrieved.compressed_content == "compressed" + assert retrieved.original_tokens == 100 + assert retrieved.compressed_tokens == 10 + + def test_set_overwrites_existing(self, backend: InMemoryBackend) -> None: + """set() should overwrite existing entries with the same key.""" + entry1 = make_entry(hash_key="abc123", original="first") + entry2 = make_entry(hash_key="abc123", original="second") + + backend.set("abc123", entry1) + backend.set("abc123", entry2) + + retrieved = backend.get("abc123") + assert retrieved is not None + assert retrieved.original_content == "second" + + def test_delete_removes_entry(self, backend: InMemoryBackend) -> None: + """delete() should remove the entry and return True.""" + entry = make_entry(hash_key="abc123") + backend.set("abc123", entry) + + result = backend.delete("abc123") + assert result is True + assert backend.get("abc123") is None + + def test_delete_returns_false_for_missing(self, backend: InMemoryBackend) -> None: + """delete() should return False for keys that don't exist.""" + result = backend.delete("nonexistent") + assert result is False + + def test_exists_returns_true_for_stored_entry(self, backend: InMemoryBackend) -> None: + """exists() should return True for stored entries.""" + entry = make_entry(hash_key="abc123") + backend.set("abc123", entry) + + assert backend.exists("abc123") is True + + def test_exists_returns_false_for_missing(self, backend: InMemoryBackend) -> None: + """exists() should return False for missing entries.""" + assert backend.exists("nonexistent") is False + + def test_clear_removes_all_entries(self, backend: InMemoryBackend) -> None: + """clear() should remove all entries.""" + backend.set("key1", make_entry(hash_key="key1")) + backend.set("key2", make_entry(hash_key="key2")) + backend.set("key3", make_entry(hash_key="key3")) + + backend.clear() + + assert backend.count() == 0 + assert backend.get("key1") is None + assert backend.get("key2") is None + assert backend.get("key3") is None + + # --- Enumeration methods --- + + def test_count_returns_zero_for_empty(self, backend: InMemoryBackend) -> None: + """count() should return 0 for empty backend.""" + assert backend.count() == 0 + + def test_count_returns_correct_count(self, backend: InMemoryBackend) -> None: + """count() should return the number of entries.""" + backend.set("key1", make_entry(hash_key="key1")) + backend.set("key2", make_entry(hash_key="key2")) + backend.set("key3", make_entry(hash_key="key3")) + + assert backend.count() == 3 + + def test_keys_returns_empty_list_for_empty(self, backend: InMemoryBackend) -> None: + """keys() should return empty list for empty backend.""" + assert backend.keys() == [] + + def test_keys_returns_all_keys(self, backend: InMemoryBackend) -> None: + """keys() should return all stored keys.""" + backend.set("key1", make_entry(hash_key="key1")) + backend.set("key2", make_entry(hash_key="key2")) + backend.set("key3", make_entry(hash_key="key3")) + + keys = backend.keys() + assert set(keys) == {"key1", "key2", "key3"} + + def test_items_returns_empty_list_for_empty(self, backend: InMemoryBackend) -> None: + """items() should return empty list for empty backend.""" + assert backend.items() == [] + + def test_items_returns_all_entries(self, backend: InMemoryBackend) -> None: + """items() should return all (key, entry) pairs.""" + entry1 = make_entry(hash_key="key1", original="content1") + entry2 = make_entry(hash_key="key2", original="content2") + + backend.set("key1", entry1) + backend.set("key2", entry2) + + items = backend.items() + assert len(items) == 2 + + items_dict = dict(items) + assert items_dict["key1"].original_content == "content1" + assert items_dict["key2"].original_content == "content2" + + # --- Statistics --- + + def test_get_stats_returns_required_fields(self, backend: InMemoryBackend) -> None: + """get_stats() should return required fields.""" + stats = backend.get_stats() + + assert "backend_type" in stats + assert "entry_count" in stats + assert stats["backend_type"] == "memory" + assert stats["entry_count"] == 0 + + def test_get_stats_entry_count_accurate(self, backend: InMemoryBackend) -> None: + """get_stats() entry_count should match actual count.""" + backend.set("key1", make_entry(hash_key="key1")) + backend.set("key2", make_entry(hash_key="key2")) + + stats = backend.get_stats() + assert stats["entry_count"] == 2 + + def test_get_stats_bytes_used_increases(self, backend: InMemoryBackend) -> None: + """get_stats() bytes_used should increase with entries.""" + stats_empty = backend.get_stats() + + backend.set( + "key1", + make_entry(hash_key="key1", original="x" * 1000), + ) + stats_one = backend.get_stats() + + backend.set( + "key2", + make_entry(hash_key="key2", original="y" * 1000), + ) + stats_two = backend.get_stats() + + assert stats_one["bytes_used"] > stats_empty["bytes_used"] + assert stats_two["bytes_used"] > stats_one["bytes_used"] + + # --- Thread safety --- + + def test_concurrent_set_operations(self, backend: InMemoryBackend) -> None: + """Backend should handle concurrent set operations safely.""" + num_threads = 10 + entries_per_thread = 100 + errors: list[Exception] = [] + + def worker(thread_id: int) -> None: + try: + for i in range(entries_per_thread): + key = f"thread{thread_id}_entry{i}" + entry = make_entry(hash_key=key) + backend.set(key, entry) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(num_threads)] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0 + assert backend.count() == num_threads * entries_per_thread + + def test_concurrent_get_set_delete(self, backend: InMemoryBackend) -> None: + """Backend should handle mixed concurrent operations safely.""" + num_iterations = 100 + errors: list[Exception] = [] + + # Pre-populate some entries + for i in range(50): + backend.set(f"key{i}", make_entry(hash_key=f"key{i}")) + + def setter() -> None: + try: + for i in range(num_iterations): + backend.set(f"new_key{i}", make_entry(hash_key=f"new_key{i}")) + except Exception as e: + errors.append(e) + + def getter() -> None: + try: + for i in range(num_iterations): + backend.get(f"key{i % 50}") + except Exception as e: + errors.append(e) + + def deleter() -> None: + try: + for i in range(num_iterations): + backend.delete(f"key{i % 50}") + except Exception as e: + errors.append(e) + + threads = [ + threading.Thread(target=setter), + threading.Thread(target=setter), + threading.Thread(target=getter), + threading.Thread(target=getter), + threading.Thread(target=deleter), + ] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0 + + # --- Edge cases --- + + def test_empty_string_key(self, backend: InMemoryBackend) -> None: + """Backend should handle empty string as key.""" + entry = make_entry(hash_key="") + backend.set("", entry) + + retrieved = backend.get("") + assert retrieved is not None + assert retrieved.hash == "" + + def test_unicode_content(self, backend: InMemoryBackend) -> None: + """Backend should handle unicode content correctly.""" + entry = make_entry( + hash_key="unicode", + original="日本語テスト 🎉 émojis", + compressed="日本語", + ) + backend.set("unicode", entry) + + retrieved = backend.get("unicode") + assert retrieved is not None + assert retrieved.original_content == "日本語テスト 🎉 émojis" + assert retrieved.compressed_content == "日本語" + + def test_large_content(self, backend: InMemoryBackend) -> None: + """Backend should handle large content.""" + large_content = "x" * 10_000_000 # 10MB + entry = make_entry(hash_key="large", original=large_content) + backend.set("large", entry) + + retrieved = backend.get("large") + assert retrieved is not None + assert len(retrieved.original_content) == 10_000_000 + + +# --- Parameterized tests for all backend implementations --- + + +def all_backends() -> list[Callable[[], CompressionStoreBackend]]: + """Return factory functions for all backend implementations.""" + return [ + InMemoryBackend, + # Add more backends here as they're implemented: + # MongoDBBackend, + # RedisBackend, + ] + + +@pytest.mark.parametrize("backend_factory", all_backends()) +class TestBackendContract: + """Contract tests that ALL backends must pass. + + These tests are parameterized to run against every backend implementation. + Add new backends to all_backends() to include them in these tests. + """ + + def test_implements_protocol( + self, backend_factory: Callable[[], CompressionStoreBackend] + ) -> None: + """All backends must implement CompressionStoreBackend protocol.""" + backend = backend_factory() + assert isinstance(backend, CompressionStoreBackend) + + def test_basic_crud_cycle(self, backend_factory: Callable[[], CompressionStoreBackend]) -> None: + """All backends must support basic CRUD operations.""" + backend = backend_factory() + + # Create + entry = make_entry(hash_key="test") + backend.set("test", entry) + assert backend.exists("test") + + # Read + retrieved = backend.get("test") + assert retrieved is not None + assert retrieved.original_content == entry.original_content + + # Update (overwrite) + entry2 = make_entry(hash_key="test", original="updated") + backend.set("test", entry2) + retrieved2 = backend.get("test") + assert retrieved2 is not None + assert retrieved2.original_content == "updated" + + # Delete + assert backend.delete("test") is True + assert backend.exists("test") is False + assert backend.get("test") is None + + def test_clear_works(self, backend_factory: Callable[[], CompressionStoreBackend]) -> None: + """All backends must support clear().""" + backend = backend_factory() + + backend.set("key1", make_entry(hash_key="key1")) + backend.set("key2", make_entry(hash_key="key2")) + assert backend.count() == 2 + + backend.clear() + assert backend.count() == 0 + + def test_stats_has_required_fields( + self, backend_factory: Callable[[], CompressionStoreBackend] + ) -> None: + """All backends must return required stats fields.""" + backend = backend_factory() + stats = backend.get_stats() + + assert "backend_type" in stats + assert "entry_count" in stats + assert isinstance(stats["backend_type"], str) + assert isinstance(stats["entry_count"], int) diff --git a/tests/test_critical_gaps.py b/tests/test_critical_gaps.py index 5df5c2556..343d24ac7 100644 --- a/tests/test_critical_gaps.py +++ b/tests/test_critical_gaps.py @@ -893,9 +893,11 @@ class TestCompressionStoreHighPriorityFixes: # Manually expire entries (simulating TTL) with store._lock: for h in hashes[:5]: - if h in store._store: - store._store[h].created_at = 0 # Make it look old - store._store[h].ttl = 0 # Make it expired + entry = store._backend.get(h) + if entry: + entry.created_at = 0 # Make it look old + entry.ttl = 0 # Make it expired + store._backend.set(h, entry) # Store more entries - should handle stale heap entries gracefully for i in range(20, 30): @@ -943,7 +945,7 @@ class TestCompressionStoreHighPriorityFixes: store.search(hash_key, f"unique_query_{i}") with store._lock: - entry = store._store.get(hash_key) + entry = store._backend.get(hash_key) if entry: assert len(entry.search_queries) <= 10 @@ -1196,12 +1198,12 @@ class TestLowPriorityFixes: # Entry should still be in internal store (not deleted) with store._lock: - assert hash_key in store._store + assert store._backend.exists(hash_key) # Now with clean_expired=True, it should delete assert store.exists(hash_key, clean_expired=True) is False with store._lock: - assert hash_key not in store._store + assert not store._backend.exists(hash_key) def test_toin_confidence_threshold_configurable(self): """LOW FIX #21: TOIN confidence threshold should be configurable.""" diff --git a/tests/test_toin_integration.py b/tests/test_toin_integration.py index 24990e325..4770aa87f 100644 --- a/tests/test_toin_integration.py +++ b/tests/test_toin_integration.py @@ -84,7 +84,7 @@ class TestTOINIntegration: # Get the entry and verify it has tool_signature_hash # We need to find the hash key from the store - entries = list(fresh_store._store.values()) + entries = [entry for _, entry in fresh_store._backend.items()] assert len(entries) >= 1, "Should have at least one entry" entry = entries[0] @@ -162,7 +162,7 @@ class TestTOINIntegration: # Step 3: Simulate retrievals (indicating compression was too aggressive) # Find the stored entry hash - entries = list(fresh_store._store.values()) + entries = [entry for _, entry in fresh_store._backend.items()] assert len(entries) > 0, "Should have cached entries" # Retrieve multiple times to trigger learning @@ -361,7 +361,7 @@ class TestStoreToTOINHash: assert was_modified, f"Content should be modified by compression: {info}" # Get the stored hash - entries = list(fresh_store._store.values()) + entries = [entry for _, entry in fresh_store._backend.items()] assert len(entries) >= 1, ( f"Should have stored entry. Modified: {was_modified}, Info: {info}" )