soulsync/core/video/enrichment/cache.py
BoulderBadgeDad 621693ecc8 video: real TTL+LRU cache (thread-safe) + cache search — the kettui way
The inline dict cache was a latent bug: no lock (the engine singleton is hit by
concurrent Flask + worker threads) and a wholesale-clear cliff at 256 (nuked hot
entries). Extracted a thread-safe TTL+LRU TTLCache into an importable core/ module
with seam tests (expiry via injected clock, LRU-not-wholesale eviction, a
concurrency stress test). Engine now uses it; search is cached too (60s, ownership
re-stamped fresh). Deliberately NOT persisted to disk — durable data already lives
in video.db; that tier would be over-engineering for a self-hosted app.
2026-06-15 13:02:24 -07:00

50 lines
1.8 KiB
Python

"""A small thread-safe TTL + LRU cache for the video enrichment engine.
The engine is a process-wide singleton hit concurrently by Flask request threads
AND the worker threads, so the cache must be locked. Entries expire after a TTL;
when the cache is full the LEAST-RECENTLY-USED entry is evicted (not the whole
cache wholesale). Isolated: imports only the stdlib; no music, no DB.
"""
from __future__ import annotations
import threading
import time
from collections import OrderedDict
class TTLCache:
def __init__(self, maxsize: int = 256, ttl: float = 1800.0, clock=time.monotonic):
self._max = max(1, int(maxsize))
self._ttl = float(ttl)
self._clock = clock # injectable for tests
self._lock = threading.Lock()
self._data: OrderedDict = OrderedDict() # key -> (expires_at, value)
def get(self, key):
now = self._clock()
with self._lock:
hit = self._data.get(key)
if hit is None:
return None
if hit[0] <= now: # expired
del self._data[key]
return None
self._data.move_to_end(key) # mark recently used
return hit[1]
def put(self, key, value, ttl: float | None = None) -> None:
expires_at = self._clock() + (self._ttl if ttl is None else float(ttl))
with self._lock:
self._data[key] = (expires_at, value)
self._data.move_to_end(key)
while len(self._data) > self._max: # evict LRU (oldest), not everything
self._data.popitem(last=False)
def clear(self) -> None:
with self._lock:
self._data.clear()
def __len__(self) -> int:
with self._lock:
return len(self._data)