""" app/core/cache.py ───────────────── Two independent market-data caches: PUBLIC_CACHE — uses the keyless CoinGecko v3 public endpoint. Auto-refreshes every 120 s via a background thread. Used by all guest visitors; logged-in users can opt in. Fetches 5 pages (1 250 coins). V3_CACHE — uses the v3 endpoint with a demo API key. Refreshed on-demand: stale after 300 s or on manual /dashboard/refresh. Fetches 6 pages (1 500 coins). Only used when a logged-in user has endpoint_mode="v3". """ import time import threading import os from app.core.scanner import MarketScanner # ── Cache stores ───────────────────────────────────────────────────────────── PUBLIC_CACHE: dict = {"data": None, "timestamp": 0} V3_CACHE: dict = {"data": None, "timestamp": 0} _PUBLIC_TTL = 120 # seconds between auto-refreshes for public endpoint _V3_TTL = 300 # seconds before v3 cache is considered stale _public_lock = threading.Lock() _v3_lock = threading.Lock() _bg_started = False _bg_lock = threading.Lock() # ── Public endpoint helpers ─────────────────────────────────────────────────── def _refresh_public() -> None: """Fetch fresh data from the keyless public endpoint and store it.""" try: data = MarketScanner(api_key="", use_public=True).run_scan(pages=4) with _public_lock: PUBLIC_CACHE["data"] = data PUBLIC_CACHE["timestamp"] = time.time() except Exception as exc: print(f"[cache] public refresh error: {exc}") def _bg_loop() -> None: """Background thread: refresh public cache every _PUBLIC_TTL seconds.""" while True: _refresh_public() time.sleep(_PUBLIC_TTL) def start_public_autorefresh() -> None: """Start the background auto-refresh thread (idempotent).""" global _bg_started with _bg_lock: if _bg_started: return t = threading.Thread(target=_bg_loop, daemon=True) t.start() _bg_started = True print("[cache] Public auto-refresh thread started (every 120 s).") def get_public_data() -> list: """Return cached public data; triggers a blocking refresh if cache is empty.""" with _public_lock: if PUBLIC_CACHE["data"]: return PUBLIC_CACHE["data"] # First-ever call: populate synchronously so the page doesn't return empty. # NOTE: We intentionally release the lock before the slow network call to # avoid blocking concurrent requests. Multiple callers may race here on a # cold start; only the last writer wins which is safe (idempotent data). _refresh_public() with _public_lock: return PUBLIC_CACHE["data"] or [] # ── V3 (demo-key) endpoint helpers ──────────────────────────────────────────── def get_v3_data(api_key: str) -> list: """ Return cached v3 data, refreshing if stale (> 300 s) or empty. Uses whichever api_key is passed (personal > env fallback). """ with _v3_lock: now = time.time() if V3_CACHE["data"] and (now - V3_CACHE["timestamp"] <= _V3_TTL): return V3_CACHE["data"] # Outside the lock for the slow network call try: data = MarketScanner(api_key=api_key, use_public=False).run_scan(pages=6) except Exception as exc: print(f"[cache] v3 refresh error: {exc}") data = V3_CACHE.get("data") or [] with _v3_lock: V3_CACHE["data"] = data V3_CACHE["timestamp"] = time.time() return data def invalidate_v3() -> None: """Force the next v3 access to re-fetch (used by /dashboard/refresh).""" with _v3_lock: V3_CACHE["data"] = None V3_CACHE["timestamp"] = 0