import json import logging import os import sqlite3 import time from typing import Any, Dict, Optional logger = logging.getLogger(__name__) class PersistentTTLCache: """ Minimal persistent TTL cache backed by SQLite. Use case: cache expensive or rate-limited API responses across app restarts. """ def __init__(self, db_path: str = "src/data/.persistent_cache.sqlite3"): self.db_path = db_path os.makedirs(os.path.dirname(db_path), exist_ok=True) self._init_db() def _connect(self) -> sqlite3.Connection: # autocommit for small operations return sqlite3.connect(self.db_path, isolation_level=None) def _init_db(self) -> None: with self._connect() as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS cache ( namespace TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (namespace, key) ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(namespace, expires_at)" ) def get(self, namespace: str, key: str) -> Optional[Dict[str, Any]]: now = int(time.time()) with self._connect() as conn: row = conn.execute( "SELECT value_json, expires_at FROM cache WHERE namespace=? AND key=?", (namespace, key), ).fetchone() if not row: return None value_json, expires_at = row if int(expires_at) <= now: try: conn.execute( "DELETE FROM cache WHERE namespace=? AND key=?", (namespace, key), ) except Exception: logger.exception("Failed deleting expired cache row") return None try: return json.loads(value_json) except Exception: logger.exception("Failed decoding cache JSON") return None def set( self, namespace: str, key: str, value: Dict[str, Any], ttl_seconds: int ) -> None: now = int(time.time()) expires_at = now + int(ttl_seconds) value_json = json.dumps(value, ensure_ascii=True, separators=(",", ":")) with self._connect() as conn: conn.execute( """ INSERT INTO cache(namespace, key, value_json, expires_at, created_at) VALUES(?,?,?,?,?) ON CONFLICT(namespace, key) DO UPDATE SET value_json=excluded.value_json, expires_at=excluded.expires_at, created_at=excluded.created_at """, (namespace, key, value_json, expires_at, now), )