superlocalmemory 3.5.7 → 3.6.0
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.
- package/ATTRIBUTION.md +24 -0
- package/CHANGELOG.md +40 -0
- package/README.md +142 -35
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/cache_cmd.py +198 -0
- package/src/superlocalmemory/cli/commands.py +100 -2
- package/src/superlocalmemory/cli/compress_cmd.py +179 -0
- package/src/superlocalmemory/cli/help_cmd.py +197 -0
- package/src/superlocalmemory/cli/main.py +122 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +175 -0
- package/src/superlocalmemory/cli/optimize_constants.py +31 -0
- package/src/superlocalmemory/cli/proxy_cmd.py +95 -0
- package/src/superlocalmemory/core/config.py +5 -0
- package/src/superlocalmemory/core/engine.py +23 -0
- package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
- package/src/superlocalmemory/infra/process_reaper.py +12 -1
- package/src/superlocalmemory/llm/backbone.py +10 -4
- package/src/superlocalmemory/mcp/server.py +34 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -2
- package/src/superlocalmemory/optimize/NOTICE +11 -0
- package/src/superlocalmemory/optimize/__init__.py +0 -0
- package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
- package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
- package/src/superlocalmemory/optimize/adapters/wrap.py +188 -0
- package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
- package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
- package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
- package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
- package/src/superlocalmemory/optimize/cache/exact.py +85 -0
- package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
- package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
- package/src/superlocalmemory/optimize/cache/manager.py +452 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
- package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
- package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
- package/src/superlocalmemory/optimize/compress/align.py +153 -0
- package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
- package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
- package/src/superlocalmemory/optimize/compress/router.py +548 -0
- package/src/superlocalmemory/optimize/config/__init__.py +35 -0
- package/src/superlocalmemory/optimize/config/defaults.py +48 -0
- package/src/superlocalmemory/optimize/config/schema.py +255 -0
- package/src/superlocalmemory/optimize/config/store.py +209 -0
- package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
- package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
- package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
- package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
- package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
- package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
- package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
- package/src/superlocalmemory/optimize/proxy/server.py +151 -0
- package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
- package/src/superlocalmemory/optimize/storage/db.py +1016 -0
- package/src/superlocalmemory/optimize/storage/schema.py +184 -0
- package/src/superlocalmemory/server/routes/optimize.py +166 -0
- package/src/superlocalmemory/server/routes/v3_api.py +63 -1
- package/src/superlocalmemory/server/unified_daemon.py +105 -0
- package/src/superlocalmemory/ui/index.html +98 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +2 -1
- package/src/superlocalmemory/ui/js/optimize.js +173 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +2 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
"""CacheDB — wraps DatabaseManager for llmcache.db operations.
|
|
2
|
+
|
|
3
|
+
INTERFACE-CONTRACT §1 CONFORMANCE: Every method below is the canonical API.
|
|
4
|
+
No implementer may add aliases. Consumers (LLD-02/03/04/08) import this exact API.
|
|
5
|
+
|
|
6
|
+
REUSE: DatabaseManager from superlocalmemory/src/superlocalmemory/storage/database.py
|
|
7
|
+
- WAL mode (database.py:66-74)
|
|
8
|
+
- busy_timeout=10000ms (database.py:41)
|
|
9
|
+
- _MAX_RETRIES=5 exponential backoff (database.py:127-148)
|
|
10
|
+
- _connect() with row_factory (database.py:95-100)
|
|
11
|
+
- transaction() context manager (database.py:102-116)
|
|
12
|
+
- execute() with retry (database.py:118-148)
|
|
13
|
+
|
|
14
|
+
ENCRYPTION (resolves SEC-C-01 / CWE-312, NEW-M-01, NEW-M-02):
|
|
15
|
+
- All value BLOBs (llmcache_entries.value_blob) are AES-256-GCM encrypted.
|
|
16
|
+
- CCR original_blob is ALSO AES-256-GCM encrypted.
|
|
17
|
+
- Key derivation: PBKDF2-HMAC-SHA256(password=machine_id, salt=_per_db_salt, iter=100_000)
|
|
18
|
+
- Salt: os.urandom(32) generated ONCE at DB creation, stored in
|
|
19
|
+
llmcache_schema_version.description='salt:<hex>'. NO hardcoded salt.
|
|
20
|
+
- Nonce (12 bytes random) prepended to each ciphertext.
|
|
21
|
+
- llmcache.db file permissions set to 0o600 at creation.
|
|
22
|
+
|
|
23
|
+
ISOLATION GUARANTEE: CacheDB MUST NOT import from superlocalmemory.storage.models.
|
|
24
|
+
Enforced by test_no_memory_db_import() in test_db.py.
|
|
25
|
+
|
|
26
|
+
FAIL-OPEN CONTRACT: Every public method catches sqlite3.Error, logs at WARNING,
|
|
27
|
+
and returns a safe default (None, [], False, 0) rather than raising.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import dataclasses
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import sqlite3
|
|
37
|
+
import struct
|
|
38
|
+
import time
|
|
39
|
+
import uuid
|
|
40
|
+
import zlib
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
46
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
47
|
+
from cryptography.hazmat.primitives import hashes
|
|
48
|
+
|
|
49
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
50
|
+
from superlocalmemory.optimize.storage import schema as _schema
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_DEFAULT_TENANT: str = "default"
|
|
55
|
+
_ZLIB_LEVEL: int = 6
|
|
56
|
+
_AES_NONCE_BYTES: int = 12
|
|
57
|
+
_PBKDF2_ITERATIONS: int = 100_000
|
|
58
|
+
|
|
59
|
+
LLMCACHE_DIRNAME: str = ".superlocalmemory"
|
|
60
|
+
LLMCACHE_DBNAME: str = "llmcache.db"
|
|
61
|
+
MID_FILENAME: str = ".llmcache_key"
|
|
62
|
+
SALT_PREFIX: str = "salt:"
|
|
63
|
+
|
|
64
|
+
_FORBIDDEN_MEMORY_TABLES: frozenset[str] = frozenset({
|
|
65
|
+
"memories", "atomic_facts", "profiles", "canonical_entities",
|
|
66
|
+
"entity_aliases", "consolidation_log", "trust_scores", "bm25_tokens",
|
|
67
|
+
"fact_retention", "core_memory_blocks", "ccq_consolidated_blocks",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# MetricsSnapshot — single definition (INTERFACE-CONTRACT v2.2 §6)
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MetricsSnapshot:
|
|
77
|
+
"""Mirror of llmcache_metrics columns — names MUST match exactly."""
|
|
78
|
+
id: int = 1
|
|
79
|
+
hits: int = 0
|
|
80
|
+
misses: int = 0
|
|
81
|
+
calls_skipped: int = 0
|
|
82
|
+
tokens_saved_input: int = 0
|
|
83
|
+
tokens_saved_output: int = 0
|
|
84
|
+
tokens_saved_compress: int = 0
|
|
85
|
+
evictions: int = 0
|
|
86
|
+
latency_overhead_ms_sum: float = 0.0
|
|
87
|
+
latency_samples: int = 0
|
|
88
|
+
compress_runs: int = 0
|
|
89
|
+
compress_bytes_original: int = 0
|
|
90
|
+
compress_bytes_after: int = 0
|
|
91
|
+
cache_size_bytes: int = 0
|
|
92
|
+
cache_entry_count: int = 0
|
|
93
|
+
updated_at: float = 0.0
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def hit_rate(self) -> float:
|
|
97
|
+
total = self.hits + self.misses
|
|
98
|
+
return self.hits / total if total > 0 else 0.0
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def avg_latency_overhead_ms(self) -> float:
|
|
102
|
+
return (
|
|
103
|
+
self.latency_overhead_ms_sum / self.latency_samples
|
|
104
|
+
if self.latency_samples > 0 else 0.0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def compression_ratio(self) -> float:
|
|
109
|
+
if self.compress_bytes_original == 0:
|
|
110
|
+
return 1.0
|
|
111
|
+
return self.compress_bytes_after / self.compress_bytes_original
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Supporting dataclasses
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CacheRow:
|
|
120
|
+
"""Return type of CacheDB.get() and get_by_id().
|
|
121
|
+
|
|
122
|
+
All fields are optional via default so that SELECT * on a table with
|
|
123
|
+
a different schema version does not blow up the constructor. The
|
|
124
|
+
caller is expected to read attributes they need; missing fields
|
|
125
|
+
return the default value.
|
|
126
|
+
"""
|
|
127
|
+
entry_id: str = ""
|
|
128
|
+
cache_key: str = ""
|
|
129
|
+
tenant_id: str = "default"
|
|
130
|
+
model: str = ""
|
|
131
|
+
provider: str = ""
|
|
132
|
+
value: bytes = b""
|
|
133
|
+
created_at: str = ""
|
|
134
|
+
last_hit_at: str | None = None
|
|
135
|
+
ttl_expires: float | None = None
|
|
136
|
+
hit_count: int = 0
|
|
137
|
+
byte_size: int = 0
|
|
138
|
+
tag_json: str = "[]"
|
|
139
|
+
cache_tier: str = "exact"
|
|
140
|
+
compressed: int = 1
|
|
141
|
+
tags: list[str] = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class BoundaryRow:
|
|
146
|
+
"""Per-item vCache MLE boundary record."""
|
|
147
|
+
entry_id: str
|
|
148
|
+
logistic_t: float = 0.95
|
|
149
|
+
logistic_gamma: float = 10.0
|
|
150
|
+
sample_count: int = 0
|
|
151
|
+
updated_at: float = 0.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# CacheDB
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
class CacheDB:
|
|
159
|
+
"""SQLite cache store backed by DatabaseManager. Canonical API per INTERFACE-CONTRACT §1."""
|
|
160
|
+
|
|
161
|
+
_default_instance: "CacheDB | None" = None
|
|
162
|
+
_default_lock: Any = None # lazy-import threading.Lock on first use
|
|
163
|
+
|
|
164
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
165
|
+
if db_path is None:
|
|
166
|
+
db_path = Path.home() / LLMCACHE_DIRNAME / LLMCACHE_DBNAME
|
|
167
|
+
self._db_path = Path(db_path)
|
|
168
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
# If the file exists but is not a valid SQLite database (e.g. user
|
|
170
|
+
# accidentally pointed us at a binary blob), delete it and start fresh
|
|
171
|
+
# so that the proxy never gets wedged on init. The proxy is the
|
|
172
|
+
# critical path — we must be able to start even if the cache file
|
|
173
|
+
# is corrupted. The previous file is left in a `.corrupt` sidecar
|
|
174
|
+
# for forensic inspection.
|
|
175
|
+
if self._db_path.exists():
|
|
176
|
+
try:
|
|
177
|
+
import sqlite3 as _sq
|
|
178
|
+
test_conn = _sq.connect(str(self._db_path))
|
|
179
|
+
test_conn.execute("PRAGMA schema_version")
|
|
180
|
+
test_conn.close()
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
corrupt_sidecar = self._db_path.with_suffix(
|
|
183
|
+
self._db_path.suffix + ".corrupt"
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
if corrupt_sidecar.exists():
|
|
187
|
+
corrupt_sidecar.unlink()
|
|
188
|
+
self._db_path.replace(corrupt_sidecar)
|
|
189
|
+
try:
|
|
190
|
+
os.chmod(corrupt_sidecar, 0o600)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
logger.error(
|
|
194
|
+
"CacheDB: %s was not a SQLite file (%s) — moved to %s. "
|
|
195
|
+
"Starting with a fresh cache.",
|
|
196
|
+
self._db_path, exc, corrupt_sidecar,
|
|
197
|
+
)
|
|
198
|
+
except OSError as move_exc:
|
|
199
|
+
logger.error(
|
|
200
|
+
"CacheDB: %s is not a SQLite file and could not be moved: %s",
|
|
201
|
+
self._db_path, move_exc,
|
|
202
|
+
)
|
|
203
|
+
raise
|
|
204
|
+
self._db = DatabaseManager(self._db_path)
|
|
205
|
+
self._db.initialize(_schema)
|
|
206
|
+
# chmod 600 (SEC-C-01)
|
|
207
|
+
try:
|
|
208
|
+
os.chmod(self._db_path, 0o600)
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
logger.warning("CacheDB: could not chmod 600 on %s: %s", self._db_path, exc)
|
|
211
|
+
self._salt = self._load_or_create_salt()
|
|
212
|
+
machine_id = self._get_machine_id()
|
|
213
|
+
self._aes_key = self._derive_aes_key(machine_id, self._salt)
|
|
214
|
+
self.assert_no_memory_db_tables()
|
|
215
|
+
|
|
216
|
+
# ---- context manager ----
|
|
217
|
+
def __enter__(self) -> "CacheDB":
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def __exit__(self, *args: Any) -> None:
|
|
221
|
+
self.close()
|
|
222
|
+
|
|
223
|
+
def close(self) -> None:
|
|
224
|
+
"""No-op (per-call connection model)."""
|
|
225
|
+
self._db.close()
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def db_path(self) -> str:
|
|
229
|
+
return str(self._db_path)
|
|
230
|
+
|
|
231
|
+
# ---- encryption helpers ----
|
|
232
|
+
|
|
233
|
+
def _load_or_create_salt(self) -> bytes:
|
|
234
|
+
rows = self._db.execute(
|
|
235
|
+
"SELECT description FROM llmcache_schema_version "
|
|
236
|
+
"WHERE description LIKE 'salt:%' LIMIT 1"
|
|
237
|
+
)
|
|
238
|
+
if rows:
|
|
239
|
+
hex_salt = dict(rows[0])["description"][len(SALT_PREFIX):]
|
|
240
|
+
if len(hex_salt) != 64:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
f"llmcache.db: salt row is malformed (len={len(hex_salt)}) — DB may be corrupted."
|
|
243
|
+
)
|
|
244
|
+
return bytes.fromhex(hex_salt)
|
|
245
|
+
salt = os.urandom(32)
|
|
246
|
+
self._db.execute(
|
|
247
|
+
"INSERT INTO llmcache_schema_version (version, description) VALUES (?, ?)",
|
|
248
|
+
(1, f"{SALT_PREFIX}{salt.hex()}"),
|
|
249
|
+
)
|
|
250
|
+
return salt
|
|
251
|
+
|
|
252
|
+
def _get_machine_id(self) -> str:
|
|
253
|
+
"""Return a stable machine identifier for AES key derivation."""
|
|
254
|
+
system = os.uname().sysname
|
|
255
|
+
mid: str | None = None
|
|
256
|
+
if system == "Darwin":
|
|
257
|
+
try:
|
|
258
|
+
import plistlib
|
|
259
|
+
import subprocess
|
|
260
|
+
out = subprocess.run(
|
|
261
|
+
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
|
|
262
|
+
capture_output=True, timeout=2,
|
|
263
|
+
)
|
|
264
|
+
if out.returncode == 0:
|
|
265
|
+
text = out.stdout.decode("utf-8", errors="ignore")
|
|
266
|
+
for line in text.splitlines():
|
|
267
|
+
if "IOPlatformUUID" in line and "=" in line:
|
|
268
|
+
mid = line.split("=", 1)[1].strip().strip('"')
|
|
269
|
+
if mid:
|
|
270
|
+
break
|
|
271
|
+
except Exception:
|
|
272
|
+
mid = None
|
|
273
|
+
elif system == "Linux":
|
|
274
|
+
try:
|
|
275
|
+
mid = Path("/etc/machine-id").read_text(encoding="utf-8").strip()
|
|
276
|
+
except OSError:
|
|
277
|
+
mid = None
|
|
278
|
+
if not mid:
|
|
279
|
+
mid_file = Path.home() / LLMCACHE_DIRNAME / MID_FILENAME
|
|
280
|
+
if mid_file.exists():
|
|
281
|
+
try:
|
|
282
|
+
mid = mid_file.read_text(encoding="utf-8").strip()
|
|
283
|
+
except OSError:
|
|
284
|
+
mid = None
|
|
285
|
+
if not mid:
|
|
286
|
+
mid = uuid.uuid4().hex
|
|
287
|
+
try:
|
|
288
|
+
mid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
mid_file.write_text(mid, encoding="utf-8")
|
|
290
|
+
os.chmod(mid_file, 0o600)
|
|
291
|
+
except OSError as exc:
|
|
292
|
+
logger.warning("CacheDB: could not persist machine id: %s", exc)
|
|
293
|
+
return mid
|
|
294
|
+
|
|
295
|
+
def _derive_aes_key(self, machine_id: str, salt: bytes) -> bytes:
|
|
296
|
+
kdf = PBKDF2HMAC(
|
|
297
|
+
algorithm=hashes.SHA256(),
|
|
298
|
+
length=32,
|
|
299
|
+
salt=salt,
|
|
300
|
+
iterations=_PBKDF2_ITERATIONS,
|
|
301
|
+
)
|
|
302
|
+
return kdf.derive(machine_id.encode("utf-8"))
|
|
303
|
+
|
|
304
|
+
def _encrypt(self, plaintext: bytes) -> bytes:
|
|
305
|
+
nonce = os.urandom(_AES_NONCE_BYTES)
|
|
306
|
+
aesgcm = AESGCM(self._aes_key)
|
|
307
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
|
|
308
|
+
return nonce + ciphertext
|
|
309
|
+
|
|
310
|
+
def _decrypt(self, blob: bytes) -> bytes:
|
|
311
|
+
if len(blob) <= _AES_NONCE_BYTES:
|
|
312
|
+
raise ValueError("Encrypted blob too short")
|
|
313
|
+
nonce = blob[:_AES_NONCE_BYTES]
|
|
314
|
+
ciphertext = blob[_AES_NONCE_BYTES:]
|
|
315
|
+
aesgcm = AESGCM(self._aes_key)
|
|
316
|
+
return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
|
|
317
|
+
|
|
318
|
+
# ---- assertion ----
|
|
319
|
+
|
|
320
|
+
def assert_no_memory_db_tables(self) -> None:
|
|
321
|
+
"""Raise RuntimeError if memory.db tables are present."""
|
|
322
|
+
rows = self._db.execute(
|
|
323
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
324
|
+
)
|
|
325
|
+
names = {dict(r)["name"] for r in rows}
|
|
326
|
+
found = _FORBIDDEN_MEMORY_TABLES & names
|
|
327
|
+
if found:
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
f"ISOLATION VIOLATION: llmcache.db contains memory.db tables: {sorted(found)}. "
|
|
330
|
+
"Wrong DB file opened. Aborting."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# ---- exact CRUD ----
|
|
334
|
+
|
|
335
|
+
def get(self, key: str, tenant_id: str) -> CacheRow | None:
|
|
336
|
+
try:
|
|
337
|
+
rows = self._db.execute(
|
|
338
|
+
"SELECT * FROM llmcache_entries "
|
|
339
|
+
"WHERE cache_key = ? AND tenant_id = ? "
|
|
340
|
+
"AND (ttl_expires IS NULL OR ttl_expires > ?) "
|
|
341
|
+
"LIMIT 1",
|
|
342
|
+
(key, tenant_id, time.time()),
|
|
343
|
+
)
|
|
344
|
+
if not rows:
|
|
345
|
+
return None
|
|
346
|
+
row = dict(rows[0])
|
|
347
|
+
# increment hit stats
|
|
348
|
+
self._db.execute(
|
|
349
|
+
"UPDATE llmcache_entries SET hit_count = hit_count + 1, "
|
|
350
|
+
"last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
351
|
+
"WHERE cache_key = ? AND tenant_id = ?",
|
|
352
|
+
(key, tenant_id),
|
|
353
|
+
)
|
|
354
|
+
row["hit_count"] = row["hit_count"] + 1
|
|
355
|
+
# decrypt + decompress value
|
|
356
|
+
try:
|
|
357
|
+
plaintext = self._decrypt(row["value_blob"])
|
|
358
|
+
if row.get("compressed", 0):
|
|
359
|
+
plaintext = zlib.decompress(plaintext)
|
|
360
|
+
row["value"] = plaintext
|
|
361
|
+
except (ValueError, zlib.error) as exc:
|
|
362
|
+
logger.warning("CacheDB.get decrypt failed (cache miss): %s", exc)
|
|
363
|
+
return None
|
|
364
|
+
return self._row_to_cacherow(row)
|
|
365
|
+
except sqlite3.Error as exc:
|
|
366
|
+
logger.warning("CacheDB.get failed (cache miss): %s", exc)
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
def set(
|
|
370
|
+
self,
|
|
371
|
+
key: str,
|
|
372
|
+
tenant_id: str,
|
|
373
|
+
value: bytes,
|
|
374
|
+
*,
|
|
375
|
+
model: str,
|
|
376
|
+
ttl_expires: float | None,
|
|
377
|
+
tags: list[str],
|
|
378
|
+
) -> None:
|
|
379
|
+
try:
|
|
380
|
+
# 1) zlib-compress
|
|
381
|
+
compressed_blob = zlib.compress(value, level=_ZLIB_LEVEL)
|
|
382
|
+
# 2) AES-256-GCM encrypt
|
|
383
|
+
encrypted_blob = self._encrypt(compressed_blob)
|
|
384
|
+
entry_id = uuid.uuid4().hex
|
|
385
|
+
self._db.execute(
|
|
386
|
+
"INSERT INTO llmcache_entries "
|
|
387
|
+
"(entry_id, cache_key, tenant_id, model, provider, value_blob, compressed, "
|
|
388
|
+
" ttl_expires, tag_json, byte_size) "
|
|
389
|
+
"VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) "
|
|
390
|
+
"ON CONFLICT(cache_key, tenant_id) DO UPDATE SET "
|
|
391
|
+
" value_blob=excluded.value_blob, model=excluded.model, provider=excluded.provider, "
|
|
392
|
+
" ttl_expires=excluded.ttl_expires, tag_json=excluded.tag_json, "
|
|
393
|
+
" byte_size=excluded.byte_size, hit_count=0, last_hit_at=NULL, "
|
|
394
|
+
" created_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
|
|
395
|
+
(
|
|
396
|
+
entry_id, key, tenant_id, model, "",
|
|
397
|
+
encrypted_blob, ttl_expires,
|
|
398
|
+
json.dumps(tags or []), len(encrypted_blob),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
except sqlite3.Error as exc:
|
|
402
|
+
logger.warning("CacheDB.set failed (fail-open): %s", exc)
|
|
403
|
+
|
|
404
|
+
def set_with_entry_id(
|
|
405
|
+
self,
|
|
406
|
+
key: str,
|
|
407
|
+
tenant_id: str,
|
|
408
|
+
value: bytes,
|
|
409
|
+
entry_id: str,
|
|
410
|
+
ttl_seconds: int | None = None,
|
|
411
|
+
tags: list[str] | None = None,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Like set(), but writes a caller-supplied entry_id instead of auto-generating one.
|
|
414
|
+
|
|
415
|
+
Used by semantic layer (index_entry) which pre-computes the entry_id from
|
|
416
|
+
the embedding vector to enable O(1) semantic-to-cache cross-reference.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
compressed_blob = zlib.compress(value, level=_ZLIB_LEVEL)
|
|
420
|
+
encrypted_blob = self._encrypt(compressed_blob)
|
|
421
|
+
ttl_expires = (time.time() + ttl_seconds) if ttl_seconds else None
|
|
422
|
+
self._db.execute(
|
|
423
|
+
"INSERT INTO llmcache_entries "
|
|
424
|
+
"(entry_id, cache_key, tenant_id, model, provider, value_blob, compressed, "
|
|
425
|
+
" ttl_expires, tag_json, byte_size) "
|
|
426
|
+
"VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) "
|
|
427
|
+
"ON CONFLICT(cache_key, tenant_id) DO UPDATE SET "
|
|
428
|
+
" entry_id=excluded.entry_id, value_blob=excluded.value_blob, "
|
|
429
|
+
" model=excluded.model, provider=excluded.provider, "
|
|
430
|
+
" ttl_expires=excluded.ttl_expires, tag_json=excluded.tag_json, "
|
|
431
|
+
" byte_size=excluded.byte_size, hit_count=0, last_hit_at=NULL, "
|
|
432
|
+
" created_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
|
|
433
|
+
(
|
|
434
|
+
entry_id, key, tenant_id, "", "",
|
|
435
|
+
encrypted_blob, ttl_expires,
|
|
436
|
+
json.dumps(tags or []), len(encrypted_blob),
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
except sqlite3.Error as exc:
|
|
440
|
+
logger.warning("CacheDB.set_with_entry_id failed (fail-open): %s", exc)
|
|
441
|
+
|
|
442
|
+
def get_by_id(self, entry_id: str) -> CacheRow | None:
|
|
443
|
+
try:
|
|
444
|
+
rows = self._db.execute(
|
|
445
|
+
"SELECT * FROM llmcache_entries WHERE entry_id = ? LIMIT 1",
|
|
446
|
+
(entry_id,),
|
|
447
|
+
)
|
|
448
|
+
if not rows:
|
|
449
|
+
return None
|
|
450
|
+
row = dict(rows[0])
|
|
451
|
+
try:
|
|
452
|
+
plaintext = self._decrypt(row["value_blob"])
|
|
453
|
+
if row.get("compressed", 0):
|
|
454
|
+
plaintext = zlib.decompress(plaintext)
|
|
455
|
+
row["value"] = plaintext
|
|
456
|
+
except (ValueError, zlib.error) as exc:
|
|
457
|
+
logger.warning("CacheDB.get_by_id decrypt failed: %s", exc)
|
|
458
|
+
return None
|
|
459
|
+
return self._row_to_cacherow(row)
|
|
460
|
+
except sqlite3.Error as exc:
|
|
461
|
+
logger.warning("CacheDB.get_by_id failed: %s", exc)
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def delete(self, key: str, tenant_id: str) -> None:
|
|
465
|
+
try:
|
|
466
|
+
with self._db.transaction():
|
|
467
|
+
rows = self._db.execute(
|
|
468
|
+
"SELECT entry_id FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
|
|
469
|
+
(key, tenant_id),
|
|
470
|
+
)
|
|
471
|
+
entry_ids = [dict(r)["entry_id"] for r in rows]
|
|
472
|
+
if entry_ids:
|
|
473
|
+
placeholders = ",".join("?" for _ in entry_ids)
|
|
474
|
+
self._db.execute(
|
|
475
|
+
f"DELETE FROM llmcache_semantic_vectors "
|
|
476
|
+
f"WHERE entry_id IN ({placeholders})",
|
|
477
|
+
tuple(entry_ids),
|
|
478
|
+
)
|
|
479
|
+
self._db.execute(
|
|
480
|
+
"DELETE FROM llmcache_tags WHERE cache_key = ? AND tenant_id = ?",
|
|
481
|
+
(key, tenant_id),
|
|
482
|
+
)
|
|
483
|
+
self._db.execute(
|
|
484
|
+
"DELETE FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
|
|
485
|
+
(key, tenant_id),
|
|
486
|
+
)
|
|
487
|
+
except sqlite3.Error as exc:
|
|
488
|
+
logger.warning("CacheDB.delete failed (fail-open): %s", exc)
|
|
489
|
+
|
|
490
|
+
def delete_batch(self, keys: list[tuple[str, str]]) -> int:
|
|
491
|
+
if not keys:
|
|
492
|
+
return 0
|
|
493
|
+
try:
|
|
494
|
+
deleted = 0
|
|
495
|
+
with self._db.transaction():
|
|
496
|
+
for key, tenant_id in keys:
|
|
497
|
+
rows = self._db.execute(
|
|
498
|
+
"SELECT entry_id FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
|
|
499
|
+
(key, tenant_id),
|
|
500
|
+
)
|
|
501
|
+
entry_ids = [dict(r)["entry_id"] for r in rows]
|
|
502
|
+
if entry_ids:
|
|
503
|
+
placeholders = ",".join("?" for _ in entry_ids)
|
|
504
|
+
self._db.execute(
|
|
505
|
+
f"DELETE FROM llmcache_semantic_vectors "
|
|
506
|
+
f"WHERE entry_id IN ({placeholders})",
|
|
507
|
+
tuple(entry_ids),
|
|
508
|
+
)
|
|
509
|
+
self._db.execute(
|
|
510
|
+
"DELETE FROM llmcache_tags WHERE cache_key = ? AND tenant_id = ?",
|
|
511
|
+
(key, tenant_id),
|
|
512
|
+
)
|
|
513
|
+
self._db.execute(
|
|
514
|
+
"DELETE FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
|
|
515
|
+
(key, tenant_id),
|
|
516
|
+
)
|
|
517
|
+
deleted += 1
|
|
518
|
+
return deleted
|
|
519
|
+
except sqlite3.Error as exc:
|
|
520
|
+
logger.warning("CacheDB.delete_batch failed: %s", exc)
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
def sweep_expired(self, now: float) -> int:
|
|
524
|
+
try:
|
|
525
|
+
with self._db.transaction():
|
|
526
|
+
rows = self._db.execute(
|
|
527
|
+
"SELECT entry_id FROM llmcache_entries "
|
|
528
|
+
"WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
|
|
529
|
+
(now,),
|
|
530
|
+
)
|
|
531
|
+
entry_ids = [dict(r)["entry_id"] for r in rows]
|
|
532
|
+
entries_deleted = len(entry_ids)
|
|
533
|
+
if entry_ids:
|
|
534
|
+
placeholders = ",".join("?" for _ in entry_ids)
|
|
535
|
+
self._db.execute(
|
|
536
|
+
f"DELETE FROM llmcache_semantic_vectors "
|
|
537
|
+
f"WHERE entry_id IN ({placeholders})",
|
|
538
|
+
tuple(entry_ids),
|
|
539
|
+
)
|
|
540
|
+
self._db.execute(
|
|
541
|
+
f"DELETE FROM llmcache_tags "
|
|
542
|
+
f"WHERE cache_key IN ("
|
|
543
|
+
f" SELECT cache_key FROM llmcache_entries WHERE entry_id IN ({placeholders})"
|
|
544
|
+
f")",
|
|
545
|
+
tuple(entry_ids),
|
|
546
|
+
)
|
|
547
|
+
self._db.execute(
|
|
548
|
+
"DELETE FROM llmcache_entries "
|
|
549
|
+
"WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
|
|
550
|
+
(now,),
|
|
551
|
+
)
|
|
552
|
+
ccr_row = self._db.execute(
|
|
553
|
+
"SELECT COUNT(*) AS n FROM llmcache_ccr_originals "
|
|
554
|
+
"WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
|
|
555
|
+
(now,),
|
|
556
|
+
)
|
|
557
|
+
ccr_count = int(dict(ccr_row[0])["n"]) if ccr_row else 0
|
|
558
|
+
self._db.execute(
|
|
559
|
+
"DELETE FROM llmcache_ccr_originals "
|
|
560
|
+
"WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
|
|
561
|
+
(now,),
|
|
562
|
+
)
|
|
563
|
+
return entries_deleted + ccr_count
|
|
564
|
+
except sqlite3.Error as exc:
|
|
565
|
+
logger.warning("CacheDB.sweep_expired failed: %s", exc)
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
# ---- tags ----
|
|
569
|
+
|
|
570
|
+
def tag_register(self, key: str, tenant_id: str, tags: list[str]) -> None:
|
|
571
|
+
if not tags:
|
|
572
|
+
return
|
|
573
|
+
try:
|
|
574
|
+
with self._db.transaction():
|
|
575
|
+
for tag in tags:
|
|
576
|
+
self._db.execute(
|
|
577
|
+
"INSERT OR IGNORE INTO llmcache_tags (tag, cache_key, tenant_id) "
|
|
578
|
+
"VALUES (?, ?, ?)",
|
|
579
|
+
(tag, key, tenant_id),
|
|
580
|
+
)
|
|
581
|
+
except sqlite3.Error as exc:
|
|
582
|
+
logger.warning("CacheDB.tag_register failed: %s", exc)
|
|
583
|
+
|
|
584
|
+
def tag_keys(self, tag: str) -> list[tuple[str, str]]:
|
|
585
|
+
try:
|
|
586
|
+
rows = self._db.execute(
|
|
587
|
+
"SELECT cache_key, tenant_id FROM llmcache_tags WHERE tag = ?",
|
|
588
|
+
(tag,),
|
|
589
|
+
)
|
|
590
|
+
return [(dict(r)["cache_key"], dict(r)["tenant_id"]) for r in rows]
|
|
591
|
+
except sqlite3.Error as exc:
|
|
592
|
+
logger.warning("CacheDB.tag_keys failed: %s", exc)
|
|
593
|
+
return []
|
|
594
|
+
|
|
595
|
+
def invalidate_by_tag(self, tag: str) -> int:
|
|
596
|
+
try:
|
|
597
|
+
with self._db.transaction():
|
|
598
|
+
rows = self._db.execute(
|
|
599
|
+
"SELECT DISTINCT e.entry_id FROM llmcache_entries e "
|
|
600
|
+
"JOIN llmcache_tags t ON t.cache_key = e.cache_key AND t.tenant_id = e.tenant_id "
|
|
601
|
+
"WHERE t.tag = ?",
|
|
602
|
+
(tag,),
|
|
603
|
+
)
|
|
604
|
+
entry_ids = [dict(r)["entry_id"] for r in rows]
|
|
605
|
+
if not entry_ids:
|
|
606
|
+
self._db.execute("DELETE FROM llmcache_tags WHERE tag = ?", (tag,))
|
|
607
|
+
return 0
|
|
608
|
+
placeholders = ",".join("?" for _ in entry_ids)
|
|
609
|
+
self._db.execute(
|
|
610
|
+
f"DELETE FROM llmcache_semantic_vectors WHERE entry_id IN ({placeholders})",
|
|
611
|
+
tuple(entry_ids),
|
|
612
|
+
)
|
|
613
|
+
self._db.execute(
|
|
614
|
+
f"DELETE FROM llmcache_entries WHERE entry_id IN ({placeholders})",
|
|
615
|
+
tuple(entry_ids),
|
|
616
|
+
)
|
|
617
|
+
self._db.execute("DELETE FROM llmcache_tags WHERE tag = ?", (tag,))
|
|
618
|
+
return len(entry_ids)
|
|
619
|
+
except sqlite3.Error as exc:
|
|
620
|
+
logger.warning("CacheDB.invalidate_by_tag failed: %s", exc)
|
|
621
|
+
return 0
|
|
622
|
+
|
|
623
|
+
# ---- semantic vectors ----
|
|
624
|
+
|
|
625
|
+
def vec_add(self, entry_id: str, tenant_id: str, vector: bytes, meta: dict) -> None:
|
|
626
|
+
try:
|
|
627
|
+
dim = int(meta.get("dim", len(vector) // 4))
|
|
628
|
+
model_name = str(meta.get("model", "nomic-ai/nomic-embed-text-v1.5"))
|
|
629
|
+
self._db.execute(
|
|
630
|
+
"INSERT OR REPLACE INTO llmcache_semantic_vectors "
|
|
631
|
+
"(entry_id, tenant_id, vector_blob, vector_dim, model_name) "
|
|
632
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
633
|
+
(entry_id, tenant_id, vector, dim, model_name),
|
|
634
|
+
)
|
|
635
|
+
except sqlite3.Error as exc:
|
|
636
|
+
logger.warning("CacheDB.vec_add failed: %s", exc)
|
|
637
|
+
|
|
638
|
+
def vec_search(self, tenant_id: str, vector: bytes, top_k: int) -> list[tuple[str, float]]:
|
|
639
|
+
try:
|
|
640
|
+
rows = self._db.execute(
|
|
641
|
+
"SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
|
|
642
|
+
"WHERE tenant_id = ?",
|
|
643
|
+
(tenant_id,),
|
|
644
|
+
)
|
|
645
|
+
try:
|
|
646
|
+
import numpy as _np
|
|
647
|
+
except ImportError:
|
|
648
|
+
return []
|
|
649
|
+
q = _np.frombuffer(vector, dtype=_np.float32)
|
|
650
|
+
q_norm = float(_np.linalg.norm(q))
|
|
651
|
+
if q_norm == 0:
|
|
652
|
+
return []
|
|
653
|
+
scored: list[tuple[str, float]] = []
|
|
654
|
+
for r in rows:
|
|
655
|
+
rd = dict(r)
|
|
656
|
+
v = _np.frombuffer(rd["vector_blob"], dtype=_np.float32)
|
|
657
|
+
v_norm = float(_np.linalg.norm(v))
|
|
658
|
+
if v_norm == 0:
|
|
659
|
+
continue
|
|
660
|
+
cos = float(_np.dot(q, v) / (q_norm * v_norm))
|
|
661
|
+
scored.append((rd["entry_id"], cos))
|
|
662
|
+
scored.sort(key=lambda t: t[1], reverse=True)
|
|
663
|
+
return scored[: max(0, top_k)]
|
|
664
|
+
except sqlite3.Error as exc:
|
|
665
|
+
logger.warning("CacheDB.vec_search failed: %s", exc)
|
|
666
|
+
return []
|
|
667
|
+
|
|
668
|
+
def vec_delete(self, entry_id: str) -> None:
|
|
669
|
+
try:
|
|
670
|
+
self._db.execute(
|
|
671
|
+
"DELETE FROM llmcache_semantic_vectors WHERE entry_id = ?",
|
|
672
|
+
(entry_id,),
|
|
673
|
+
)
|
|
674
|
+
except sqlite3.Error as exc:
|
|
675
|
+
logger.warning("CacheDB.vec_delete failed: %s", exc)
|
|
676
|
+
|
|
677
|
+
# ---- vCache boundary + centroids (Phase 3 hooks, stub-implemented) ----
|
|
678
|
+
|
|
679
|
+
def boundary_get(self, entry_id: str) -> BoundaryRow | None:
|
|
680
|
+
try:
|
|
681
|
+
rows = self._db.execute(
|
|
682
|
+
"SELECT * FROM llmcache_boundaries WHERE entry_id = ? LIMIT 1",
|
|
683
|
+
(entry_id,),
|
|
684
|
+
)
|
|
685
|
+
if not rows:
|
|
686
|
+
return None
|
|
687
|
+
d = dict(rows[0])
|
|
688
|
+
return BoundaryRow(**d)
|
|
689
|
+
except sqlite3.Error as exc:
|
|
690
|
+
logger.warning("CacheDB.boundary_get failed: %s", exc)
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
def boundary_upsert(self, entry_id: str, row: BoundaryRow) -> None:
|
|
694
|
+
try:
|
|
695
|
+
self._db.execute(
|
|
696
|
+
"INSERT INTO llmcache_boundaries "
|
|
697
|
+
"(entry_id, logistic_t, logistic_gamma, sample_count, updated_at) "
|
|
698
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
699
|
+
"ON CONFLICT(entry_id) DO UPDATE SET "
|
|
700
|
+
" logistic_t=excluded.logistic_t, logistic_gamma=excluded.logistic_gamma, "
|
|
701
|
+
" sample_count=excluded.sample_count, updated_at=excluded.updated_at",
|
|
702
|
+
(
|
|
703
|
+
entry_id, row.logistic_t, row.logistic_gamma,
|
|
704
|
+
row.sample_count, row.updated_at,
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
except sqlite3.Error as exc:
|
|
708
|
+
logger.warning("CacheDB.boundary_upsert failed: %s", exc)
|
|
709
|
+
|
|
710
|
+
def centroid_get(self, tenant_id: str) -> bytes | None:
|
|
711
|
+
try:
|
|
712
|
+
rows = self._db.execute(
|
|
713
|
+
"SELECT centroid_blob FROM llmcache_centroids WHERE tenant_id = ? LIMIT 1",
|
|
714
|
+
(tenant_id,),
|
|
715
|
+
)
|
|
716
|
+
if not rows:
|
|
717
|
+
return None
|
|
718
|
+
return dict(rows[0])["centroid_blob"]
|
|
719
|
+
except sqlite3.Error as exc:
|
|
720
|
+
logger.warning("CacheDB.centroid_get failed: %s", exc)
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
def centroid_update(self, tenant_id: str, centroid: bytes, n: int) -> None:
|
|
724
|
+
try:
|
|
725
|
+
self._db.execute(
|
|
726
|
+
"INSERT INTO llmcache_centroids (tenant_id, centroid_blob, n) "
|
|
727
|
+
"VALUES (?, ?, ?) "
|
|
728
|
+
"ON CONFLICT(tenant_id) DO UPDATE SET "
|
|
729
|
+
" centroid_blob=excluded.centroid_blob, n=excluded.n, "
|
|
730
|
+
" updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
|
|
731
|
+
(tenant_id, centroid, n),
|
|
732
|
+
)
|
|
733
|
+
except sqlite3.Error as exc:
|
|
734
|
+
logger.warning("CacheDB.centroid_update failed: %s", exc)
|
|
735
|
+
|
|
736
|
+
# ---- v2.2 supplementary boundary methods (LLD-03 Phase 3) ----
|
|
737
|
+
|
|
738
|
+
def get_all_boundaries(self) -> list[dict[str, Any]]:
|
|
739
|
+
"""Return all vCache MLE boundary records (warm-start / rebuild).
|
|
740
|
+
|
|
741
|
+
LLD-03 §4.4: called at VCacheSemantic startup to populate the
|
|
742
|
+
in-memory boundary cache.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
List of dicts with keys: entry_id, t_hat, gamma_hat,
|
|
746
|
+
sample_count, updated_at. [] on error.
|
|
747
|
+
"""
|
|
748
|
+
try:
|
|
749
|
+
rows = self._db.execute(
|
|
750
|
+
"SELECT entry_id, logistic_t, logistic_gamma, sample_count, updated_at "
|
|
751
|
+
"FROM llmcache_boundaries"
|
|
752
|
+
)
|
|
753
|
+
return [dict(r) for r in rows]
|
|
754
|
+
except sqlite3.Error as exc:
|
|
755
|
+
logger.warning("CacheDB.get_all_boundaries failed: %s", exc)
|
|
756
|
+
return []
|
|
757
|
+
|
|
758
|
+
def delete_boundary(self, entry_id: str) -> None:
|
|
759
|
+
"""Delete a vCache boundary record. Fail-open."""
|
|
760
|
+
try:
|
|
761
|
+
self._db.execute(
|
|
762
|
+
"DELETE FROM llmcache_boundaries WHERE entry_id = ?",
|
|
763
|
+
(entry_id,),
|
|
764
|
+
)
|
|
765
|
+
except sqlite3.Error as exc:
|
|
766
|
+
logger.warning("CacheDB.delete_boundary failed: %s", exc)
|
|
767
|
+
|
|
768
|
+
def get_entry_by_id(self, entry_id: str) -> dict[str, Any] | None:
|
|
769
|
+
"""Fetch a cache entry by entry_id and return its decoded response dict.
|
|
770
|
+
|
|
771
|
+
LLD-03 §4.5 _fetch_response: needed to retrieve the response for a
|
|
772
|
+
semantically matched entry_id. Decrypts + decompresses the value_blob.
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
Response dict (parsed JSON), or None on miss / error.
|
|
776
|
+
"""
|
|
777
|
+
try:
|
|
778
|
+
rows = self._db.execute(
|
|
779
|
+
"SELECT value_blob, compressed FROM llmcache_entries "
|
|
780
|
+
"WHERE entry_id = ? LIMIT 1",
|
|
781
|
+
(entry_id,),
|
|
782
|
+
)
|
|
783
|
+
if not rows:
|
|
784
|
+
return None
|
|
785
|
+
row = dict(rows[0])
|
|
786
|
+
try:
|
|
787
|
+
plaintext = self._decrypt(row["value_blob"])
|
|
788
|
+
if row.get("compressed", 0):
|
|
789
|
+
plaintext = zlib.decompress(plaintext)
|
|
790
|
+
except (ValueError, zlib.error) as exc:
|
|
791
|
+
logger.warning("CacheDB.get_entry_by_id decrypt failed: %s", exc)
|
|
792
|
+
return None
|
|
793
|
+
try:
|
|
794
|
+
import json as _json
|
|
795
|
+
return _json.loads(plaintext.decode("utf-8"))
|
|
796
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
797
|
+
return {"raw_bytes": plaintext.hex()}
|
|
798
|
+
except sqlite3.Error as exc:
|
|
799
|
+
logger.warning("CacheDB.get_entry_by_id failed: %s", exc)
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
# ---- CCR originals ----
|
|
803
|
+
|
|
804
|
+
def ccr_put(
|
|
805
|
+
self,
|
|
806
|
+
ccr_id: str,
|
|
807
|
+
original: bytes,
|
|
808
|
+
ttl_expires: float | None = None,
|
|
809
|
+
) -> None:
|
|
810
|
+
import hashlib
|
|
811
|
+
try:
|
|
812
|
+
compressed = zlib.compress(original, level=_ZLIB_LEVEL)
|
|
813
|
+
encrypted = self._encrypt(compressed)
|
|
814
|
+
self._db.execute(
|
|
815
|
+
"INSERT OR REPLACE INTO llmcache_ccr_originals "
|
|
816
|
+
"(ccr_id, original_blob, compressed_hash, byte_size_orig, byte_size_comp, ttl_expires) "
|
|
817
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
818
|
+
(
|
|
819
|
+
ccr_id, encrypted,
|
|
820
|
+
hashlib.sha256(original).hexdigest(),
|
|
821
|
+
len(original), len(compressed),
|
|
822
|
+
ttl_expires,
|
|
823
|
+
),
|
|
824
|
+
)
|
|
825
|
+
except sqlite3.Error as exc:
|
|
826
|
+
logger.warning("CacheDB.ccr_put failed: %s", exc)
|
|
827
|
+
|
|
828
|
+
def ccr_get(self, ccr_id: str) -> bytes | None:
|
|
829
|
+
try:
|
|
830
|
+
rows = self._db.execute(
|
|
831
|
+
"SELECT original_blob FROM llmcache_ccr_originals "
|
|
832
|
+
"WHERE ccr_id = ? "
|
|
833
|
+
"AND (ttl_expires IS NULL OR ttl_expires > ?)",
|
|
834
|
+
(ccr_id, time.time()),
|
|
835
|
+
)
|
|
836
|
+
if not rows:
|
|
837
|
+
return None
|
|
838
|
+
blob = dict(rows[0])["original_blob"]
|
|
839
|
+
plaintext = self._decrypt(blob)
|
|
840
|
+
return zlib.decompress(plaintext)
|
|
841
|
+
except (sqlite3.Error, ValueError, zlib.error) as exc:
|
|
842
|
+
logger.warning("CacheDB.ccr_get failed: %s", exc)
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
def ccr_update_compressed(self, ccr_id: str, compressed: bytes) -> None:
|
|
846
|
+
import hashlib
|
|
847
|
+
try:
|
|
848
|
+
self._db.execute(
|
|
849
|
+
"UPDATE llmcache_ccr_originals "
|
|
850
|
+
"SET compressed_hash = ?, byte_size_comp = ? WHERE ccr_id = ?",
|
|
851
|
+
(hashlib.sha256(compressed).hexdigest(), len(compressed), ccr_id),
|
|
852
|
+
)
|
|
853
|
+
except sqlite3.Error as exc:
|
|
854
|
+
logger.warning("CacheDB.ccr_update_compressed failed: %s", exc)
|
|
855
|
+
|
|
856
|
+
# ---- v2 additions ----
|
|
857
|
+
|
|
858
|
+
def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes]]:
|
|
859
|
+
try:
|
|
860
|
+
rows = self._db.execute(
|
|
861
|
+
"SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
|
|
862
|
+
"WHERE tenant_id = ?",
|
|
863
|
+
(tenant_id,),
|
|
864
|
+
)
|
|
865
|
+
return [(dict(r)["entry_id"], dict(r)["vector_blob"]) for r in rows]
|
|
866
|
+
except sqlite3.Error as exc:
|
|
867
|
+
logger.warning("CacheDB.get_all_vectors failed: %s", exc)
|
|
868
|
+
return []
|
|
869
|
+
|
|
870
|
+
# ---- metrics ----
|
|
871
|
+
|
|
872
|
+
def metrics_load(self) -> MetricsSnapshot:
|
|
873
|
+
try:
|
|
874
|
+
rows = self._db.execute("SELECT * FROM llmcache_metrics WHERE id = 1")
|
|
875
|
+
if not rows:
|
|
876
|
+
return MetricsSnapshot()
|
|
877
|
+
d = dict(rows[0])
|
|
878
|
+
return MetricsSnapshot(**d)
|
|
879
|
+
except sqlite3.Error as exc:
|
|
880
|
+
logger.warning("CacheDB.metrics_load failed: %s", exc)
|
|
881
|
+
return MetricsSnapshot()
|
|
882
|
+
|
|
883
|
+
def metrics_flush(self, snap: MetricsSnapshot) -> None:
|
|
884
|
+
try:
|
|
885
|
+
params = dataclasses.asdict(snap)
|
|
886
|
+
sql = (
|
|
887
|
+
"INSERT INTO llmcache_metrics ("
|
|
888
|
+
" id, hits, misses, calls_skipped, "
|
|
889
|
+
" tokens_saved_input, tokens_saved_output, tokens_saved_compress, "
|
|
890
|
+
" evictions, latency_overhead_ms_sum, latency_samples, "
|
|
891
|
+
" compress_runs, compress_bytes_original, compress_bytes_after, "
|
|
892
|
+
" cache_size_bytes, cache_entry_count, updated_at) "
|
|
893
|
+
"VALUES ("
|
|
894
|
+
" :id, :hits, :misses, :calls_skipped, "
|
|
895
|
+
" :tokens_saved_input, :tokens_saved_output, :tokens_saved_compress, "
|
|
896
|
+
" :evictions, :latency_overhead_ms_sum, :latency_samples, "
|
|
897
|
+
" :compress_runs, :compress_bytes_original, :compress_bytes_after, "
|
|
898
|
+
" :cache_size_bytes, :cache_entry_count, :updated_at) "
|
|
899
|
+
"ON CONFLICT(id) DO UPDATE SET "
|
|
900
|
+
" hits=excluded.hits, misses=excluded.misses, calls_skipped=excluded.calls_skipped, "
|
|
901
|
+
" tokens_saved_input=excluded.tokens_saved_input, "
|
|
902
|
+
" tokens_saved_output=excluded.tokens_saved_output, "
|
|
903
|
+
" tokens_saved_compress=excluded.tokens_saved_compress, "
|
|
904
|
+
" evictions=excluded.evictions, "
|
|
905
|
+
" latency_overhead_ms_sum=excluded.latency_overhead_ms_sum, "
|
|
906
|
+
" latency_samples=excluded.latency_samples, "
|
|
907
|
+
" compress_runs=excluded.compress_runs, "
|
|
908
|
+
" compress_bytes_original=excluded.compress_bytes_original, "
|
|
909
|
+
" compress_bytes_after=excluded.compress_bytes_after, "
|
|
910
|
+
" cache_size_bytes=excluded.cache_size_bytes, "
|
|
911
|
+
" cache_entry_count=excluded.cache_entry_count, "
|
|
912
|
+
" updated_at=excluded.updated_at"
|
|
913
|
+
)
|
|
914
|
+
with self._db.transaction():
|
|
915
|
+
self._db.execute(sql, params)
|
|
916
|
+
except sqlite3.Error as exc:
|
|
917
|
+
logger.warning("CacheDB.metrics_flush failed: %s", exc)
|
|
918
|
+
|
|
919
|
+
# ---- convenience / non-contract helpers ----
|
|
920
|
+
|
|
921
|
+
def entry_exists(self, cache_key: str, tenant_id: str = _DEFAULT_TENANT) -> bool:
|
|
922
|
+
try:
|
|
923
|
+
rows = self._db.execute(
|
|
924
|
+
"SELECT 1 FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ? LIMIT 1",
|
|
925
|
+
(cache_key, tenant_id),
|
|
926
|
+
)
|
|
927
|
+
return bool(rows)
|
|
928
|
+
except sqlite3.Error:
|
|
929
|
+
return False
|
|
930
|
+
|
|
931
|
+
def clear_tenant(self, tenant_id: str) -> int:
|
|
932
|
+
try:
|
|
933
|
+
with self._db.transaction():
|
|
934
|
+
rows = self._db.execute(
|
|
935
|
+
"SELECT entry_id FROM llmcache_entries WHERE tenant_id = ?",
|
|
936
|
+
(tenant_id,),
|
|
937
|
+
)
|
|
938
|
+
entry_ids = [dict(r)["entry_id"] for r in rows]
|
|
939
|
+
if entry_ids:
|
|
940
|
+
placeholders = ",".join("?" for _ in entry_ids)
|
|
941
|
+
self._db.execute(
|
|
942
|
+
f"DELETE FROM llmcache_semantic_vectors "
|
|
943
|
+
f"WHERE entry_id IN ({placeholders})",
|
|
944
|
+
tuple(entry_ids),
|
|
945
|
+
)
|
|
946
|
+
self._db.execute(
|
|
947
|
+
"DELETE FROM llmcache_tags WHERE tenant_id = ?", (tenant_id,),
|
|
948
|
+
)
|
|
949
|
+
self._db.execute(
|
|
950
|
+
"DELETE FROM llmcache_entries WHERE tenant_id = ?", (tenant_id,),
|
|
951
|
+
)
|
|
952
|
+
return len(entry_ids)
|
|
953
|
+
except sqlite3.Error as exc:
|
|
954
|
+
logger.warning("CacheDB.clear_tenant failed: %s", exc)
|
|
955
|
+
return 0
|
|
956
|
+
|
|
957
|
+
def entry_count(self, tenant_id: str = _DEFAULT_TENANT) -> int:
|
|
958
|
+
try:
|
|
959
|
+
rows = self._db.execute(
|
|
960
|
+
"SELECT COUNT(*) AS n FROM llmcache_entries "
|
|
961
|
+
"WHERE tenant_id = ? AND (ttl_expires IS NULL OR ttl_expires > ?)",
|
|
962
|
+
(tenant_id, time.time()),
|
|
963
|
+
)
|
|
964
|
+
return int(dict(rows[0])["n"]) if rows else 0
|
|
965
|
+
except sqlite3.Error:
|
|
966
|
+
return 0
|
|
967
|
+
|
|
968
|
+
def db_size_bytes(self) -> int:
|
|
969
|
+
try:
|
|
970
|
+
rows = self._db.execute("PRAGMA page_count")
|
|
971
|
+
page_count = int(dict(rows[0])["page_count"]) if rows else 0
|
|
972
|
+
rows = self._db.execute("PRAGMA page_size")
|
|
973
|
+
page_size = int(dict(rows[0])["page_size"]) if rows else 0
|
|
974
|
+
return page_count * page_size
|
|
975
|
+
except sqlite3.Error:
|
|
976
|
+
return 0
|
|
977
|
+
|
|
978
|
+
# ---- internal helpers ----
|
|
979
|
+
|
|
980
|
+
def _row_to_cacherow(self, row: dict) -> "CacheRow":
|
|
981
|
+
"""Build a CacheRow from a sqlite3.Row dict, robust to extra/missing keys."""
|
|
982
|
+
kwargs: dict[str, Any] = {}
|
|
983
|
+
for fname in CacheRow.__dataclass_fields__:
|
|
984
|
+
if fname == "value":
|
|
985
|
+
continue
|
|
986
|
+
if fname in row:
|
|
987
|
+
kwargs[fname] = row[fname]
|
|
988
|
+
kwargs["value"] = row.get("value", b"")
|
|
989
|
+
try:
|
|
990
|
+
kwargs["tags"] = json.loads(row.get("tag_json") or "[]")
|
|
991
|
+
except (json.JSONDecodeError, TypeError):
|
|
992
|
+
kwargs["tags"] = []
|
|
993
|
+
return CacheRow(**kwargs)
|
|
994
|
+
|
|
995
|
+
# ---- singleton (INTERFACE-CONTRACT v2.2 §1) ----
|
|
996
|
+
|
|
997
|
+
@classmethod
|
|
998
|
+
def get_default(cls) -> "CacheDB":
|
|
999
|
+
if cls._default_instance is None:
|
|
1000
|
+
import threading as _t
|
|
1001
|
+
if cls._default_lock is None:
|
|
1002
|
+
cls._default_lock = _t.Lock()
|
|
1003
|
+
with cls._default_lock:
|
|
1004
|
+
if cls._default_instance is None:
|
|
1005
|
+
cls._default_instance = cls()
|
|
1006
|
+
return cls._default_instance
|
|
1007
|
+
|
|
1008
|
+
@classmethod
|
|
1009
|
+
def reset_default(cls) -> None:
|
|
1010
|
+
"""Reset the singleton (testing only)."""
|
|
1011
|
+
if cls._default_instance is not None:
|
|
1012
|
+
try:
|
|
1013
|
+
cls._default_instance.close()
|
|
1014
|
+
except Exception:
|
|
1015
|
+
pass
|
|
1016
|
+
cls._default_instance = None
|