File size: 7,970 Bytes
5c9b605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afd6eeb
 
 
 
 
 
 
5c9b605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""Database-backed response cache with stale fallback support."""

from __future__ import annotations

import hashlib
import os
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any

from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine

from app.core.config import settings
from app.utils.serialization import dumps_json, loads_json, to_jsonable


UTC = timezone.utc
TABLE_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")


@dataclass(frozen=True)
class CacheEntry:
    key: str
    namespace: str
    payload: Any
    source: str
    created_at: datetime
    expires_at: datetime
    row_count: int | None

    @property
    def expired(self) -> bool:
        return self.expires_at <= datetime.now(UTC)


class ResponseCache:
    def __init__(self, database_url: str, enabled: bool = True) -> None:
        self.enabled = enabled
        self.database_url = self._normalize_database_url(database_url)
        self.table_name = self._validate_table_name(settings.cache_table_name)
        self.engine: Engine | None = None
        if self.enabled:
            self._init_engine()

    def _normalize_database_url(self, database_url: str) -> str:
        if database_url.startswith("mysql://"):
            return database_url.replace("mysql://", "mysql+pymysql://", 1)
        return database_url

    def _validate_table_name(self, table_name: str) -> str:
        value = table_name.strip()
        if not TABLE_NAME_PATTERN.fullmatch(value):
            raise ValueError(f"Invalid CACHE_TABLE_NAME: {table_name!r}")
        return value

    def _init_engine(self) -> None:
        if self.database_url.startswith("sqlite:///"):
            path = self.database_url.removeprefix("sqlite:///")
            dirname = os.path.dirname(path)
            if dirname:
                os.makedirs(dirname, exist_ok=True)
        connect_args: dict[str, Any] = {}
        if settings.database_ssl and self.database_url.startswith("mysql+pymysql://"):
            connect_args["ssl"] = {"ssl": True}
        self.engine = create_engine(
            self.database_url,
            pool_pre_ping=True,
            future=True,
            connect_args=connect_args,
        )
        with self.engine.begin() as conn:
            conn.execute(
                text(
                    f"""
                    CREATE TABLE IF NOT EXISTS {self.table_name} (
                        cache_key VARCHAR(191) PRIMARY KEY,
                        namespace VARCHAR(96) NOT NULL,
                        request_hash VARCHAR(64) NOT NULL,
                        payload_json LONGTEXT NOT NULL,
                        source_name VARCHAR(160) NOT NULL,
                        row_count INTEGER NULL,
                        created_at DATETIME NOT NULL,
                        expires_at DATETIME NOT NULL
                    )
                    """
                )
            )

    def make_key(self, namespace: str, params: dict[str, Any]) -> str:
        canonical = dumps_json(
            {
                "version": settings.cache_schema_version,
                "namespace": namespace,
                "params": to_jsonable(params),
            }
        )
        digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
        return f"{namespace}:{digest}"

    def get(self, key: str, allow_expired: bool = False) -> CacheEntry | None:
        if not self.enabled or self.engine is None:
            return None
        with self.engine.begin() as conn:
            row = conn.execute(
                text(
                    f"""
                    SELECT cache_key, namespace, payload_json, source_name, row_count, created_at, expires_at
                    FROM {self.table_name}
                    WHERE cache_key = :key
                    """
                ),
                {"key": key},
            ).mappings().first()
        if not row:
            return None

        entry = CacheEntry(
            key=str(row["cache_key"]),
            namespace=str(row["namespace"]),
            payload=loads_json(str(row["payload_json"])),
            source=str(row["source_name"]),
            row_count=row["row_count"],
            created_at=self._as_utc(row["created_at"]),
            expires_at=self._as_utc(row["expires_at"]),
        )
        if entry.expired and not allow_expired:
            return None
        return entry

    def set(
        self,
        key: str,
        namespace: str,
        payload: Any,
        source: str,
        ttl_seconds: int,
        row_count: int | None = None,
    ) -> CacheEntry | None:
        if not self.enabled or self.engine is None:
            return None
        now = datetime.now(UTC)
        expires_at = now + timedelta(seconds=ttl_seconds)
        request_hash = key.rsplit(":", 1)[-1]
        with self.engine.begin() as conn:
            conn.execute(
                text(
                    f"""
                    DELETE FROM {self.table_name}
                    WHERE cache_key = :key
                    """
                ),
                {"key": key},
            )
            conn.execute(
                text(
                    f"""
                    INSERT INTO {self.table_name} (
                        cache_key, namespace, request_hash, payload_json, source_name,
                        row_count, created_at, expires_at
                    )
                    VALUES (
                        :key, :namespace, :request_hash, :payload_json, :source_name,
                        :row_count, :created_at, :expires_at
                    )
                    """
                ),
                {
                    "key": key,
                    "namespace": namespace,
                    "request_hash": request_hash,
                    "payload_json": dumps_json(payload),
                    "source_name": source,
                    "row_count": row_count,
                    "created_at": now.replace(tzinfo=None),
                    "expires_at": expires_at.replace(tzinfo=None),
                },
            )
        return CacheEntry(key, namespace, payload, source, now, expires_at, row_count)

    def purge_expired(self) -> int:
        if not self.enabled or self.engine is None:
            return 0
        now = datetime.now(UTC).replace(tzinfo=None)
        with self.engine.begin() as conn:
            result = conn.execute(
                text(f"DELETE FROM {self.table_name} WHERE expires_at < :now"),
                {"now": now},
            )
            return int(result.rowcount or 0)

    def purge_all(self) -> int:
        if not self.enabled or self.engine is None:
            return 0
        with self.engine.begin() as conn:
            result = conn.execute(text(f"DELETE FROM {self.table_name}"))
            return int(result.rowcount or 0)

    def stats(self) -> dict[str, Any]:
        if not self.enabled or self.engine is None:
            return {"enabled": False}
        with self.engine.begin() as conn:
            total = conn.execute(text(f"SELECT COUNT(*) FROM {self.table_name}")).scalar_one()
            expired = conn.execute(
                text(f"SELECT COUNT(*) FROM {self.table_name} WHERE expires_at < :now"),
                {"now": datetime.now(UTC).replace(tzinfo=None)},
            ).scalar_one()
        return {
            "enabled": True,
            "backend": self.database_url.split(":", 1)[0],
            "total_entries": int(total),
            "expired_entries": int(expired),
        }

    def _as_utc(self, value: Any) -> datetime:
        if isinstance(value, datetime):
            if value.tzinfo is None:
                return value.replace(tzinfo=UTC)
            return value.astimezone(UTC)
        return datetime.fromisoformat(str(value)).replace(tzinfo=UTC)


cache = ResponseCache(settings.database_url, enabled=settings.cache_enabled)