mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Add pluggable storage backend abstraction for CompressionStore
- Add CompressionStoreBackend protocol for duck-typed backends - Add InMemoryBackend as default thread-safe implementation - Refactor CompressionStore to accept optional backend parameter - Add comprehensive backend contract tests (28 tests)
This commit is contained in:
parent
029ca7d7e8
commit
3ffe68618a
7 changed files with 770 additions and 33 deletions
29
headroom/cache/backends/__init__.py
vendored
Normal file
29
headroom/cache/backends/__init__.py
vendored
Normal file
|
|
@ -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",
|
||||
]
|
||||
133
headroom/cache/backends/base.py
vendored
Normal file
133
headroom/cache/backends/base.py
vendored
Normal file
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
140
headroom/cache/backends/memory.py
vendored
Normal file
140
headroom/cache/backends/memory.py
vendored
Normal file
|
|
@ -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,
|
||||
}
|
||||
71
headroom/cache/compression_store.py
vendored
71
headroom/cache/compression_store.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
410
tests/test_cache/test_backends.py
Normal file
410
tests/test_cache/test_backends.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue