Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/databases/query_cache.py

387 lines
12 KiB
Python

"""Query Cache Module.
Query result caching using standard library only.
Key Features:
- In-memory query result caching with TTL
- Query parameter and result validation
- Thread-safe operations
- Cache size limits and eviction policies
- Query normalization and deduplication
- Standard library only (no external dependencies)
Dependencies:
- threading (standard library)
- time (standard library)
- typing (standard library)
- hashlib (standard library)
- json (standard library)
"""
import threading
import time
from typing import Optional, Dict, Any, Tuple, List
from collections import OrderedDict
import hashlib
import json
class QueryCacheError(Exception):
"""Query cache operation error"""
class QueryCache:
"""Handle query result caching operations.
Provides caching of query results with validation, TTL expiration,
and configurable cache size limits.
"""
def __init__(
self,
max_size: int = 1000,
ttl_seconds: int = 3600
):
"""Initialize query cache.
Args:
max_size: Maximum number of entries in cache
ttl_seconds: Time-to-live in seconds for cache entries
"""
self.max_size = max_size
self.ttl_seconds = ttl_seconds
# Cache storage: query_key -> (result, timestamp)
self._cache: OrderedDict[str, Tuple[Any, float]] = OrderedDict()
self._lock = threading.RLock()
self._stats = {
'hits': 0,
'misses': 0,
'evictions': 0,
'insertions': 0
}
def get_result(self, query: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
"""Get cached result for a query.
Args:
query: Query string
params: Query parameters
Returns:
Cached result or None if not found/cached
"""
with self._lock:
query_key = self._generate_key(query, params)
if query_key not in self._cache:
self._stats['misses'] += 1
return None
result, timestamp = self._cache[query_key]
# Check if entry is expired
if time.monotonic() - timestamp > self.ttl_seconds:
del self._cache[query_key]
self._stats['misses'] += 1
return None
self._stats['hits'] += 1
return result
def set_result(self, query: str, params: Optional[Dict[str, Any]] = None, result: Any = None) -> None:
"""Set result for a query in cache.
Args:
query: Query string
params: Query parameters
result: Query result to cache
"""
with self._lock:
query_key = self._generate_key(query, params)
# Remove oldest entry if at max size
if len(self._cache) >= self.max_size:
self._cache.popitem(last=False)
self._stats['evictions'] += 1
# Store with current timestamp
timestamp = time.monotonic()
self._cache[query_key] = (result, timestamp)
# Move to end to mark as most recently used
self._cache.move_to_end(query_key, last=True)
self._stats['insertions'] += 1
def invalidate(self, query: str, params: Optional[Dict[str, Any]] = None) -> bool:
"""Invalidate cache entry for a query.
Args:
query: Query string
params: Query parameters
Returns:
True if entry was invalidated, False if not found
"""
with self._lock:
query_key = self._generate_key(query, params)
if query_key in self._cache:
del self._cache[query_key]
return True
return False
def invalidate_all(self) -> None:
"""Invalidate all cache entries."""
with self._lock:
# Count the number of entries being cleared
num_entries = len(self._cache)
self._cache.clear()
# Increment evictions by the number of entries that were cleared
self._stats['evictions'] += num_entries
def invalidate_by_prefix(self, prefix: str) -> int:
"""Invalidate all cache entries with a specific prefix.
Args:
prefix: Prefix to match
Returns:
Number of entries invalidated
"""
with self._lock:
keys_to_remove = []
for key in self._cache.keys():
if key.startswith(prefix):
keys_to_remove.append(key)
for key in keys_to_remove:
del self._cache[key]
self._stats['evictions'] += 1
return len(keys_to_remove)
def validate_cache(self) -> int:
"""Validate all cache entries and remove stale ones.
Returns:
Number of entries removed
"""
with self._lock:
removed_count = 0
current_time = time.monotonic()
# Collect keys to remove
keys_to_remove = []
for query_key, (result, timestamp) in self._cache.items():
# Check TTL expiration
if current_time - timestamp > self.ttl_seconds:
keys_to_remove.append(query_key)
# Remove stale entries
for query_key in keys_to_remove:
del self._cache[query_key]
removed_count += 1
return removed_count
def get_stats(self) -> Dict[str, Any]:
"""Get cache statistics.
Returns:
Dictionary of cache statistics
"""
with self._lock:
stats = self._stats.copy()
stats['size'] = len(self._cache)
stats['capacity'] = self.max_size
stats['hit_rate'] = (
stats['hits'] / (stats['hits'] + stats['misses'])
if (stats['hits'] + stats['misses']) > 0
else 0.0
)
return stats
def get_cache_size(self) -> int:
"""Get current cache size.
Returns:
Number of entries in cache
"""
with self._lock:
return len(self._cache)
def is_cached(self, query: str, params: Optional[Dict[str, Any]] = None) -> bool:
"""Check if a query result is cached and valid.
Args:
query: Query string
params: Query parameters
Returns:
True if query result is cached and valid
"""
return self.get_result(query, params) is not None
def cleanup_expired(self) -> int:
"""Remove expired entries from cache.
Returns:
Number of entries removed
"""
return self.validate_cache()
def resize(self, new_max_size: int) -> None:
"""Resize cache and evict excess entries if necessary.
Args:
new_max_size: New maximum cache size
"""
with self._lock:
self.max_size = new_max_size
# Remove excess entries from the beginning (LRU)
while len(self._cache) > self.max_size:
self._cache.popitem(last=False)
self._stats['evictions'] += 1
def get_memory_usage(self) -> int:
"""Get approximate memory usage of cache.
Returns:
Approximate memory usage in bytes
"""
with self._lock:
# Rough estimate: query_key string + result + timestamp + overhead
usage = 0
for query_key, (result, timestamp) in self._cache.items():
usage += len(query_key.encode('utf-8')) # Query key
# Estimate result size (this is a rough approximation)
try:
result_str = str(result)
usage += len(result_str.encode('utf-8'))
except:
usage += 100 # Default estimate if conversion fails
usage += 8 # Timestamp (float)
usage += 50 # Overhead per entry
return usage
def _generate_key(self, query: str, params: Optional[Dict[str, Any]] = None) -> str:
"""Generate a unique cache key for a query and parameters.
Args:
query: Query string
params: Query parameters
Returns:
Unique cache key
"""
# Normalize query (remove extra whitespace, convert to lowercase)
normalized_query = ' '.join(query.split()).lower()
# Create parameter string if params exist
if params:
# Sort parameters by key for consistency
sorted_params = sorted(params.items())
params_str = json.dumps(sorted_params, sort_keys=True, default=str)
# Create hash of parameters to keep key manageable
params_hash = hashlib.md5(params_str.encode()).hexdigest()
return f"{normalized_query}:{params_hash}"
else:
return f"{normalized_query}:none"
def clear_by_query_pattern(self, pattern: str) -> int:
"""Clear cache entries that match a query pattern.
Args:
pattern: Pattern to match in query strings
Returns:
Number of entries cleared
"""
with self._lock:
keys_to_remove = []
for key in self._cache.keys():
# Extract query part from key (before the last colon)
query_part = key.rsplit(':', 1)[0] if ':' in key else key
if pattern.lower() in query_part.lower():
keys_to_remove.append(key)
for key in keys_to_remove:
del self._cache[key]
self._stats['evictions'] += 1
return len(keys_to_remove)
def get_cached_queries(self) -> List[str]:
"""Get list of cached query patterns.
Returns:
List of cached query patterns
"""
with self._lock:
queries = []
for key in self._cache.keys():
query_part = key.rsplit(':', 1)[0] if ':' in key else key
if query_part not in queries:
queries.append(query_part)
return queries
def export_metrics_prometheus(
self,
prefix: str = "nodupe_query_cache_",
labels: Optional[Dict[str, str]] = None
) -> str:
"""Export cache metrics in Prometheus format.
Args:
prefix: Metric name prefix
labels: Optional additional labels to include
Returns:
Prometheus-format metrics string
"""
label_str = ""
if labels:
label_parts = [f'{k}="{v}"' for k, v in labels.items()]
label_str = "{" + ",".join(label_parts) + "}"
lines = [
f"# HELP {prefix}hits_total Total number of cache hits",
f"# TYPE {prefix}hits_total counter",
f"{prefix}hits_total{label_str} {self._stats['hits']}",
f"# HELP {prefix}misses_total Total number of cache misses",
f"# TYPE {prefix}misses_total counter",
f"{prefix}misses_total{label_str} {self._stats['misses']}",
f"# HELP {prefix}evictions_total Total number of cache evictions",
f"# TYPE {prefix}evictions_total counter",
f"{prefix}evictions_total{label_str} {self._stats['evictions']}",
f"# HELP {prefix}insertions_total Total number of cache insertions",
f"# TYPE {prefix}insertions_total counter",
f"{prefix}insertions_total{label_str} {self._stats['insertions']}",
f"# HELP {prefix}size Current cache size",
f"# TYPE {prefix}size gauge",
f"{prefix}size{label_str} {len(self._cache)}",
f"# HELP {prefix}max_size Maximum cache size",
f"# TYPE {prefix}max_size gauge",
f"{prefix}max_size{label_str} {self.max_size}",
]
return "\n".join(lines)
def create_query_cache(
max_size: int = 1000,
ttl_seconds: int = 3600
) -> QueryCache:
"""Create a query cache instance.
Args:
max_size: Maximum number of entries
ttl_seconds: Time-to-live in seconds
Returns:
QueryCache instance
"""
return QueryCache(max_size, ttl_seconds)