longcache 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- longcache/__init__.py +11 -0
- longcache/backends/__init__.py +21 -0
- longcache/backends/async_redis.py +274 -0
- longcache/backends/base.py +167 -0
- longcache/backends/memory.py +243 -0
- longcache/backends/redis.py +374 -0
- longcache/backends/sqlite.py +521 -0
- longcache/cli.py +216 -0
- longcache/config.py +127 -0
- longcache/core/__init__.py +8 -0
- longcache/core/base_embedder.py +43 -0
- longcache/core/cache.py +872 -0
- longcache/core/embedder.py +155 -0
- longcache/core/entry.py +122 -0
- longcache/core/index.py +272 -0
- longcache/integrations/__init__.py +32 -0
- longcache/integrations/langchain.py +316 -0
- longcache/integrations/langgraph.py +150 -0
- longcache/integrations/llamaindex.py +103 -0
- longcache/integrations/openai.py +146 -0
- longcache/middleware.py +366 -0
- longcache-0.1.0.dist-info/METADATA +942 -0
- longcache-0.1.0.dist-info/RECORD +26 -0
- longcache-0.1.0.dist-info/WHEEL +4 -0
- longcache-0.1.0.dist-info/entry_points.txt +2 -0
- longcache-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite cache backend for LongCache.
|
|
3
|
+
|
|
4
|
+
Default backend with zero external dependencies (beyond Python stdlib).
|
|
5
|
+
Stores vectors as BLOBs, responses as JSON, and uses WAL mode
|
|
6
|
+
for thread-safe concurrent reads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from longcache.backends.base import AbstractCacheBackend
|
|
21
|
+
from longcache.core.entry import CacheEntry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SQLiteCacheBackend(AbstractCacheBackend):
|
|
25
|
+
"""
|
|
26
|
+
SQLite-based cache backend with WAL mode for concurrent access.
|
|
27
|
+
|
|
28
|
+
Vectors are stored as binary BLOBs (numpy float32 bytes).
|
|
29
|
+
Responses are JSON-serialized. All operations are thread-safe
|
|
30
|
+
via a reentrant lock.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
backend = SQLiteCacheBackend(db_path=".longcache/cache.db")
|
|
34
|
+
entry_id = backend.store(entry)
|
|
35
|
+
result = backend.retrieve(entry_id)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
SCHEMA = """
|
|
39
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
namespace TEXT NOT NULL,
|
|
42
|
+
query TEXT NOT NULL,
|
|
43
|
+
vector BLOB NOT NULL,
|
|
44
|
+
response TEXT NOT NULL,
|
|
45
|
+
created_at REAL NOT NULL,
|
|
46
|
+
expires_at REAL,
|
|
47
|
+
hit_count INTEGER DEFAULT 0,
|
|
48
|
+
model TEXT DEFAULT '',
|
|
49
|
+
system_prompt TEXT DEFAULT '',
|
|
50
|
+
temperature REAL DEFAULT -1.0
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_namespace ON cache_entries(namespace);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_expires ON cache_entries(expires_at);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_namespace_created ON cache_entries(namespace, created_at);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS cache_tags (
|
|
57
|
+
entry_id INTEGER NOT NULL REFERENCES cache_entries(id) ON DELETE CASCADE,
|
|
58
|
+
tag TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_tags_tag ON cache_tags(tag);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_tags_entry_id ON cache_tags(entry_id);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS cache_stats (
|
|
64
|
+
key TEXT PRIMARY KEY,
|
|
65
|
+
value INTEGER NOT NULL DEFAULT 0
|
|
66
|
+
);
|
|
67
|
+
INSERT OR IGNORE INTO cache_stats(key, value) VALUES ('hits', 0);
|
|
68
|
+
INSERT OR IGNORE INTO cache_stats(key, value) VALUES ('misses', 0);
|
|
69
|
+
INSERT OR IGNORE INTO cache_stats(key, value) VALUES ('total_saved_ms', 0);
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, db_path: str = ".longcache/cache.db", max_entries: int = 10_000):
|
|
73
|
+
"""
|
|
74
|
+
Initialize the SQLite backend.
|
|
75
|
+
|
|
76
|
+
Creates the database file and schema if they don't exist.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
db_path: Path to the SQLite database file.
|
|
80
|
+
Parent directories are created automatically.
|
|
81
|
+
max_entries: Maximum entries per namespace (used by evict_oldest).
|
|
82
|
+
"""
|
|
83
|
+
self._db_path = db_path
|
|
84
|
+
self._max_entries = max_entries
|
|
85
|
+
self._lock = threading.RLock()
|
|
86
|
+
self._local = threading.local()
|
|
87
|
+
|
|
88
|
+
# Ensure directory exists
|
|
89
|
+
db_dir = os.path.dirname(db_path)
|
|
90
|
+
if db_dir:
|
|
91
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Initialize schema
|
|
94
|
+
with self._get_connection() as conn:
|
|
95
|
+
conn.executescript(self.SCHEMA)
|
|
96
|
+
# Schema migrations for existing databases
|
|
97
|
+
cursor = conn.execute("PRAGMA table_info(cache_entries)")
|
|
98
|
+
columns = {row["name"] for row in cursor.fetchall()}
|
|
99
|
+
if "model" not in columns:
|
|
100
|
+
conn.execute("ALTER TABLE cache_entries ADD COLUMN model TEXT DEFAULT ''")
|
|
101
|
+
if "system_prompt" not in columns:
|
|
102
|
+
conn.execute("ALTER TABLE cache_entries ADD COLUMN system_prompt TEXT DEFAULT ''")
|
|
103
|
+
if "temperature" not in columns:
|
|
104
|
+
conn.execute("ALTER TABLE cache_entries ADD COLUMN temperature REAL DEFAULT -1.0")
|
|
105
|
+
|
|
106
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
107
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
108
|
+
conn.execute("PRAGMA cache_size=-64000") # 64MB page cache
|
|
109
|
+
conn.execute("PRAGMA temp_store=MEMORY") # temp tables in RAM
|
|
110
|
+
conn.execute("PRAGMA mmap_size=268435456") # 256MB memory-mapped I/O
|
|
111
|
+
|
|
112
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
113
|
+
"""
|
|
114
|
+
Get a thread-local database connection.
|
|
115
|
+
|
|
116
|
+
Each thread gets its own connection to avoid SQLite threading issues.
|
|
117
|
+
Connections are cached per-thread for performance.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
sqlite3.Connection instance.
|
|
121
|
+
"""
|
|
122
|
+
if not hasattr(self._local, "connection") or self._local.connection is None:
|
|
123
|
+
conn = sqlite3.connect(self._db_path, timeout=30.0)
|
|
124
|
+
conn.row_factory = sqlite3.Row
|
|
125
|
+
self._local.connection = conn
|
|
126
|
+
return self._local.connection
|
|
127
|
+
|
|
128
|
+
def _entry_to_row(self, entry: CacheEntry) -> dict:
|
|
129
|
+
"""Convert a CacheEntry to a row dict for database insertion."""
|
|
130
|
+
vector_bytes = entry.vector.tobytes() if entry.vector is not None else b""
|
|
131
|
+
response_json = json.dumps(entry.response, default=str)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"namespace": entry.namespace,
|
|
135
|
+
"query": entry.query,
|
|
136
|
+
"vector": vector_bytes,
|
|
137
|
+
"response": response_json,
|
|
138
|
+
"created_at": entry.created_at,
|
|
139
|
+
"expires_at": entry.expires_at,
|
|
140
|
+
"hit_count": entry.hit_count,
|
|
141
|
+
"model": entry.model,
|
|
142
|
+
"system_prompt": entry.system_prompt,
|
|
143
|
+
"temperature": entry.temperature,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def _row_to_entry(self, row) -> CacheEntry:
|
|
147
|
+
"""Convert a database row to a CacheEntry."""
|
|
148
|
+
vector = None
|
|
149
|
+
if row["vector"]:
|
|
150
|
+
vector = np.frombuffer(row["vector"], dtype=np.float32).copy()
|
|
151
|
+
|
|
152
|
+
response = row["response"]
|
|
153
|
+
if isinstance(response, str):
|
|
154
|
+
try:
|
|
155
|
+
response = json.loads(response)
|
|
156
|
+
except (json.JSONDecodeError, TypeError):
|
|
157
|
+
pass # Keep as string if not valid JSON
|
|
158
|
+
|
|
159
|
+
model = row["model"] if "model" in row.keys() else ""
|
|
160
|
+
system_prompt = row["system_prompt"] if "system_prompt" in row.keys() else ""
|
|
161
|
+
temperature = (
|
|
162
|
+
float(row["temperature"])
|
|
163
|
+
if "temperature" in row.keys() and row["temperature"] is not None
|
|
164
|
+
else -1.0
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
tags = []
|
|
168
|
+
try:
|
|
169
|
+
conn = self._get_connection()
|
|
170
|
+
tag_rows = conn.execute(
|
|
171
|
+
"SELECT tag FROM cache_tags WHERE entry_id = ?", (row["id"],)
|
|
172
|
+
).fetchall()
|
|
173
|
+
tags = [t["tag"] for t in tag_rows]
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return CacheEntry(
|
|
178
|
+
id=row["id"],
|
|
179
|
+
namespace=row["namespace"],
|
|
180
|
+
query=row["query"],
|
|
181
|
+
vector=vector,
|
|
182
|
+
response=response,
|
|
183
|
+
created_at=row["created_at"],
|
|
184
|
+
expires_at=row["expires_at"],
|
|
185
|
+
hit_count=row["hit_count"],
|
|
186
|
+
model=model,
|
|
187
|
+
system_prompt=system_prompt,
|
|
188
|
+
temperature=temperature,
|
|
189
|
+
tags=tags,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def store(self, entry: CacheEntry) -> int:
|
|
193
|
+
"""
|
|
194
|
+
Store a cache entry in the SQLite database.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
entry: The CacheEntry to store.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The auto-generated integer ID of the stored entry.
|
|
201
|
+
"""
|
|
202
|
+
row = self._entry_to_row(entry)
|
|
203
|
+
|
|
204
|
+
with self._lock:
|
|
205
|
+
conn = self._get_connection()
|
|
206
|
+
cursor = conn.execute(
|
|
207
|
+
"""
|
|
208
|
+
INSERT INTO cache_entries (namespace, query, vector, response, created_at, expires_at, hit_count, model, system_prompt, temperature)
|
|
209
|
+
VALUES (:namespace, :query, :vector, :response, :created_at, :expires_at, :hit_count, :model, :system_prompt, :temperature)
|
|
210
|
+
""",
|
|
211
|
+
row,
|
|
212
|
+
)
|
|
213
|
+
entry_id = cursor.lastrowid
|
|
214
|
+
if entry.tags:
|
|
215
|
+
tag_rows = [(entry_id, tag) for tag in entry.tags]
|
|
216
|
+
conn.executemany(
|
|
217
|
+
"INSERT INTO cache_tags (entry_id, tag) VALUES (?, ?)",
|
|
218
|
+
tag_rows,
|
|
219
|
+
)
|
|
220
|
+
conn.commit()
|
|
221
|
+
return entry_id
|
|
222
|
+
|
|
223
|
+
def retrieve(self, entry_id: int) -> Optional[CacheEntry]:
|
|
224
|
+
"""
|
|
225
|
+
Retrieve a cache entry by its ID.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
entry_id: The unique ID of the entry.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
CacheEntry if found and not expired, None otherwise.
|
|
232
|
+
"""
|
|
233
|
+
with self._lock:
|
|
234
|
+
conn = self._get_connection()
|
|
235
|
+
row = conn.execute("SELECT * FROM cache_entries WHERE id = ?", (entry_id,)).fetchone()
|
|
236
|
+
|
|
237
|
+
if row is None:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
entry = self._row_to_entry(row)
|
|
241
|
+
if entry.is_expired():
|
|
242
|
+
# Auto-delete expired entries on retrieval
|
|
243
|
+
self.delete(entry_id)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
return entry
|
|
247
|
+
|
|
248
|
+
def delete(self, entry_id: int) -> bool:
|
|
249
|
+
"""
|
|
250
|
+
Delete a cache entry by its ID.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
entry_id: The ID of the entry to delete.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if the entry was deleted, False if not found.
|
|
257
|
+
"""
|
|
258
|
+
with self._lock:
|
|
259
|
+
conn = self._get_connection()
|
|
260
|
+
conn.execute("DELETE FROM cache_tags WHERE entry_id = ?", (entry_id,))
|
|
261
|
+
cursor = conn.execute("DELETE FROM cache_entries WHERE id = ?", (entry_id,))
|
|
262
|
+
conn.commit()
|
|
263
|
+
return cursor.rowcount > 0
|
|
264
|
+
|
|
265
|
+
def delete_namespace(self, namespace: str) -> int:
|
|
266
|
+
"""
|
|
267
|
+
Delete all entries in a namespace.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
namespace: The namespace to clear.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The number of entries deleted.
|
|
274
|
+
"""
|
|
275
|
+
with self._lock:
|
|
276
|
+
conn = self._get_connection()
|
|
277
|
+
rows = conn.execute(
|
|
278
|
+
"SELECT id FROM cache_entries WHERE namespace = ?", (namespace,)
|
|
279
|
+
).fetchall()
|
|
280
|
+
if not rows:
|
|
281
|
+
return 0
|
|
282
|
+
entry_ids = [r["id"] for r in rows]
|
|
283
|
+
placeholders = ",".join("?" * len(entry_ids))
|
|
284
|
+
conn.execute(
|
|
285
|
+
f"DELETE FROM cache_tags WHERE entry_id IN ({placeholders})",
|
|
286
|
+
entry_ids,
|
|
287
|
+
)
|
|
288
|
+
cursor = conn.execute("DELETE FROM cache_entries WHERE namespace = ?", (namespace,))
|
|
289
|
+
conn.commit()
|
|
290
|
+
return cursor.rowcount
|
|
291
|
+
|
|
292
|
+
def delete_by_tag(self, tag: str, namespace: str) -> int:
|
|
293
|
+
"""
|
|
294
|
+
Delete all entries in a namespace that carry the given tag.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
tag: The tag string to match.
|
|
298
|
+
namespace: Scope the deletion to this namespace.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
The number of entries deleted.
|
|
302
|
+
"""
|
|
303
|
+
with self._lock:
|
|
304
|
+
conn = self._get_connection()
|
|
305
|
+
rows = conn.execute(
|
|
306
|
+
"""
|
|
307
|
+
SELECT DISTINCT t.entry_id FROM cache_tags t
|
|
308
|
+
JOIN cache_entries e ON e.id = t.entry_id
|
|
309
|
+
WHERE t.tag = ? AND e.namespace = ?
|
|
310
|
+
""",
|
|
311
|
+
(tag, namespace),
|
|
312
|
+
).fetchall()
|
|
313
|
+
if not rows:
|
|
314
|
+
return 0
|
|
315
|
+
entry_ids = [r["entry_id"] for r in rows]
|
|
316
|
+
placeholders = ",".join("?" * len(entry_ids))
|
|
317
|
+
conn.execute(
|
|
318
|
+
f"DELETE FROM cache_tags WHERE entry_id IN ({placeholders})",
|
|
319
|
+
entry_ids,
|
|
320
|
+
)
|
|
321
|
+
conn.execute(
|
|
322
|
+
f"DELETE FROM cache_entries WHERE id IN ({placeholders})",
|
|
323
|
+
entry_ids,
|
|
324
|
+
)
|
|
325
|
+
conn.commit()
|
|
326
|
+
return len(entry_ids)
|
|
327
|
+
|
|
328
|
+
def clear(self) -> int:
|
|
329
|
+
"""
|
|
330
|
+
Clear all entries across all namespaces.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The total number of entries deleted.
|
|
334
|
+
"""
|
|
335
|
+
with self._lock:
|
|
336
|
+
conn = self._get_connection()
|
|
337
|
+
conn.execute("DELETE FROM cache_tags")
|
|
338
|
+
count = conn.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0]
|
|
339
|
+
conn.execute("DELETE FROM cache_entries")
|
|
340
|
+
conn.commit()
|
|
341
|
+
return count
|
|
342
|
+
|
|
343
|
+
def count(self, namespace: str) -> int:
|
|
344
|
+
"""
|
|
345
|
+
Count entries in a namespace.
|
|
346
|
+
|
|
347
|
+
Only counts non-expired entries.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
namespace: The namespace to count.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
The number of non-expired entries.
|
|
354
|
+
"""
|
|
355
|
+
with self._lock:
|
|
356
|
+
conn = self._get_connection()
|
|
357
|
+
now = time.time()
|
|
358
|
+
row = conn.execute(
|
|
359
|
+
"""
|
|
360
|
+
SELECT COUNT(*) FROM cache_entries
|
|
361
|
+
WHERE namespace = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
362
|
+
""",
|
|
363
|
+
(namespace, now),
|
|
364
|
+
).fetchone()
|
|
365
|
+
return row[0]
|
|
366
|
+
|
|
367
|
+
def list_entries(self, namespace: str) -> list[CacheEntry]:
|
|
368
|
+
"""
|
|
369
|
+
List all non-expired entries in a namespace.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
namespace: The namespace to list.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of CacheEntry objects with vectors populated.
|
|
376
|
+
"""
|
|
377
|
+
with self._lock:
|
|
378
|
+
conn = self._get_connection()
|
|
379
|
+
now = time.time()
|
|
380
|
+
rows = conn.execute(
|
|
381
|
+
"""
|
|
382
|
+
SELECT * FROM cache_entries
|
|
383
|
+
WHERE namespace = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
384
|
+
ORDER BY created_at DESC
|
|
385
|
+
""",
|
|
386
|
+
(namespace, now),
|
|
387
|
+
).fetchall()
|
|
388
|
+
return [self._row_to_entry(row) for row in rows]
|
|
389
|
+
|
|
390
|
+
def update_hit_count(self, entry_id: int) -> None:
|
|
391
|
+
"""
|
|
392
|
+
Increment the hit count for a cache entry.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
entry_id: The ID of the entry to update.
|
|
396
|
+
"""
|
|
397
|
+
with self._lock:
|
|
398
|
+
conn = self._get_connection()
|
|
399
|
+
conn.execute(
|
|
400
|
+
"UPDATE cache_entries SET hit_count = hit_count + 1 WHERE id = ?",
|
|
401
|
+
(entry_id,),
|
|
402
|
+
)
|
|
403
|
+
conn.commit()
|
|
404
|
+
|
|
405
|
+
def evict_oldest(self, namespace: str, count: int) -> int:
|
|
406
|
+
"""
|
|
407
|
+
Evict the oldest entries in a namespace.
|
|
408
|
+
|
|
409
|
+
Deletes entries with the oldest created_at timestamps.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
namespace: The namespace to evict from.
|
|
413
|
+
count: The number of entries to evict.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The number of entries actually evicted.
|
|
417
|
+
"""
|
|
418
|
+
with self._lock:
|
|
419
|
+
conn = self._get_connection()
|
|
420
|
+
cursor = conn.execute(
|
|
421
|
+
"""
|
|
422
|
+
DELETE FROM cache_entries
|
|
423
|
+
WHERE id IN (
|
|
424
|
+
SELECT id FROM cache_entries
|
|
425
|
+
WHERE namespace = ?
|
|
426
|
+
ORDER BY created_at ASC
|
|
427
|
+
LIMIT ?
|
|
428
|
+
)
|
|
429
|
+
""",
|
|
430
|
+
(namespace, count),
|
|
431
|
+
)
|
|
432
|
+
conn.commit()
|
|
433
|
+
return cursor.rowcount
|
|
434
|
+
|
|
435
|
+
def get_expired(self, namespace: str) -> list[int]:
|
|
436
|
+
"""
|
|
437
|
+
Get IDs of all expired entries in a namespace.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
namespace: The namespace to check.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
List of expired entry IDs.
|
|
444
|
+
"""
|
|
445
|
+
with self._lock:
|
|
446
|
+
conn = self._get_connection()
|
|
447
|
+
now = time.time()
|
|
448
|
+
rows = conn.execute(
|
|
449
|
+
"""
|
|
450
|
+
SELECT id FROM cache_entries
|
|
451
|
+
WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?
|
|
452
|
+
""",
|
|
453
|
+
(namespace, now),
|
|
454
|
+
).fetchall()
|
|
455
|
+
return [row[0] for row in rows]
|
|
456
|
+
|
|
457
|
+
def export_all(self) -> list[dict]:
|
|
458
|
+
"""
|
|
459
|
+
Export all cache entries as dictionaries.
|
|
460
|
+
|
|
461
|
+
Used by the CLI export command.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
List of dictionaries with entry data.
|
|
465
|
+
"""
|
|
466
|
+
with self._lock:
|
|
467
|
+
conn = self._get_connection()
|
|
468
|
+
rows = conn.execute("SELECT * FROM cache_entries ORDER BY created_at DESC").fetchall()
|
|
469
|
+
results = []
|
|
470
|
+
for row in rows:
|
|
471
|
+
entry = self._row_to_entry(row)
|
|
472
|
+
results.append(
|
|
473
|
+
{
|
|
474
|
+
"id": entry.id,
|
|
475
|
+
"namespace": entry.namespace,
|
|
476
|
+
"query": entry.query,
|
|
477
|
+
"response": entry.response,
|
|
478
|
+
"created_at": entry.created_at,
|
|
479
|
+
"expires_at": entry.expires_at,
|
|
480
|
+
"hit_count": entry.hit_count,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
return results
|
|
484
|
+
|
|
485
|
+
def sweep_expired(self) -> int:
|
|
486
|
+
"""
|
|
487
|
+
Delete all expired entries across all namespaces.
|
|
488
|
+
Returns the number of entries deleted.
|
|
489
|
+
"""
|
|
490
|
+
with self._lock:
|
|
491
|
+
conn = self._get_connection()
|
|
492
|
+
now = time.time()
|
|
493
|
+
cursor = conn.execute(
|
|
494
|
+
"DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at <= ?", (now,)
|
|
495
|
+
)
|
|
496
|
+
conn.commit()
|
|
497
|
+
return cursor.rowcount
|
|
498
|
+
|
|
499
|
+
def increment_hits(self) -> None:
|
|
500
|
+
with self._lock:
|
|
501
|
+
self._get_connection().execute(
|
|
502
|
+
"UPDATE cache_stats SET value = value + 1 WHERE key = 'hits'"
|
|
503
|
+
)
|
|
504
|
+
self._get_connection().commit()
|
|
505
|
+
|
|
506
|
+
def increment_misses(self, saved_ms: int = 0) -> None:
|
|
507
|
+
with self._lock:
|
|
508
|
+
conn = self._get_connection()
|
|
509
|
+
conn.execute("UPDATE cache_stats SET value=value+1 WHERE key='misses'")
|
|
510
|
+
conn.execute(
|
|
511
|
+
"UPDATE cache_stats SET value=value+? WHERE key='total_saved_ms'", (int(saved_ms),)
|
|
512
|
+
)
|
|
513
|
+
conn.commit()
|
|
514
|
+
|
|
515
|
+
def load_stats(self) -> dict:
|
|
516
|
+
with self._lock:
|
|
517
|
+
rows = self._get_connection().execute("SELECT key, value FROM cache_stats").fetchall()
|
|
518
|
+
return {row["key"]: row["value"] for row in rows}
|
|
519
|
+
|
|
520
|
+
def __repr__(self) -> str:
|
|
521
|
+
return f"SQLiteCacheBackend(db_path={self._db_path!r})"
|