pi-mega-compact 0.4.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/LICENSE +24 -0
- package/README.md +375 -0
- package/extensions/DASHBOARD.md +160 -0
- package/extensions/dashboard-server.test.ts +124 -0
- package/extensions/dashboard-server.ts +459 -0
- package/extensions/error-patterns.ts +175 -0
- package/extensions/mega-compact.test.ts +351 -0
- package/extensions/mega-compact.ts +846 -0
- package/extensions/openclaw-mega-compact.ts +370 -0
- package/package.json +61 -0
- package/src/adapt.ts +120 -0
- package/src/boundary.test.ts +61 -0
- package/src/boundary.ts +94 -0
- package/src/canary.ts +126 -0
- package/src/compact.test.ts +99 -0
- package/src/compact.ts +262 -0
- package/src/config/dedup.ts +120 -0
- package/src/config.ts +15 -0
- package/src/dedup/dedup.test.ts +46 -0
- package/src/dedup/digest.ts +40 -0
- package/src/dedup/l1-lsh.ts +67 -0
- package/src/dedup/l1-minhash.ts +90 -0
- package/src/dedup/l1-verify.ts +55 -0
- package/src/dedup/l1.test.ts +57 -0
- package/src/dedup/mmr.ts +54 -0
- package/src/dedup/normalize.ts +41 -0
- package/src/dedup/raptor/guardrails.ts +112 -0
- package/src/dedup/raptor/index.ts +118 -0
- package/src/dedup/raptor/kmeans.ts +156 -0
- package/src/dedup/raptor/raptor.test.ts +238 -0
- package/src/dedup/raptor/retrieval.ts +102 -0
- package/src/dedup/raptor/summarizer.ts +91 -0
- package/src/dedup/raptor/tree.ts +254 -0
- package/src/dedup/sprint12.test.ts +242 -0
- package/src/dedup/topk.ts +61 -0
- package/src/dedup-engine.test.ts +609 -0
- package/src/e2e.test.ts +843 -0
- package/src/embedder.ts +111 -0
- package/src/engine.test.ts +123 -0
- package/src/engine.ts +192 -0
- package/src/extractive.test.ts +156 -0
- package/src/extractive.ts +265 -0
- package/src/httpEmbedder.ts +154 -0
- package/src/log.test.ts +47 -0
- package/src/log.ts +60 -0
- package/src/monitoring.ts +171 -0
- package/src/ratio.bench.test.ts +1316 -0
- package/src/recall.integration.test.ts +96 -0
- package/src/recall.test.ts +59 -0
- package/src/recall.ts +100 -0
- package/src/sprint14.test.ts +245 -0
- package/src/store/backfill.ts +263 -0
- package/src/store/bloom.ts +122 -0
- package/src/store/compression.test.ts +83 -0
- package/src/store/compression.ts +203 -0
- package/src/store/integrity.ts +65 -0
- package/src/store/migrate.test.ts +158 -0
- package/src/store/migrate.ts +108 -0
- package/src/store/sprint10.test.ts +182 -0
- package/src/store/sqlite.ts +519 -0
- package/src/store.test.ts +169 -0
- package/src/store.ts +192 -0
- package/src/supersede.test.ts +42 -0
- package/src/supersede.ts +67 -0
- package/src/tokens.ts +35 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +49 -0
- package/src/vectorStore.test.ts +480 -0
- package/src/vectorStore.ts +544 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backfill.ts — resumable / idempotent hash backfill (Sprint 10).
|
|
3
|
+
*
|
|
4
|
+
* Purpose: populate `content_hash` / `content_hash2` / `content_hash_version` /
|
|
5
|
+
* `normalized_text` for any rows left with null hashes (e.g. pre-Sprint-9 data
|
|
6
|
+
* or rows that degraded to "store without dedup" under the QA #13 timeout).
|
|
7
|
+
* Structured so Sprint 11 can plug in its own MinHash/LSH phase after the
|
|
8
|
+
* content hashes land.
|
|
9
|
+
*
|
|
10
|
+
* Properties (QA #1 / QA #14):
|
|
11
|
+
* - Resumable: progress stored in a `backfill_progress` table (last processed id).
|
|
12
|
+
* - Idempotent: ON CONFLICT DO NOTHING + partial UNIQUE on (session_id, content_hash)
|
|
13
|
+
* make a second run a no-op where it safely can.
|
|
14
|
+
* - Batched: 1000 rows/commit to bound lock time; throttle between batches.
|
|
15
|
+
*
|
|
16
|
+
* SQLite is the source of truth; this touches no network (PREVENT-PI-004).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Database } from "better-sqlite3";
|
|
20
|
+
import { openStore } from "./sqlite.js";
|
|
21
|
+
import { computeContentDigest } from "../dedup/digest.js";
|
|
22
|
+
import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "../dedup/l1-minhash.js";
|
|
23
|
+
import { lshBands } from "../dedup/l1-lsh.js";
|
|
24
|
+
import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree } from "./sqlite.js";
|
|
25
|
+
import { buildRaptorTree, type Leaf } from "../dedup/raptor/tree.js";
|
|
26
|
+
import type { Embedder } from "../embedder.js";
|
|
27
|
+
import { defaultEmbedder } from "../embedder.js";
|
|
28
|
+
import { getStateDir } from "../store.js";
|
|
29
|
+
|
|
30
|
+
const BATCH = 1000;
|
|
31
|
+
const THROTTLE_MS = 0; // synchronous backfill; no cross-process yield needed
|
|
32
|
+
|
|
33
|
+
/** Backfill phases, in order (Sprint 14 full-pipeline wiring). */
|
|
34
|
+
export type BackfillPhase = "L0" | "L1" | "L2" | "RAPTOR";
|
|
35
|
+
|
|
36
|
+
interface BackfillResult {
|
|
37
|
+
processed: number;
|
|
38
|
+
updated: number;
|
|
39
|
+
duplicatesResolved: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PhaseProgressRow {
|
|
43
|
+
last_session_id: string | null;
|
|
44
|
+
last_id: string | null;
|
|
45
|
+
processed: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureProgressTable(db: Database): void {
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS backfill_progress (
|
|
51
|
+
name TEXT PRIMARY KEY,
|
|
52
|
+
last_session_id TEXT, -- session of the highest (session_id, id) scanned
|
|
53
|
+
last_id TEXT, -- highest context_chunks.id scanned within that session
|
|
54
|
+
updated INTEGER,
|
|
55
|
+
duplicates_resolved INTEGER
|
|
56
|
+
);
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function progress(db: Database): { lastSid: string | null; lastId: string | null; updated: number; dups: number } {
|
|
61
|
+
const row = db
|
|
62
|
+
.prepare("SELECT last_session_id, last_id, updated, duplicates_resolved FROM backfill_progress WHERE name='content_hashes'")
|
|
63
|
+
.get() as { last_session_id: string | null; last_id: string | null; updated: number; duplicates_resolved: number } | undefined;
|
|
64
|
+
return { lastSid: row?.last_session_id ?? null, lastId: row?.last_id ?? null, updated: row?.updated ?? 0, dups: row?.duplicates_resolved ?? 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Backfill content hashes for all rows missing them, from the last scanned
|
|
69
|
+
* (session_id, id) forward. Returns counts; fully idempotent and resumable.
|
|
70
|
+
*/
|
|
71
|
+
export function backfillContentHashes(stateDir: string = getStateDir()): BackfillResult {
|
|
72
|
+
const db = openStore(stateDir);
|
|
73
|
+
ensureProgressTable(db);
|
|
74
|
+
const start = progress(db);
|
|
75
|
+
|
|
76
|
+
// Rows needing hashing: null content_hash, ordered by (session_id, id) for a
|
|
77
|
+
// stable, resumable cursor (ids are only unique per session).
|
|
78
|
+
const pending = db
|
|
79
|
+
.prepare(
|
|
80
|
+
`SELECT id, session_id, summary FROM context_chunks
|
|
81
|
+
WHERE content_hash IS NULL
|
|
82
|
+
AND (session_id > COALESCE(?, '')
|
|
83
|
+
OR (session_id = COALESCE(?, '') AND id > COALESCE(?, '')))
|
|
84
|
+
ORDER BY session_id ASC, id ASC LIMIT ?`,
|
|
85
|
+
)
|
|
86
|
+
.all(start.lastSid, start.lastSid, start.lastId, BATCH) as { id: string; session_id: string; summary: string }[];
|
|
87
|
+
|
|
88
|
+
let updated = start.updated;
|
|
89
|
+
let duplicatesResolved = start.dups;
|
|
90
|
+
let processed = 0;
|
|
91
|
+
let lastSid = start.lastSid;
|
|
92
|
+
let lastId = start.lastId;
|
|
93
|
+
|
|
94
|
+
const tx = db.transaction((rows: { id: string; session_id: string; summary: string }[]) => {
|
|
95
|
+
const lookup = db.prepare(
|
|
96
|
+
"SELECT id FROM context_chunks WHERE session_id = ? AND content_hash = ? AND content_hash2 = ? AND id != ? LIMIT 1",
|
|
97
|
+
);
|
|
98
|
+
const update = db.prepare(
|
|
99
|
+
`UPDATE context_chunks
|
|
100
|
+
SET content_hash=?, content_hash2=?, content_hash_version=?, normalized_text=?,
|
|
101
|
+
dedup_status='active'
|
|
102
|
+
WHERE id=?`,
|
|
103
|
+
);
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const digest = computeContentDigest(row.summary ?? "");
|
|
106
|
+
// Keep the oldest row on a collision (partial UNIQUE would reject the
|
|
107
|
+
// newer insert); mark the newer one as superseded-without-store.
|
|
108
|
+
const clash = lookup.get(row.session_id, digest.contentHash, digest.contentHash2, row.id);
|
|
109
|
+
if (clash) {
|
|
110
|
+
db.prepare("UPDATE context_chunks SET dedup_status='dup-resolved' WHERE id=?").run(row.id);
|
|
111
|
+
duplicatesResolved++;
|
|
112
|
+
} else {
|
|
113
|
+
update.run(
|
|
114
|
+
digest.contentHash,
|
|
115
|
+
digest.contentHash2,
|
|
116
|
+
digest.contentHashVersion,
|
|
117
|
+
digest.normalizedText,
|
|
118
|
+
row.id,
|
|
119
|
+
);
|
|
120
|
+
updated++;
|
|
121
|
+
}
|
|
122
|
+
lastSid = row.session_id;
|
|
123
|
+
lastId = row.id;
|
|
124
|
+
processed++;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (pending.length > 0) {
|
|
129
|
+
tx(pending);
|
|
130
|
+
db.prepare(
|
|
131
|
+
"INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved) VALUES('content_hashes',?,?,?,?) ON CONFLICT(name) DO UPDATE SET last_session_id=excluded.last_session_id, last_id=excluded.last_id, updated=excluded.updated, duplicates_resolved=excluded.duplicates_resolved",
|
|
132
|
+
).run(lastSid, lastId, updated, duplicatesResolved);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (THROTTLE_MS > 0) {
|
|
136
|
+
// No-op in this synchronous build; placeholder for future streaming backfill.
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { processed, updated, duplicatesResolved };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when no rows remain pending (backfill complete for this state dir). */
|
|
143
|
+
export function isBackfillComplete(stateDir: string = getStateDir()): boolean {
|
|
144
|
+
const db = openStore(stateDir);
|
|
145
|
+
const row = db
|
|
146
|
+
.prepare("SELECT COUNT(*) AS c FROM context_chunks WHERE content_hash IS NULL")
|
|
147
|
+
.get() as { c: number };
|
|
148
|
+
return row.c === 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- Sprint 14: L1 / L2 / RAPTOR phase backfill (resumable) ---------------
|
|
152
|
+
|
|
153
|
+
function phaseCursor(db: Database, phase: BackfillPhase): { lastId: string | null; processed: number } {
|
|
154
|
+
ensureProgressTable(db);
|
|
155
|
+
const row = db
|
|
156
|
+
.prepare("SELECT last_id, updated AS processed FROM backfill_progress WHERE name = ?")
|
|
157
|
+
.get(`phase_${phase}`) as PhaseProgressRow | undefined;
|
|
158
|
+
return { lastId: row?.last_id ?? null, processed: row?.processed ?? 0 };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function savePhaseCursor(db: Database, phase: BackfillPhase, lastId: string | null, processed: number): void {
|
|
162
|
+
db.prepare(
|
|
163
|
+
`INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved)
|
|
164
|
+
VALUES(?, NULL, ?, ?, 0)
|
|
165
|
+
ON CONFLICT(name) DO UPDATE SET last_id=excluded.last_id, updated=excluded.updated`,
|
|
166
|
+
).run(`phase_${phase}`, lastId, processed);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface PhaseBackfillResult {
|
|
170
|
+
phase: BackfillPhase;
|
|
171
|
+
processed: number;
|
|
172
|
+
batches: number;
|
|
173
|
+
interrupted: boolean;
|
|
174
|
+
/** Last processed checkpoint id (the resume cursor). */
|
|
175
|
+
cursor: string | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Backfill L1 (MinHash sigs + LSH buckets) or L2 (MinHash sigs only) derived
|
|
180
|
+
* data for a session, in batches, resumable from the persisted cursor. Pass
|
|
181
|
+
* `interruptAfterBatches` to simulate a crash for resume testing.
|
|
182
|
+
*/
|
|
183
|
+
export function backfillPhase(
|
|
184
|
+
phase: "L1" | "L2",
|
|
185
|
+
sessionId: string,
|
|
186
|
+
stateDir: string,
|
|
187
|
+
opts: { batchSize?: number; interruptAfterBatches?: number } = {},
|
|
188
|
+
): PhaseBackfillResult {
|
|
189
|
+
const db = openStore(stateDir);
|
|
190
|
+
const batchSize = opts.batchSize ?? BATCH;
|
|
191
|
+
const all = listCheckpoints(sessionId, stateDir).sort((a, b) =>
|
|
192
|
+
a.checkpointId.localeCompare(b.checkpointId),
|
|
193
|
+
);
|
|
194
|
+
const { lastId } = phaseCursor(db, phase);
|
|
195
|
+
let { processed } = phaseCursor(db, phase);
|
|
196
|
+
const startIndex = lastId ? all.findIndex((c) => c.checkpointId === lastId) + 1 : 0;
|
|
197
|
+
|
|
198
|
+
let batches = 0;
|
|
199
|
+
let interrupted = false;
|
|
200
|
+
let cursor: string | undefined = lastId ?? undefined;
|
|
201
|
+
|
|
202
|
+
for (let i = Math.max(0, startIndex); i < all.length; i += batchSize) {
|
|
203
|
+
const batch = all.slice(i, i + batchSize);
|
|
204
|
+
const tx = db.transaction(() => {
|
|
205
|
+
for (const cp of batch) {
|
|
206
|
+
const sig = minhashSignature(cp.normalizedText ?? cp.summary ?? "");
|
|
207
|
+
if (sig.length === NUM_HASHES) {
|
|
208
|
+
upsertMinhashSignature(cp.checkpointId, sessionId, SIGNATURE_VERSION, sig, stateDir);
|
|
209
|
+
if (phase === "L1") {
|
|
210
|
+
insertLshBuckets(
|
|
211
|
+
cp.checkpointId, sessionId, SIGNATURE_VERSION,
|
|
212
|
+
lshBands(sig, sessionId, SIGNATURE_VERSION), stateDir,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
cursor = cp.checkpointId;
|
|
217
|
+
processed++;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
tx();
|
|
221
|
+
savePhaseCursor(db, phase, cursor ?? null, processed);
|
|
222
|
+
batches++;
|
|
223
|
+
if (THROTTLE_MS > 0) { const end = Date.now() + THROTTLE_MS; while (Date.now() < end) { /* throttle */ } }
|
|
224
|
+
if (opts.interruptAfterBatches && batches >= opts.interruptAfterBatches) {
|
|
225
|
+
interrupted = true;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { phase, processed, batches, interrupted, cursor };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Backfill the RAPTOR tree for a session (single pass over all leaves). Builds
|
|
235
|
+
* + persists raptor_nodes. Not batched — the builder has its own budget cap.
|
|
236
|
+
*/
|
|
237
|
+
export function backfillRaptor(
|
|
238
|
+
sessionId: string,
|
|
239
|
+
stateDir: string,
|
|
240
|
+
embedder: Embedder = defaultEmbedder(),
|
|
241
|
+
): PhaseBackfillResult {
|
|
242
|
+
const all = listCheckpoints(sessionId, stateDir).sort((a, b) =>
|
|
243
|
+
a.checkpointId.localeCompare(b.checkpointId),
|
|
244
|
+
);
|
|
245
|
+
const leaves: Leaf[] = all.map((cp) => {
|
|
246
|
+
const text = cp.normalizedText ?? cp.summary ?? "";
|
|
247
|
+
return {
|
|
248
|
+
id: cp.checkpointId,
|
|
249
|
+
messages: [{ role: "user", text }],
|
|
250
|
+
sourceText: text,
|
|
251
|
+
embedding: embedder.embed(text),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
if (leaves.length === 0) {
|
|
255
|
+
return { phase: "RAPTOR", processed: 0, batches: 0, interrupted: false, cursor: undefined };
|
|
256
|
+
}
|
|
257
|
+
const tree = buildRaptorTree(leaves, { embedder });
|
|
258
|
+
saveRaptorTree(sessionId, tree, stateDir);
|
|
259
|
+
const db = openStore(stateDir);
|
|
260
|
+
ensureProgressTable(db);
|
|
261
|
+
savePhaseCursor(db, "RAPTOR", leaves[leaves.length - 1].id, leaves.length);
|
|
262
|
+
return { phase: "RAPTOR", processed: leaves.length, batches: 1, interrupted: false, cursor: leaves[leaves.length - 1].id };
|
|
263
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bloom.ts — local bloom-filter accelerator for the L0 content-hash dedup tier
|
|
3
|
+
* (Sprint 10).
|
|
4
|
+
*
|
|
5
|
+
* ACCELERATOR ONLY (QA #2 spirit, re-mapped locally): a bloom filter has zero
|
|
6
|
+
* false negatives — a MISS truly means "this content_hash is not present", so we
|
|
7
|
+
* can skip the full SQLite scan on the happy path. A HIT is only a candidate and
|
|
8
|
+
* MUST be confirmed by a SELECT against SQLite, which remains the source of truth
|
|
9
|
+
* (PREVENT-PI-004: in-process, no network; SQLite owns durability).
|
|
10
|
+
*
|
|
11
|
+
* The filter is an in-memory `bloom-filters` Map persisted to
|
|
12
|
+
* `STATE_DIR/bloom.json.gz` so a fresh VectorStore over the same dir reuses the
|
|
13
|
+
* warm filter instead of rebuilding from a scan.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { getStateDir } from "../store.js";
|
|
19
|
+
import { compressSmart, decompressSmart } from "../store.js";
|
|
20
|
+
|
|
21
|
+
// Fixed bit-array size + hash count sized for a 1K-checkpoint fixture at <1% FP
|
|
22
|
+
// (m ≈ -n*ln(p)/ln(2)^2). 8 KiB bits → ~8192 bits, k=7 → well under 1% at 1K.
|
|
23
|
+
const BITS = 8192;
|
|
24
|
+
const HASHES = 7;
|
|
25
|
+
const STORAGE_MARK = 0x42; // 'B' — marks a persisted bloom blob (not versioned)
|
|
26
|
+
|
|
27
|
+
function fnv1a(data: Buffer, seed: number): number {
|
|
28
|
+
let h = 0x811c9dc5 ^ seed;
|
|
29
|
+
for (let i = 0; i < data.length; i++) {
|
|
30
|
+
h ^= data[i];
|
|
31
|
+
h = Math.imul(h, 0x01000193);
|
|
32
|
+
}
|
|
33
|
+
return h >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class BloomFilter {
|
|
37
|
+
private bits: Uint8Array;
|
|
38
|
+
|
|
39
|
+
constructor(bits?: Uint8Array) {
|
|
40
|
+
this.bits = bits ?? new Uint8Array(BITS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private indices(key: string): number[] {
|
|
44
|
+
const data = Buffer.from(key, "utf-8");
|
|
45
|
+
const idx: number[] = [];
|
|
46
|
+
for (let i = 0; i < HASHES; i++) {
|
|
47
|
+
// Double-hashing (Kirsch–Mitzenmacher) to derive k independent positions.
|
|
48
|
+
const h1 = fnv1a(data, 0x9e3779b1 * i);
|
|
49
|
+
const h2 = fnv1a(data, 0x85ebca77 * (i + 1));
|
|
50
|
+
idx.push((h1 + i * h2) % BITS);
|
|
51
|
+
}
|
|
52
|
+
return idx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
add(key: string): void {
|
|
56
|
+
for (const i of this.indices(key)) this.bits[i >> 3] |= 1 << (i & 7);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A miss is definitive (zero false negatives): false ⇒ definitely absent. */
|
|
60
|
+
maybeHas(key: string): boolean {
|
|
61
|
+
for (const i of this.indices(key)) {
|
|
62
|
+
if ((this.bits[i >> 3] & (1 << (i & 7))) === 0) return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toBuffer(): Buffer {
|
|
68
|
+
return Buffer.concat([Buffer.from([STORAGE_MARK]), Buffer.from(this.bits)]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Raw bit array (for persistence). */
|
|
72
|
+
bytes(): Uint8Array {
|
|
73
|
+
return this.bits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static fromBuffer(buf: Buffer): BloomFilter {
|
|
77
|
+
if (buf.length >= 1 && buf[0] === STORAGE_MARK) {
|
|
78
|
+
return new BloomFilter(Uint8Array.from(buf.subarray(1)));
|
|
79
|
+
}
|
|
80
|
+
// Legacy/compressed form: best-effort decompress.
|
|
81
|
+
try {
|
|
82
|
+
const raw = decompressSmart(buf);
|
|
83
|
+
return new BloomFilter(Uint8Array.from(raw));
|
|
84
|
+
} catch {
|
|
85
|
+
return new BloomFilter();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cache = new Map<string, BloomFilter>();
|
|
91
|
+
|
|
92
|
+
/** Load (or lazily create + cache) the bloom filter for a state dir. */
|
|
93
|
+
export function openBloom(stateDir: string = getStateDir()): BloomFilter {
|
|
94
|
+
const existing = cache.get(stateDir);
|
|
95
|
+
if (existing) return existing;
|
|
96
|
+
const path = join(stateDir, "bloom.json.gz");
|
|
97
|
+
let filter = new BloomFilter();
|
|
98
|
+
if (existsSync(path)) {
|
|
99
|
+
try {
|
|
100
|
+
filter = BloomFilter.fromBuffer(readFileSync(path));
|
|
101
|
+
} catch {
|
|
102
|
+
filter = new BloomFilter();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
cache.set(stateDir, filter);
|
|
106
|
+
return filter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Persist the bloom filter to disk (additive — does not clear the cache). */
|
|
110
|
+
export function saveBloom(stateDir: string = getStateDir()): void {
|
|
111
|
+
const filter = cache.get(stateDir);
|
|
112
|
+
if (!filter) return;
|
|
113
|
+
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
|
114
|
+
// Compress the raw bit array for a smaller, versioned-on-disk footprint.
|
|
115
|
+
const blob = compressSmart(Buffer.from(filter.bytes()));
|
|
116
|
+
writeFileSync(join(stateDir, "bloom.json.gz"), blob);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Evict the cached filter (test teardown only). */
|
|
120
|
+
export function closeBloom(stateDir: string): void {
|
|
121
|
+
cache.delete(stateDir);
|
|
122
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compression.test.ts — versioned compression tiers + backward compatibility.
|
|
3
|
+
*
|
|
4
|
+
* Proves Sprint 8's root-cause fix: the 0x03 tag collision is impossible because
|
|
5
|
+
* new blobs carry a 2-byte version magic, and legacy blobs (untagged gzip, legacy
|
|
6
|
+
* single-tag incl. the old 0x03=brotli) still decompress.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { gzipSync, brotliCompressSync } from "node:zlib";
|
|
12
|
+
import {
|
|
13
|
+
compressSmart,
|
|
14
|
+
decompressSmart,
|
|
15
|
+
compressZstd,
|
|
16
|
+
decompressZstd,
|
|
17
|
+
isVersioned,
|
|
18
|
+
detectFormat,
|
|
19
|
+
decompressSyncAuto,
|
|
20
|
+
} from "./compression.js";
|
|
21
|
+
|
|
22
|
+
const buf = (s: string, n: number) => Buffer.from(s.repeat(n));
|
|
23
|
+
|
|
24
|
+
test("versioned format: all size tiers roundtrip and are versioned", () => {
|
|
25
|
+
const cases = [
|
|
26
|
+
{ label: "tiny", data: buf("x", 100), expectTag: 0x00 },
|
|
27
|
+
{ label: "small", data: buf("a", 600), expectTag: 0x01 },
|
|
28
|
+
{ label: "medium", data: buf("b", 8000), expectTag: 0x02 },
|
|
29
|
+
{ label: "large", data: buf("c", 40000), expectTag: 0x05 },
|
|
30
|
+
];
|
|
31
|
+
for (const { label, data, expectTag } of cases) {
|
|
32
|
+
const c = compressSmart(data);
|
|
33
|
+
// New 2-byte magic header present.
|
|
34
|
+
assert.ok(isVersioned(c), `${label}: should be versioned (0xEC 0x01)`);
|
|
35
|
+
assert.equal(c[0], 0xec, `${label}: magic hi`);
|
|
36
|
+
assert.equal(c[1], 0x01, `${label}: magic lo (version)`);
|
|
37
|
+
assert.equal(c[2], 0x01, `${label}: format version 1`);
|
|
38
|
+
assert.equal(c[3], expectTag, `${label}: tier tag ${expectTag.toString(16)}`);
|
|
39
|
+
// Roundtrips exactly.
|
|
40
|
+
assert.deepEqual(decompressSmart(c), data, `${label}: roundtrip`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("large tier actually compresses better than raw (brotli 0x05)", () => {
|
|
45
|
+
const data = buf("this is a long summary of a coding session. ", 900);
|
|
46
|
+
const c = compressSmart(data);
|
|
47
|
+
assert.ok(c.length < data.length, "compressed smaller than raw");
|
|
48
|
+
assert.equal(c[3], 0x05, "tag is brotli-4");
|
|
49
|
+
assert.deepEqual(decompressSmart(c), data);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("legacy untagged gzip (0x1f magic) still decompresses", () => {
|
|
53
|
+
const data = Buffer.from(JSON.stringify({ legacy: true }));
|
|
54
|
+
const legacyGzip = gzipSync(data); // no tag byte, starts with 0x1f
|
|
55
|
+
assert.equal(legacyGzip[0], 0x1f, "gzip magic present");
|
|
56
|
+
assert.equal(detectFormat(legacyGzip), "legacy-gzip");
|
|
57
|
+
assert.deepEqual(JSON.parse(decompressSmart(legacyGzip).toString()), { legacy: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("legacy single-tag 0x03=brotli (the collision case) still decompresses", () => {
|
|
61
|
+
const data = buf("legacy brotli payload ", 200);
|
|
62
|
+
// Reconstruct the EXACT v0.1.0 legacy brotli frame: tag 0x03 + brotli payload.
|
|
63
|
+
const legacy = Buffer.concat([Buffer.from([0x03]), brotliCompressSync(data)]);
|
|
64
|
+
assert.equal(detectFormat(legacy), "legacy-tag");
|
|
65
|
+
assert.deepEqual(decompressSmart(legacy), data, "legacy 0x03 brotli roundtrips");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("detectFormat classifies all eras", () => {
|
|
69
|
+
assert.equal(detectFormat(compressSmart(buf("q", 700))), "versioned");
|
|
70
|
+
assert.equal(detectFormat(gzipSync(buf("q", 10))), "legacy-gzip");
|
|
71
|
+
assert.equal(detectFormat(Buffer.from([0x00, 1, 2, 3])), "legacy-tag");
|
|
72
|
+
assert.equal(detectFormat(Buffer.from([0x99, 0x88])), "unknown");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("zstd helper roundtrips (async) and is not sync-decoded", async () => {
|
|
76
|
+
const data = buf("zstd dr export payload ", 1500);
|
|
77
|
+
const c = await compressZstd(data);
|
|
78
|
+
assert.ok(c.length < data.length, "zstd compresses");
|
|
79
|
+
// decompressSyncAuto reports zstd without throwing (caller awaits decompressZstd).
|
|
80
|
+
const auto = decompressSyncAuto(c);
|
|
81
|
+
assert.equal(auto.isZstd, true, "flagged as zstd");
|
|
82
|
+
assert.deepEqual(await decompressZstd(c), data, "zstd roundtrip");
|
|
83
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compression.ts — versioned, size-adaptive compression for checkpoint blobs.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from store.ts (Sprint 8). Two coordinated compressors:
|
|
5
|
+
*
|
|
6
|
+
* 1. `compressSmart` / `decompressSmart` — SYNCHRONOUS, zlib-based. Used by the
|
|
7
|
+
* VectorStore write path (which must stay synchronous — see Sprint 8 plan:
|
|
8
|
+
* better-sqlite3 replaced PGlite precisely to avoid an async cascade).
|
|
9
|
+
*
|
|
10
|
+
* 2. `compressZstd` / `decompressZstd` — ASYNCHRONOUS, via @mongodb-js/zstd.
|
|
11
|
+
* Optional, used for DR-export / large-blob paths where an await is fine.
|
|
12
|
+
*
|
|
13
|
+
* FORMAT-VERSION PROBLEM (root cause of Sprint 8):
|
|
14
|
+
* store.ts shipped `0x03` = brotli (legacy single-tag format). PLAN.md reassigns
|
|
15
|
+
* `0x03` → zstd, which would corrupt every existing checkpoint file. We fix this
|
|
16
|
+
* with a 2-byte magic header on the NEW format so the tag byte is namespaced and
|
|
17
|
+
* can never collide with legacy payloads:
|
|
18
|
+
*
|
|
19
|
+
* NEW (versioned): 0xEC 0x01 [TIER_TAG] [payload]
|
|
20
|
+
* LEGACY single-tag: [TIER_TAG] [payload] (tags 0x00..0x03)
|
|
21
|
+
* LEGACY untagged: 0x1f ... (plain gzip magic)
|
|
22
|
+
*
|
|
23
|
+
* `0xEC` is chosen because it collides with no zlib output: gzip magic is 0x1f,
|
|
24
|
+
* brotli streams start 0xCE/0xCF, zlib/deflate streams start 0x78/0x05/0x03.
|
|
25
|
+
* decompressSmart detects the magic first, so all three eras roundtrip together.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
gzipSync,
|
|
30
|
+
gunzipSync,
|
|
31
|
+
brotliCompressSync,
|
|
32
|
+
brotliDecompressSync,
|
|
33
|
+
constants as zlibConstants,
|
|
34
|
+
} from "node:zlib";
|
|
35
|
+
import zstd from "@mongodb-js/zstd";
|
|
36
|
+
|
|
37
|
+
// --- Versioned format markers ----------------------------------------------
|
|
38
|
+
const MAGIC_HI = 0xec;
|
|
39
|
+
const MAGIC_LO = 0x01; // format version 1
|
|
40
|
+
|
|
41
|
+
// Tier tags (only meaningful inside the 0xEC 0x01 versioned frame).
|
|
42
|
+
const TAG_RAW = 0x00; // no compression (< 512 bytes)
|
|
43
|
+
const TAG_GZIP_1 = 0x01; // gzip level 1 (fast, 512B–4KB)
|
|
44
|
+
const TAG_GZIP_6 = 0x02; // gzip level 6 (default, 4KB–32KB)
|
|
45
|
+
const TAG_BROTLI_4 = 0x05; // brotli level 4 (> 32KB, best text ratio, sync)
|
|
46
|
+
|
|
47
|
+
// Reserved for the async zstd helper (see compressZstd). Not used by the sync path.
|
|
48
|
+
const TAG_ZSTD_3 = 0x03;
|
|
49
|
+
const TAG_ZSTD_9 = 0x04;
|
|
50
|
+
|
|
51
|
+
/** Gzip magic byte — used to detect legacy untagged files. */
|
|
52
|
+
const GZIP_MAGIC = 0x1f;
|
|
53
|
+
|
|
54
|
+
const SIZE_TINY = 512;
|
|
55
|
+
const SIZE_SMALL = 4096;
|
|
56
|
+
const SIZE_MEDIUM = 32768;
|
|
57
|
+
|
|
58
|
+
function header(ver: number, tag: number): Buffer {
|
|
59
|
+
return Buffer.from([MAGIC_HI, MAGIC_LO, ver, tag]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compress synchronously using the best zlib tier for the payload size.
|
|
64
|
+
*
|
|
65
|
+
* Tiers (all synchronous — no network, no async, PREVENT-PI-004):
|
|
66
|
+
* < 512 B → raw (tag 0x00)
|
|
67
|
+
* 512B–4KB → gzip level 1 (tag 0x01)
|
|
68
|
+
* 4KB–32KB → gzip level 6 (tag 0x02)
|
|
69
|
+
* > 32 KB → brotli 4 (tag 0x05)
|
|
70
|
+
*
|
|
71
|
+
* Writes the versioned header so readers disambiguate from legacy blobs.
|
|
72
|
+
*/
|
|
73
|
+
export function compressSmart(data: Buffer): Buffer {
|
|
74
|
+
const len = data.length;
|
|
75
|
+
if (len < SIZE_TINY) {
|
|
76
|
+
return Buffer.concat([header(1, TAG_RAW), data]);
|
|
77
|
+
}
|
|
78
|
+
if (len < SIZE_SMALL) {
|
|
79
|
+
return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level: 1 })]);
|
|
80
|
+
}
|
|
81
|
+
if (len < SIZE_MEDIUM) {
|
|
82
|
+
return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level: 6 })]);
|
|
83
|
+
}
|
|
84
|
+
const compressed = brotliCompressSync(data, {
|
|
85
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
|
86
|
+
});
|
|
87
|
+
return Buffer.concat([header(1, TAG_BROTLI_4), compressed]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when `buf` is a versioned-format blob (0xEC 0x01 …). */
|
|
91
|
+
export function isVersioned(buf: Buffer): boolean {
|
|
92
|
+
return buf.length >= 2 && buf[0] === MAGIC_HI && buf[1] === MAGIC_LO;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Detect which format era a buffer belongs to (for tests/telemetry). */
|
|
96
|
+
export type CompressedFormat = "versioned" | "legacy-tag" | "legacy-gzip" | "unknown";
|
|
97
|
+
export function detectFormat(buf: Buffer): CompressedFormat {
|
|
98
|
+
if (isVersioned(buf)) return "versioned";
|
|
99
|
+
if (buf[0] === GZIP_MAGIC) return "legacy-gzip";
|
|
100
|
+
// Legacy single-tag: first byte is a known legacy tag.
|
|
101
|
+
if (buf[0] === 0x00 || buf[0] === 0x01 || buf[0] === 0x02 || buf[0] === 0x03) {
|
|
102
|
+
return "legacy-tag";
|
|
103
|
+
}
|
|
104
|
+
return "unknown";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decompress a buffer written by `compressSmart` (versioned) OR any legacy
|
|
109
|
+
* format still on disk (legacy single-tag, legacy untagged gzip). SYNCHRONOUS.
|
|
110
|
+
*
|
|
111
|
+
* Throws on zstd blobs — those must go through the async `decompressZstd`,
|
|
112
|
+
* because zstd decompression cannot be awaited inside this sync path.
|
|
113
|
+
*/
|
|
114
|
+
export function decompressSmart(buf: Buffer): Buffer {
|
|
115
|
+
if (buf.length === 0) return buf;
|
|
116
|
+
|
|
117
|
+
// New versioned format — dispatch on the namespaced tier tag.
|
|
118
|
+
if (isVersioned(buf)) {
|
|
119
|
+
const tag = buf[3];
|
|
120
|
+
const payload = buf.subarray(4);
|
|
121
|
+
switch (tag) {
|
|
122
|
+
case TAG_RAW:
|
|
123
|
+
return payload;
|
|
124
|
+
case TAG_GZIP_1:
|
|
125
|
+
case TAG_GZIP_6:
|
|
126
|
+
return gunzipSync(payload);
|
|
127
|
+
case TAG_BROTLI_4:
|
|
128
|
+
return brotliDecompressSync(payload);
|
|
129
|
+
case TAG_ZSTD_3:
|
|
130
|
+
case TAG_ZSTD_9:
|
|
131
|
+
throw new Error(
|
|
132
|
+
"decompressSmart cannot read zstd blobs (async only) — use decompressZstd",
|
|
133
|
+
);
|
|
134
|
+
default:
|
|
135
|
+
throw new Error(`decompressSmart: unknown versioned tier tag 0x${tag.toString(16)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Legacy untagged gzip file (old writeGzJson with no tag byte).
|
|
140
|
+
if (buf[0] === GZIP_MAGIC) {
|
|
141
|
+
return gunzipSync(buf);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Legacy single-tag format (store.ts v0.1.0): tags 0x00..0x03.
|
|
145
|
+
const tag = buf[0];
|
|
146
|
+
const payload = buf.subarray(1);
|
|
147
|
+
switch (tag) {
|
|
148
|
+
case 0x00: // TAG_RAW (legacy)
|
|
149
|
+
return payload;
|
|
150
|
+
case 0x01: // TAG_GZIP_1 (legacy)
|
|
151
|
+
case 0x02: // TAG_GZIP_6 (legacy)
|
|
152
|
+
return gunzipSync(payload);
|
|
153
|
+
case 0x03: // TAG_BROTLI (legacy) — the very collision this format fixes
|
|
154
|
+
return brotliDecompressSync(payload);
|
|
155
|
+
default:
|
|
156
|
+
// Unknown legacy tag — last-ditch try plain gzip.
|
|
157
|
+
return gunzipSync(buf);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Optional async zstd path (DR export / large blobs) --------------------
|
|
162
|
+
// Self-describing: own 2-byte marker so it never routes through decompressSmart.
|
|
163
|
+
const ZSTD_MAGIC_HI = 0x5a; // 'Z'
|
|
164
|
+
const ZSTD_MAGIC_LO = 0x53; // 'S'
|
|
165
|
+
|
|
166
|
+
async function compressZstdWithLevel(data: Buffer, level: number): Promise<Buffer> {
|
|
167
|
+
const compressed = await zstd.compress(data, level);
|
|
168
|
+
return Buffer.concat([Buffer.from([ZSTD_MAGIC_HI, ZSTD_MAGIC_LO]), compressed]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Compress with zstd level 3 (fast, balanced). Async. */
|
|
172
|
+
export function compressZstd(data: Buffer): Promise<Buffer> {
|
|
173
|
+
return compressZstdWithLevel(data, 3);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Compress with zstd level 9 (max ratio). Async. */
|
|
177
|
+
export function compressZstdMax(data: Buffer): Promise<Buffer> {
|
|
178
|
+
return compressZstdWithLevel(data, 9);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when a buffer is a zstd-compressed blob from this helper. */
|
|
182
|
+
export function isZstd(buf: Buffer): boolean {
|
|
183
|
+
return buf.length >= 2 && buf[0] === ZSTD_MAGIC_HI && buf[1] === ZSTD_MAGIC_LO;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Decompress a zstd blob produced by compressZstd/compressZstdMax. Async. */
|
|
187
|
+
export async function decompressZstd(buf: Buffer): Promise<Buffer> {
|
|
188
|
+
if (buf.length === 0) return buf;
|
|
189
|
+
if (!isZstd(buf)) {
|
|
190
|
+
throw new Error("decompressZstd: buffer is not a zstd blob (missing ZS marker)");
|
|
191
|
+
}
|
|
192
|
+
return zstd.decompress(buf.subarray(2));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Decompress anything we can WITHOUT awaiting: versioned + legacy zlib formats.
|
|
197
|
+
* zstd blobs are detected and reported (not thrown blindly) so callers can
|
|
198
|
+
* decide whether to await decompressZstd.
|
|
199
|
+
*/
|
|
200
|
+
export function decompressSyncAuto(buf: Buffer): { data: Buffer; isZstd: boolean } {
|
|
201
|
+
if (isZstd(buf)) return { data: buf, isZstd: true };
|
|
202
|
+
return { data: decompressSmart(buf), isZstd: false };
|
|
203
|
+
}
|