Spaces:
Sleeping
Sleeping
| """MongoDB key-value cache with TTL. Replaces SQLite. Fail-open on every operation.""" | |
| import functools | |
| import hashlib | |
| import json | |
| import logging | |
| import sqlite3 | |
| from datetime import datetime, timezone, timedelta | |
| from app import config | |
| logger = logging.getLogger("saarthi.cache") | |
| _collection = None | |
| _conn = None | |
| def _use_sqlite(): | |
| return bool(getattr(config, "CACHE_DB", None)) | |
| def _get_conn(): | |
| """SQLite cache connection used by tests/local fallback.""" | |
| global _conn | |
| if _conn is None: | |
| _conn = sqlite3.connect(config.CACHE_DB, check_same_thread=False) | |
| _conn.execute( | |
| "CREATE TABLE IF NOT EXISTS cache " | |
| "(key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at REAL NOT NULL)" | |
| ) | |
| _conn.commit() | |
| return _conn | |
| def _get_collection(): | |
| global _collection | |
| if _collection is None: | |
| from app.db import get_db | |
| col = get_db()["api_cache"] | |
| col.create_index("expires_at", expireAfterSeconds=0) | |
| _collection = col | |
| return _collection | |
| def get(key: str): | |
| """Return cached value or None. Any failure is a miss.""" | |
| try: | |
| if _use_sqlite(): | |
| now_ts = datetime.now(timezone.utc).timestamp() | |
| row = _get_conn().execute( | |
| "SELECT value, expires_at FROM cache WHERE key = ?", | |
| (key,), | |
| ).fetchone() | |
| if not row: | |
| return None | |
| value_json, expires_at = row | |
| if expires_at <= now_ts: | |
| _get_conn().execute("DELETE FROM cache WHERE key = ?", (key,)) | |
| _get_conn().commit() | |
| return None | |
| return json.loads(value_json) | |
| doc = _get_collection().find_one({"key": key}, {"_id": 0, "value": 1}) | |
| return doc["value"] if doc else None | |
| except Exception as error: | |
| logger.warning("Cache read failed (treating as miss): %s", error) | |
| return None | |
| def set(key: str, value, ttl_seconds: int): | |
| """Write to cache. Any failure is silently skipped.""" | |
| try: | |
| expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) | |
| if _use_sqlite(): | |
| _get_conn().execute( | |
| "REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)", | |
| (key, json.dumps(value), expires_at.timestamp()), | |
| ) | |
| _get_conn().commit() | |
| return | |
| _get_collection().replace_one( | |
| {"key": key}, | |
| {"key": key, "value": value, "expires_at": expires_at}, | |
| upsert=True, | |
| ) | |
| except Exception as error: | |
| logger.warning("Cache write failed (skipping): %s", error) | |
| def cached(ttl_seconds: int): | |
| """Decorator: cache a function's JSON-serializable result by its arguments.""" | |
| def decorator(func): | |
| def wrapper(*args, **kwargs): | |
| raw = json.dumps( | |
| [func.__module__, func.__name__, args, kwargs], | |
| sort_keys=True, default=str, | |
| ) | |
| key = hashlib.sha256(raw.encode()).hexdigest() | |
| hit = get(key) | |
| if hit is not None: | |
| return hit | |
| result = func(*args, **kwargs) | |
| if result is not None: | |
| set(key, result, ttl_seconds) | |
| return result | |
| return wrapper | |
| return decorator | |