File size: 3,379 Bytes
8804f81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
"""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):
        @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