2025-09-24 13:57:23 -07:00
|
|
|
"""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
|
|
|
|
|
|
2025-10-31 08:18:09 -07:00
|
|
|
from .preview_metrics import record_eviction
|
2025-09-24 13:57:23 -07:00
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
|
)
|
2025-10-31 08:18:09 -07:00
|
|
|
from .preview_cache_backend import redis_store
|
2025-09-24 13:57:23 -07:00
|
|
|
|
|
|
|
|
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
|