mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat(web): Core Refactor Phase A — extract sampling and cache modules; add adaptive TTL + eviction heuristics, Redis PoC, and metrics wiring. Tests added for TTL, eviction, exports, splash-adaptive, card index, and service worker. Docs+roadmap updated.
This commit is contained in:
parent
c4a7fc48ea
commit
a029d430c5
49 changed files with 3889 additions and 701 deletions
323
code/web/services/preview_cache.py
Normal file
323
code/web/services/preview_cache.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue