mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 22:58:06 +00:00
CacheManager.set(key, data, ttl=...) stored the number and no read path
ever consulted it. Expiry came from a max_age inferred from substrings
in the key -- "live", "odds", "stock" -- so all 52 callers passing a ttl
were writing a value that did nothing. The docstring said so outright:
"stored for compatibility but expiration is still controlled via max_age
when reading". It is easier to read that as a note than as a defect,
which is presumably how it survived.
Both cache layers already hold the record when they decide, so each now
prefers an explicit ttl and falls back to max_age when there is none.
The caller that wrote the record knows what its data is; a substring
guess is a reasonable default for records that never said, and a poor
override for records that did.
Measured against a device's real cache of 8,875 entries carrying a ttl,
the inferred and intended values disagreed nearly everywhere:
stocks max_age 600 vs ttl 1800 4903 entries
news max_age 3600 vs ttl 600 1770 entries
odds max_age 1800 vs ttl 3600 1301 entries
images max_age 300 vs ttl 2592000 20 entries
In every case the ttl matches what the plugin plainly intended: stock
quotes cached for half an hour rather than ten minutes, headlines
refreshed every ten minutes rather than hourly, bird photographs that
never change kept for a month rather than five minutes.
Two things make this safe to land now. No sports_live entry carries a
ttl at all -- the live-score path does not use set(ttl=) -- so live
freshness is untouched, which matters with a season two weeks out. And
replaying the change against that real cache, 997 currently-expired
entries become live while not one live entry becomes expired, so there
is no invalidation spike on deploy.
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
"""
|
|
Memory Cache
|
|
|
|
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
|
"""
|
|
|
|
import time
|
|
import threading
|
|
import logging
|
|
from typing import Dict, Any, Optional
|
|
|
|
|
|
class MemoryCache:
|
|
"""Manages in-memory cache with TTL and size limits."""
|
|
|
|
def __init__(self, max_size: int = 1000, cleanup_interval: float = 300.0) -> None:
|
|
"""
|
|
Initialize memory cache.
|
|
|
|
Args:
|
|
max_size: Maximum number of entries in cache
|
|
cleanup_interval: Seconds between automatic cleanups
|
|
"""
|
|
self.logger = logging.getLogger(__name__)
|
|
self._cache: Dict[str, Dict[str, Any]] = {}
|
|
self._timestamps: Dict[str, float] = {}
|
|
self._lock = threading.Lock()
|
|
self._max_size = max_size
|
|
self._cleanup_interval = cleanup_interval
|
|
self._last_cleanup = time.time()
|
|
|
|
def get(self, key: str, max_age: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get value from memory cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
max_age: Maximum age in seconds (None = no expiration)
|
|
|
|
Returns:
|
|
Cached value or None if not found or expired
|
|
"""
|
|
now = time.time()
|
|
|
|
with self._lock:
|
|
if key not in self._cache:
|
|
return None
|
|
|
|
timestamp = self._timestamps.get(key)
|
|
if isinstance(timestamp, str):
|
|
try:
|
|
timestamp = float(timestamp)
|
|
except ValueError:
|
|
self.logger.error(f"Invalid timestamp format for key {key}: {timestamp}")
|
|
timestamp = None
|
|
|
|
if timestamp is None:
|
|
return None
|
|
|
|
# An explicit per-entry ttl wins over the caller's max_age, matching
|
|
# DiskCache. max_age is inferred from substrings in the key and is
|
|
# only a fallback for records that did not say what they wanted.
|
|
record = self._cache[key]
|
|
if isinstance(record, dict):
|
|
stored_ttl = record.get('ttl')
|
|
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
|
and stored_ttl >= 0:
|
|
max_age = stored_ttl
|
|
|
|
# Check expiration
|
|
if max_age is not None and (now - timestamp) > max_age:
|
|
# Expired - remove it
|
|
self._cache.pop(key, None)
|
|
self._timestamps.pop(key, None)
|
|
return None
|
|
|
|
return self._cache[key]
|
|
|
|
def set(self, key: str, value: Dict[str, Any]) -> None:
|
|
"""
|
|
Set value in memory cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
value: Value to cache
|
|
"""
|
|
with self._lock:
|
|
self._cache[key] = value
|
|
self._timestamps[key] = time.time()
|
|
|
|
def clear(self, key: Optional[str] = None) -> None:
|
|
"""
|
|
Clear cache entry or all entries.
|
|
|
|
Args:
|
|
key: Specific key to clear, or None to clear all
|
|
"""
|
|
with self._lock:
|
|
if key:
|
|
self._cache.pop(key, None)
|
|
self._timestamps.pop(key, None)
|
|
else:
|
|
self._cache.clear()
|
|
self._timestamps.clear()
|
|
|
|
def cleanup(self, force: bool = False) -> int:
|
|
"""
|
|
Clean up expired entries and enforce size limits.
|
|
|
|
Args:
|
|
force: If True, perform cleanup regardless of time interval
|
|
|
|
Returns:
|
|
Number of entries removed
|
|
"""
|
|
now = time.time()
|
|
|
|
# Check if cleanup is needed
|
|
if not force and (now - self._last_cleanup) < self._cleanup_interval:
|
|
return 0
|
|
|
|
with self._lock:
|
|
removed_count = 0
|
|
current_time = time.time()
|
|
|
|
# Remove expired entries (entries older than 1 hour without access are considered expired)
|
|
max_age_for_cleanup = 3600 # 1 hour
|
|
|
|
expired_keys = []
|
|
for key, timestamp in list(self._timestamps.items()):
|
|
if isinstance(timestamp, str):
|
|
try:
|
|
timestamp = float(timestamp)
|
|
except ValueError:
|
|
timestamp = None
|
|
|
|
if timestamp is None or (current_time - timestamp) > max_age_for_cleanup:
|
|
expired_keys.append(key)
|
|
|
|
# Remove expired entries
|
|
for key in expired_keys:
|
|
self._cache.pop(key, None)
|
|
self._timestamps.pop(key, None)
|
|
removed_count += 1
|
|
|
|
# Enforce size limit by removing oldest entries if cache is too large
|
|
if len(self._cache) > self._max_size:
|
|
# Sort by timestamp (oldest first)
|
|
sorted_entries = sorted(
|
|
self._timestamps.items(),
|
|
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
|
)
|
|
|
|
# Remove oldest entries until we're under the limit
|
|
excess_count = len(self._cache) - self._max_size
|
|
for i in range(excess_count):
|
|
if i < len(sorted_entries):
|
|
key = sorted_entries[i][0]
|
|
self._cache.pop(key, None)
|
|
self._timestamps.pop(key, None)
|
|
removed_count += 1
|
|
|
|
self._last_cleanup = current_time
|
|
|
|
if removed_count > 0:
|
|
self.logger.debug("Memory cache cleanup: removed %d entries (current size: %d)",
|
|
removed_count, len(self._cache))
|
|
|
|
return removed_count
|
|
|
|
def size(self) -> int:
|
|
"""Get current cache size."""
|
|
with self._lock:
|
|
return len(self._cache)
|
|
|
|
def max_size(self) -> int:
|
|
"""Get maximum cache size."""
|
|
return self._max_size
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""
|
|
Get cache statistics.
|
|
|
|
Returns:
|
|
Dictionary with cache statistics
|
|
"""
|
|
with self._lock:
|
|
return {
|
|
'size': len(self._cache),
|
|
'max_size': self._max_size,
|
|
'usage_percent': (len(self._cache) / self._max_size * 100) if self._max_size > 0 else 0,
|
|
'last_cleanup': self._last_cleanup,
|
|
'cleanup_interval': self._cleanup_interval
|
|
}
|
|
|