| """MongoDB key-value cache with TTL. Replaces SQLite. Fail-open on every operation.""" |
|
|
| import functools |
| import hashlib |
| import json |
| import logging |
| from datetime import datetime, timezone, timedelta |
|
|
| from app import config |
|
|
| logger = logging.getLogger("saarthi.cache") |
|
|
| _collection = None |
|
|
|
|
| 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: |
| 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) |
| _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): |
| @functools.wraps(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 |
|
|