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.
50 lines
1.8 KiB
Python
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)
|