Files
LEDMatrix/src/cache/memory_cache.py
T
bb1a1671ec fix(cache): make the ttl parameter actually control expiry (#450)
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>
2026-08-11 14:14:25 -04:00

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
}