mtg_python_deckbuilder/code/web/services/preview_cache.py

324 lines
12 KiB
Python
Raw Normal View History

"""Preview cache utilities & adaptive policy (Core Refactor Phase A continued).
This module now owns:
- In-memory preview cache (OrderedDict)
- Cache bust helper
- Adaptive TTL policy & recent hit tracking
- Background refresh thread orchestration (warming top-K hot themes)
`theme_preview` orchestrator invokes `record_request_hit()` and
`maybe_adapt_ttl()` after each build/cache check, and calls `ensure_bg_thread()`
post-build. Metrics still aggregated in `theme_preview` but TTL state lives
here to prepare for future backend abstraction.
"""
from __future__ import annotations
from collections import OrderedDict, deque
from typing import Any, Dict, Tuple, Callable
import time as _t
import os
import json
import threading
import math
from .preview_metrics import record_eviction # type: ignore
# Phase 2 extraction: adaptive TTL band policy moved into preview_policy
from .preview_policy import (
compute_ttl_adjustment,
DEFAULT_TTL_BASE as _POLICY_TTL_BASE,
DEFAULT_TTL_MIN as _POLICY_TTL_MIN,
DEFAULT_TTL_MAX as _POLICY_TTL_MAX,
)
from .preview_cache_backend import redis_store # type: ignore
TTL_SECONDS = 600
# Backward-compat variable names retained (tests may reference) mapping to policy constants
_TTL_BASE = _POLICY_TTL_BASE
_TTL_MIN = _POLICY_TTL_MIN
_TTL_MAX = _POLICY_TTL_MAX
_ADAPT_SAMPLE_WINDOW = 120
_ADAPT_INTERVAL_S = 30
_ADAPTATION_ENABLED = (os.getenv("THEME_PREVIEW_ADAPTIVE") or "").lower() in {"1","true","yes","on"}
_RECENT_HITS: "deque[bool]" = deque(maxlen=_ADAPT_SAMPLE_WINDOW)
_LAST_ADAPT_AT: float | None = None
_BG_REFRESH_THREAD_STARTED = False
_BG_REFRESH_INTERVAL_S = int(os.getenv("THEME_PREVIEW_BG_REFRESH_INTERVAL") or 120)
_BG_REFRESH_ENABLED = (os.getenv("THEME_PREVIEW_BG_REFRESH") or "").lower() in {"1","true","yes","on"}
_BG_REFRESH_MIN = 30
_BG_REFRESH_MAX = max(300, _BG_REFRESH_INTERVAL_S * 5)
def record_request_hit(hit: bool) -> None:
_RECENT_HITS.append(hit)
def recent_hit_window() -> int:
return len(_RECENT_HITS)
def ttl_seconds() -> int:
return TTL_SECONDS
def _maybe_adapt_ttl(now: float) -> None:
"""Apply adaptive TTL adjustment using extracted policy.
Keeps prior guards (sample window, interval) for stability; only the
banded adjustment math has moved to preview_policy.
"""
global TTL_SECONDS, _LAST_ADAPT_AT
if not _ADAPTATION_ENABLED:
return
if len(_RECENT_HITS) < max(30, int(_ADAPT_SAMPLE_WINDOW * 0.5)):
return
if _LAST_ADAPT_AT and (now - _LAST_ADAPT_AT) < _ADAPT_INTERVAL_S:
return
hit_ratio = sum(1 for h in _RECENT_HITS if h) / len(_RECENT_HITS)
new_ttl = compute_ttl_adjustment(hit_ratio, TTL_SECONDS, _TTL_BASE, _TTL_MIN, _TTL_MAX)
if new_ttl != TTL_SECONDS:
TTL_SECONDS = new_ttl
try: # pragma: no cover - defensive logging
print(json.dumps({
"event": "theme_preview_ttl_adapt",
"hit_ratio": round(hit_ratio, 3),
"ttl": TTL_SECONDS,
})) # noqa: T201
except Exception:
pass
_LAST_ADAPT_AT = now
def maybe_adapt_ttl() -> None:
_maybe_adapt_ttl(_t.time())
def _bg_refresh_loop(build_top_slug: Callable[[str], None], get_hot_slugs: Callable[[], list[str]]): # pragma: no cover
while True:
if not _BG_REFRESH_ENABLED:
return
try:
for slug in get_hot_slugs():
try:
build_top_slug(slug)
except Exception:
continue
except Exception:
pass
_t.sleep(_BG_REFRESH_INTERVAL_S)
def ensure_bg_thread(build_top_slug: Callable[[str], None], get_hot_slugs: Callable[[], list[str]]): # pragma: no cover
global _BG_REFRESH_THREAD_STARTED
if _BG_REFRESH_THREAD_STARTED or not _BG_REFRESH_ENABLED:
return
try:
th = threading.Thread(target=_bg_refresh_loop, args=(build_top_slug, get_hot_slugs), name="theme_preview_bg_refresh", daemon=True)
th.start()
_BG_REFRESH_THREAD_STARTED = True
except Exception:
pass
PREVIEW_CACHE: "OrderedDict[Tuple[str, int, str | None, str | None, str], Dict[str, Any]]" = OrderedDict()
# Cache entry shape (dict) — groundwork for adaptive eviction (Phase 2)
# Keys:
# payload: preview payload dict
# _cached_at / cached_at: epoch seconds when stored (TTL reference; _cached_at kept for backward compat)
# inserted_at: epoch seconds first insertion
# last_access: epoch seconds of last successful cache hit
# hit_count: int number of cache hits (excludes initial store)
# build_cost_ms: float build duration captured at store time (used for cost-based protection)
def register_cache_hit(key: Tuple[str, int, str | None, str | None, str]) -> None:
entry = PREVIEW_CACHE.get(key)
if not entry:
return
now = _t.time()
# Initialize metadata if legacy entry present
if "inserted_at" not in entry:
entry["inserted_at"] = entry.get("_cached_at", now)
entry["last_access"] = now
entry["hit_count"] = int(entry.get("hit_count", 0)) + 1
def store_cache_entry(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], build_cost_ms: float) -> None:
now = _t.time()
PREVIEW_CACHE[key] = {
"payload": payload,
"_cached_at": now, # legacy field name
"cached_at": now,
"inserted_at": now,
"last_access": now,
"hit_count": 0,
"build_cost_ms": float(build_cost_ms),
}
PREVIEW_CACHE.move_to_end(key)
# Optional Redis write-through (best-effort)
try:
if os.getenv("THEME_PREVIEW_REDIS_URL") and not os.getenv("THEME_PREVIEW_REDIS_DISABLE"):
redis_store(key, payload, int(TTL_SECONDS), build_cost_ms)
except Exception:
pass
# --- Adaptive Eviction Weight & Threshold Resolution (Phase 2 Step 4) --- #
_EVICT_WEIGHTS_CACHE: Dict[str, float] | None = None
_EVICT_THRESH_CACHE: Tuple[float, float, float] | None = None
def _resolve_eviction_weights() -> Dict[str, float]:
global _EVICT_WEIGHTS_CACHE
if _EVICT_WEIGHTS_CACHE is not None:
return _EVICT_WEIGHTS_CACHE
def _f(env_key: str, default: float) -> float:
raw = os.getenv(env_key)
if not raw:
return default
try:
return float(raw)
except Exception:
return default
_EVICT_WEIGHTS_CACHE = {
"W_HITS": _f("THEME_PREVIEW_EVICT_W_HITS", 3.0),
"W_RECENCY": _f("THEME_PREVIEW_EVICT_W_RECENCY", 2.0),
"W_COST": _f("THEME_PREVIEW_EVICT_W_COST", 1.0),
"W_AGE": _f("THEME_PREVIEW_EVICT_W_AGE", 1.5),
}
return _EVICT_WEIGHTS_CACHE
def _resolve_cost_thresholds() -> Tuple[float, float, float]:
global _EVICT_THRESH_CACHE
if _EVICT_THRESH_CACHE is not None:
return _EVICT_THRESH_CACHE
raw = os.getenv("THEME_PREVIEW_EVICT_COST_THRESHOLDS", "5,15,40")
parts = [p.strip() for p in raw.split(',') if p.strip()]
nums: list[float] = []
for p in parts:
try:
nums.append(float(p))
except Exception:
pass
while len(nums) < 3:
# pad with defaults if insufficient
defaults = [5.0, 15.0, 40.0]
nums.append(defaults[len(nums)])
nums = sorted(nums[:3])
_EVICT_THRESH_CACHE = (nums[0], nums[1], nums[2])
return _EVICT_THRESH_CACHE
def _cost_bucket(build_cost_ms: float) -> int:
t1, t2, t3 = _resolve_cost_thresholds()
if build_cost_ms < t1:
return 0
if build_cost_ms < t2:
return 1
if build_cost_ms < t3:
return 2
return 3
def compute_protection_score(entry: Dict[str, Any], now: float | None = None) -> float:
"""Compute protection score (higher = more protected from eviction).
Score components:
- hit_count (log scaled) weighted by W_HITS
- recency (inverse minutes since last access) weighted by W_RECENCY
- build cost bucket weighted by W_COST
- age penalty (minutes since insert) weighted by W_AGE (subtracted)
"""
if now is None:
now = _t.time()
weights = _resolve_eviction_weights()
inserted = float(entry.get("inserted_at", now))
last_access = float(entry.get("last_access", inserted))
hits = int(entry.get("hit_count", 0))
build_cost_ms = float(entry.get("build_cost_ms", 0.0))
minutes_since_last = max(0.0, (now - last_access) / 60.0)
minutes_since_insert = max(0.0, (now - inserted) / 60.0)
recency_score = 1.0 / (1.0 + minutes_since_last)
age_score = minutes_since_insert
cost_b = _cost_bucket(build_cost_ms)
score = (
weights["W_HITS"] * math.log(1 + hits)
+ weights["W_RECENCY"] * recency_score
+ weights["W_COST"] * cost_b
- weights["W_AGE"] * age_score
)
return float(score)
# --- Eviction Logic (Phase 2 Step 6) --- #
def _cache_max() -> int:
try:
raw = os.getenv("THEME_PREVIEW_CACHE_MAX") or "400"
v = int(raw)
if v <= 0:
raise ValueError
return v
except Exception:
return 400
def evict_if_needed() -> None:
"""Adaptive eviction replacing FIFO.
Strategy:
- If size <= limit: no-op
- If size > 2*limit: emergency overflow path (age-based removal until within limit)
- Else: remove lowest protection score entry (single) if over limit
"""
try:
# Removed previous hard floor (50) to allow test scenarios with small limits.
# Operational deployments can still set higher env value. Tests rely on low limits
# (e.g., 5) to exercise eviction deterministically.
limit = _cache_max()
size = len(PREVIEW_CACHE)
if size <= limit:
return
now = _t.time()
# Emergency overflow path
if size > 2 * limit:
while len(PREVIEW_CACHE) > limit:
# Oldest by inserted_at/_cached_at
oldest_key = min(
PREVIEW_CACHE.items(),
key=lambda kv: kv[1].get("inserted_at", kv[1].get("_cached_at", 0.0)),
)[0]
entry = PREVIEW_CACHE.pop(oldest_key)
meta = {
"hit_count": int(entry.get("hit_count", 0)),
"age_ms": int((now - entry.get("inserted_at", now)) * 1000),
"build_cost_ms": float(entry.get("build_cost_ms", 0.0)),
"protection_score": compute_protection_score(entry, now),
"reason": "emergency_overflow",
"cache_limit": limit,
"size_before": size,
"size_after": len(PREVIEW_CACHE),
}
record_eviction(meta)
return
# Standard single-entry score-based eviction
lowest_key = None
lowest_score = None
for key, entry in PREVIEW_CACHE.items():
score = compute_protection_score(entry, now)
if lowest_score is None or score < lowest_score:
lowest_key = key
lowest_score = score
if lowest_key is not None:
entry = PREVIEW_CACHE.pop(lowest_key)
meta = {
"hit_count": int(entry.get("hit_count", 0)),
"age_ms": int((now - entry.get("inserted_at", now)) * 1000),
"build_cost_ms": float(entry.get("build_cost_ms", 0.0)),
"protection_score": float(lowest_score if lowest_score is not None else 0.0),
"reason": "low_score",
"cache_limit": limit,
"size_before": size,
"size_after": len(PREVIEW_CACHE),
}
record_eviction(meta)
except Exception:
# Fail quiet; eviction is best-effort
pass
_PREVIEW_LAST_BUST_AT: float | None = None
def bust_preview_cache(reason: str | None = None) -> None: # pragma: no cover (trivial)
global PREVIEW_CACHE, _PREVIEW_LAST_BUST_AT
try:
PREVIEW_CACHE.clear()
_PREVIEW_LAST_BUST_AT = _t.time()
except Exception:
pass
def preview_cache_last_bust_at() -> float | None:
return _PREVIEW_LAST_BUST_AT