mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50: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
113
code/web/services/preview_cache_backend.py
Normal file
113
code/web/services/preview_cache_backend.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Cache backend abstraction (Phase 2 extension) with Redis PoC.
|
||||
|
||||
The in-memory cache remains authoritative for adaptive eviction heuristics.
|
||||
This backend layer provides optional read-through / write-through to Redis
|
||||
for latency & CPU comparison. It is intentionally minimal:
|
||||
|
||||
Environment:
|
||||
THEME_PREVIEW_REDIS_URL=redis://host:port/db -> enable PoC if redis-py importable
|
||||
THEME_PREVIEW_REDIS_DISABLE=1 -> hard disable even if URL present
|
||||
|
||||
Behavior:
|
||||
- On store: serialize payload + metadata into JSON and SETEX with TTL.
|
||||
- On get (memory miss only): attempt Redis GET and rehydrate (respect TTL).
|
||||
- Failures are swallowed; metrics track attempts/hits/errors.
|
||||
|
||||
No eviction coordination is attempted; Redis TTL handles expiry. The goal is
|
||||
purely observational at this stage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
try: # lazy optional dependency
|
||||
import redis # type: ignore
|
||||
except Exception: # pragma: no cover - absence path
|
||||
redis = None # type: ignore
|
||||
|
||||
_URL = os.getenv("THEME_PREVIEW_REDIS_URL")
|
||||
_DISABLED = (os.getenv("THEME_PREVIEW_REDIS_DISABLE") or "").lower() in {"1","true","yes","on"}
|
||||
|
||||
_CLIENT = None
|
||||
_INIT_ERR: str | None = None
|
||||
|
||||
def _init() -> None:
|
||||
global _CLIENT, _INIT_ERR
|
||||
if _CLIENT is not None or _INIT_ERR is not None:
|
||||
return
|
||||
if _DISABLED or not _URL or not redis:
|
||||
_INIT_ERR = "disabled_or_missing"
|
||||
return
|
||||
try:
|
||||
_CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore
|
||||
# lightweight ping (non-fatal)
|
||||
try:
|
||||
_CLIENT.ping()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e: # pragma: no cover - network/dep issues
|
||||
_INIT_ERR = f"init_error:{e}"[:120]
|
||||
|
||||
|
||||
def backend_info() -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": bool(_CLIENT),
|
||||
"init_error": _INIT_ERR,
|
||||
"url_present": bool(_URL),
|
||||
}
|
||||
|
||||
def _serialize(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], build_cost_ms: float) -> str:
|
||||
return json.dumps({
|
||||
"k": list(key),
|
||||
"p": payload,
|
||||
"bc": build_cost_ms,
|
||||
"ts": time.time(),
|
||||
}, separators=(",", ":"))
|
||||
|
||||
def redis_store(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], ttl_seconds: int, build_cost_ms: float) -> bool:
|
||||
_init()
|
||||
if not _CLIENT:
|
||||
return False
|
||||
try:
|
||||
data = _serialize(key, payload, build_cost_ms)
|
||||
# Compose a simple namespaced key; join tuple parts with '|'
|
||||
skey = "tpv:" + "|".join([str(part) for part in key])
|
||||
_CLIENT.setex(skey, ttl_seconds, data)
|
||||
return True
|
||||
except Exception: # pragma: no cover
|
||||
return False
|
||||
|
||||
def redis_get(key: Tuple[str, int, str | None, str | None, str]) -> Optional[Dict[str, Any]]:
|
||||
_init()
|
||||
if not _CLIENT:
|
||||
return None
|
||||
try:
|
||||
skey = "tpv:" + "|".join([str(part) for part in key])
|
||||
raw: bytes | None = _CLIENT.get(skey) # type: ignore
|
||||
if not raw:
|
||||
return None
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
# Expect shape from _serialize
|
||||
payload = obj.get("p")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return {
|
||||
"payload": payload,
|
||||
"_cached_at": float(obj.get("ts") or 0),
|
||||
"cached_at": float(obj.get("ts") or 0),
|
||||
"inserted_at": float(obj.get("ts") or 0),
|
||||
"last_access": float(obj.get("ts") or 0),
|
||||
"hit_count": 0,
|
||||
"build_cost_ms": float(obj.get("bc") or 0.0),
|
||||
}
|
||||
except Exception: # pragma: no cover
|
||||
return None
|
||||
|
||||
__all__ = [
|
||||
"backend_info",
|
||||
"redis_store",
|
||||
"redis_get",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue