Files
LEDMatrix/src/cache/memory_cache.py
T
ChuckandClaude Opus 5.5 b8c01c69fb ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)
* ci: mypy ratchet -- keep type-clean modules clean

mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).

35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.

mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: annotate check_types.py's list-form mypy subprocess

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:01:35 -04:00

258 lines
8.8 KiB
Python

"""
Memory Cache
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
"""
import os
import time
import threading
import logging
from typing import Dict, Any, Optional, Union
# Historical fixed ceiling, kept as the fallback when RAM cannot be read.
DEFAULT_MAX_SIZE = 1000
def _total_memory_mb() -> Optional[float]:
"""Physical RAM in MB, or None where /proc/meminfo is unavailable."""
try:
with open('/proc/meminfo', 'r', encoding='utf-8') as fh:
for line in fh:
if line.startswith('MemTotal:'):
return int(line.split()[1]) / 1024
except (OSError, ValueError, IndexError):
return None
return None
def default_max_size() -> int:
"""Entry ceiling scaled to this machine's RAM.
One fixed ceiling cannot serve both a 512 MB Pi Zero 2 W and an 8 GB Pi 5.
Entries here are parsed API payloads that routinely run tens of kilobytes
each, so a thousand of them is a comfortable cache on a large board and a
substantial fraction of total RAM on a small one — where the process
competing for that RAM is also driving the panel. Set
LEDMATRIX_CACHE_MAX_ENTRIES to override.
"""
override = os.environ.get('LEDMATRIX_CACHE_MAX_ENTRIES')
if override:
try:
value = int(override)
if value > 0:
return value
except ValueError:
pass
total_mb = _total_memory_mb()
if total_mb is None:
return DEFAULT_MAX_SIZE
if total_mb < 1536: # 512 MB and 1 GB boards
return 150
if total_mb < 3072: # 2 GB
return 400
if total_mb < 6144: # 4 GB
return 800
return 1500 # 8 GB and up
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]] = {}
# Values are time.time() floats; get()/cleanup also accept a numeric
# string, as a timestamp may have been restored from serialized data.
self._timestamps: Dict[str, Union[float, str]] = {}
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[float] = 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()
# Enforce the ceiling here rather than leaving it to the periodic
# cleanup, which only runs every cleanup_interval seconds (300 by
# default). A burst of inserts between two sweeps could otherwise
# take the cache far past _max_size, which is the memory growth this
# limit exists to prevent -- and on a 1GB board that is the
# difference between a bounded cache and an unreachable Pi.
self._evict_over_limit_locked()
def _evict_over_limit_locked(self) -> int:
"""Drop oldest entries until the cache is within _max_size.
Caller must hold self._lock. Returns the number of entries removed.
"""
excess = len(self._cache) - self._max_size
if excess <= 0:
return 0
oldest = sorted(
self._timestamps.items(),
key=lambda item: float(item[1]) if isinstance(item[1], (int, float)) else 0.0
)
removed = 0
for key, _ in oldest[:excess]:
self._cache.pop(key, None)
self._timestamps.pop(key, None)
removed += 1
return removed
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 = []
timestamp: Optional[Union[float, str]]
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
# Same ceiling enforcement set() uses, so the two cannot drift.
removed_count += self._evict_over_limit_locked()
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
}