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.
@@ -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})"