@prisma/streams-server 0.0.1 → 0.1.1

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.
Files changed (83) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +201 -0
  4. package/README.md +39 -2
  5. package/SECURITY.md +33 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +29 -34
  8. package/src/app.ts +74 -0
  9. package/src/app_core.ts +1706 -0
  10. package/src/app_local.ts +46 -0
  11. package/src/backpressure.ts +66 -0
  12. package/src/bootstrap.ts +239 -0
  13. package/src/config.ts +251 -0
  14. package/src/db/db.ts +1386 -0
  15. package/src/db/schema.ts +625 -0
  16. package/src/expiry_sweeper.ts +44 -0
  17. package/src/hist.ts +169 -0
  18. package/src/index/binary_fuse.ts +379 -0
  19. package/src/index/indexer.ts +745 -0
  20. package/src/index/run_cache.ts +84 -0
  21. package/src/index/run_format.ts +213 -0
  22. package/src/ingest.ts +655 -0
  23. package/src/lens/lens.ts +501 -0
  24. package/src/manifest.ts +114 -0
  25. package/src/memory.ts +155 -0
  26. package/src/metrics.ts +161 -0
  27. package/src/metrics_emitter.ts +50 -0
  28. package/src/notifier.ts +64 -0
  29. package/src/objectstore/interface.ts +13 -0
  30. package/src/objectstore/mock_r2.ts +269 -0
  31. package/src/objectstore/null.ts +32 -0
  32. package/src/objectstore/r2.ts +128 -0
  33. package/src/offset.ts +70 -0
  34. package/src/reader.ts +454 -0
  35. package/src/runtime/hash.ts +156 -0
  36. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  37. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  38. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  39. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  40. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  41. package/src/schema/lens_schema.ts +290 -0
  42. package/src/schema/proof.ts +547 -0
  43. package/src/schema/registry.ts +405 -0
  44. package/src/segment/cache.ts +179 -0
  45. package/src/segment/format.ts +331 -0
  46. package/src/segment/segmenter.ts +326 -0
  47. package/src/segment/segmenter_worker.ts +43 -0
  48. package/src/segment/segmenter_workers.ts +94 -0
  49. package/src/server.ts +326 -0
  50. package/src/sqlite/adapter.ts +164 -0
  51. package/src/stats.ts +205 -0
  52. package/src/touch/engine.ts +41 -0
  53. package/src/touch/interpreter_worker.ts +442 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +827 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1199 -0
  58. package/src/touch/spec.ts +456 -0
  59. package/src/touch/touch_journal.ts +671 -0
  60. package/src/touch/touch_key_id.ts +20 -0
  61. package/src/touch/worker_pool.ts +189 -0
  62. package/src/touch/worker_protocol.ts +56 -0
  63. package/src/types/proper-lockfile.d.ts +1 -0
  64. package/src/uploader.ts +317 -0
  65. package/src/util/base32_crockford.ts +81 -0
  66. package/src/util/bloom256.ts +67 -0
  67. package/src/util/cleanup.ts +22 -0
  68. package/src/util/crc32c.ts +29 -0
  69. package/src/util/ds_error.ts +15 -0
  70. package/src/util/duration.ts +17 -0
  71. package/src/util/endian.ts +53 -0
  72. package/src/util/json_pointer.ts +148 -0
  73. package/src/util/log.ts +25 -0
  74. package/src/util/lru.ts +45 -0
  75. package/src/util/retry.ts +35 -0
  76. package/src/util/siphash.ts +71 -0
  77. package/src/util/stream_paths.ts +31 -0
  78. package/src/util/time.ts +14 -0
  79. package/src/util/yield.ts +3 -0
  80. package/build/index.d.mts +0 -1
  81. package/build/index.d.ts +0 -1
  82. package/build/index.js +0 -0
  83. package/build/index.mjs +0 -1
@@ -0,0 +1,745 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { Result } from "better-result";
4
+ import type { Config } from "../config";
5
+ import type { IndexRunRow, SegmentRow, SqliteDurableStore } from "../db/db";
6
+ import type { ObjectStore } from "../objectstore/interface";
7
+ import { SegmentDiskCache } from "../segment/cache";
8
+ import { iterateBlocksResult } from "../segment/format";
9
+ import { siphash24 } from "../util/siphash";
10
+ import { retry } from "../util/retry";
11
+ import { indexRunObjectKey, segmentObjectKey, streamHash16Hex } from "../util/stream_paths";
12
+ import { binaryFuseContains, buildBinaryFuseResult } from "./binary_fuse";
13
+ import { decodeIndexRunResult, encodeIndexRunResult, RUN_TYPE_MASK16, RUN_TYPE_POSTINGS, type IndexRun } from "./run_format";
14
+ import { IndexRunCache } from "./run_cache";
15
+ import type { Metrics } from "../metrics";
16
+ import { dsError } from "../util/ds_error.ts";
17
+
18
+ export type IndexCandidate = { segments: Set<number>; indexedThrough: number };
19
+ type IndexBuildError = { kind: "invalid_index_build"; message: string };
20
+
21
+ function invalidIndexBuild<T = never>(message: string): Result<T, IndexBuildError> {
22
+ return Result.err({ kind: "invalid_index_build", message });
23
+ }
24
+
25
+ function errorMessage(e: unknown): string {
26
+ return String((e as any)?.message ?? e);
27
+ }
28
+
29
+ export class IndexManager {
30
+ private readonly cfg: Config;
31
+ private readonly db: SqliteDurableStore;
32
+ private readonly os: ObjectStore;
33
+ private readonly segmentCache?: SegmentDiskCache;
34
+ private readonly runDiskCache?: SegmentDiskCache;
35
+ private readonly runCache: IndexRunCache;
36
+ private readonly span: number;
37
+ private readonly buildConcurrency: number;
38
+ private readonly compactionFanout: number;
39
+ private readonly maxLevel: number;
40
+ private readonly compactionConcurrency: number;
41
+ private readonly retireGenWindow: number;
42
+ private readonly retireMinMs: number;
43
+ private readonly queue = new Set<string>();
44
+ private readonly building = new Set<string>();
45
+ private readonly compacting = new Set<string>();
46
+ private readonly metrics?: Metrics;
47
+ private lastRunCacheHits = 0;
48
+ private lastRunCacheMisses = 0;
49
+ private lastRunCacheEvictions = 0;
50
+ private lastDiskHits = 0;
51
+ private lastDiskMisses = 0;
52
+ private lastDiskEvictions = 0;
53
+ private lastDiskBytesAdded = 0;
54
+ private timer: any | null = null;
55
+ private running = false;
56
+ private readonly publishManifest?: (stream: string) => Promise<void>;
57
+
58
+ constructor(
59
+ cfg: Config,
60
+ db: SqliteDurableStore,
61
+ os: ObjectStore,
62
+ segmentCache: SegmentDiskCache | undefined,
63
+ publishManifest?: (stream: string) => Promise<void>,
64
+ metrics?: Metrics
65
+ ) {
66
+ this.cfg = cfg;
67
+ this.db = db;
68
+ this.os = os;
69
+ this.segmentCache = segmentCache;
70
+ this.publishManifest = publishManifest;
71
+ this.span = cfg.indexL0SpanSegments;
72
+ this.buildConcurrency = Math.max(1, cfg.indexBuildConcurrency);
73
+ this.compactionFanout = cfg.indexCompactionFanout;
74
+ this.maxLevel = cfg.indexMaxLevel;
75
+ this.compactionConcurrency = Math.max(1, cfg.indexCompactionConcurrency);
76
+ this.retireGenWindow = Math.max(0, cfg.indexRetireGenWindow);
77
+ this.retireMinMs = Math.max(0, cfg.indexRetireMinMs);
78
+ this.metrics = metrics;
79
+ this.runCache = new IndexRunCache(cfg.indexRunMemoryCacheBytes);
80
+ this.runDiskCache = cfg.indexRunCacheMaxBytes > 0 ? new SegmentDiskCache(`${cfg.rootDir}/cache/index`, cfg.indexRunCacheMaxBytes) : undefined;
81
+ }
82
+
83
+ start(): void {
84
+ if (this.span <= 0) return;
85
+ if (this.timer) return;
86
+ this.timer = setInterval(() => {
87
+ void this.tick();
88
+ }, this.cfg.indexCheckIntervalMs);
89
+ }
90
+
91
+ stop(): void {
92
+ if (this.timer) clearInterval(this.timer);
93
+ this.timer = null;
94
+ }
95
+
96
+ enqueue(stream: string): void {
97
+ if (this.span <= 0) return;
98
+ this.queue.add(stream);
99
+ }
100
+
101
+ async candidateSegments(stream: string, keyBytes: Uint8Array): Promise<IndexCandidate | null> {
102
+ if (this.span <= 0) return null;
103
+ const state = this.db.getIndexState(stream);
104
+ if (!state) return null;
105
+ const runs = this.db.listIndexRuns(stream);
106
+ if (runs.length === 0 && state.indexed_through === 0) return null;
107
+
108
+ const fp = siphash24(state.index_secret, keyBytes);
109
+ const segments = new Set<number>();
110
+ for (const meta of runs) {
111
+ const runRes = await this.loadRunResult(meta);
112
+ if (Result.isError(runRes)) continue;
113
+ const run = runRes.value;
114
+ if (!run) continue;
115
+ if (run.filter && !binaryFuseContains(run.filter, fp)) continue;
116
+ if (run.runType === RUN_TYPE_MASK16 && run.masks) {
117
+ const idx = binarySearch(run.fingerprints, fp);
118
+ if (idx >= 0) {
119
+ const mask = run.masks[idx];
120
+ for (let bit = 0; bit < 16; bit++) {
121
+ if ((mask & (1 << bit)) !== 0) segments.add(run.meta.startSegment + bit);
122
+ }
123
+ }
124
+ } else if (run.postings) {
125
+ const idx = binarySearch(run.fingerprints, fp);
126
+ if (idx >= 0) {
127
+ for (const seg of run.postings[idx]) segments.add(seg);
128
+ }
129
+ }
130
+ }
131
+ return { segments, indexedThrough: state.indexed_through };
132
+ }
133
+
134
+ private async tick(): Promise<void> {
135
+ if (this.running) return;
136
+ this.running = true;
137
+ try {
138
+ if (this.metrics) {
139
+ this.metrics.record("tieredstore.index.build.queue_len", this.queue.size, "count");
140
+ this.metrics.record("tieredstore.index.builds_inflight", this.building.size, "count");
141
+ }
142
+ const streams = Array.from(this.queue);
143
+ this.queue.clear();
144
+ for (const stream of streams) {
145
+ try {
146
+ const buildRes = await this.maybeBuildRuns(stream);
147
+ if (Result.isError(buildRes)) {
148
+ // eslint-disable-next-line no-console
149
+ console.error("index build failed", stream, buildRes.error.message);
150
+ this.queue.add(stream);
151
+ continue;
152
+ }
153
+ const compactRes = await this.maybeCompactRuns(stream);
154
+ if (Result.isError(compactRes)) {
155
+ // eslint-disable-next-line no-console
156
+ console.error("index compaction failed", stream, compactRes.error.message);
157
+ this.queue.add(stream);
158
+ continue;
159
+ }
160
+ } catch (e) {
161
+ const msg = String((e as any)?.message ?? e);
162
+ const lower = msg.toLowerCase();
163
+ if (lower.includes("database has closed") || lower.includes("closed database") || lower.includes("statement has finalized")) {
164
+ continue;
165
+ }
166
+ // eslint-disable-next-line no-console
167
+ console.error("index build failed", stream, e);
168
+ this.queue.add(stream);
169
+ }
170
+ }
171
+ this.recordCacheStats();
172
+ } finally {
173
+ this.running = false;
174
+ }
175
+ }
176
+
177
+ private async maybeBuildRuns(stream: string): Promise<Result<void, IndexBuildError>> {
178
+ if (this.span <= 0) return Result.ok(undefined);
179
+ if (this.building.has(stream)) return Result.ok(undefined);
180
+ this.building.add(stream);
181
+ try {
182
+ let state = this.db.getIndexState(stream);
183
+ if (!state) {
184
+ const secret = randomBytes(16);
185
+ this.db.upsertIndexState(stream, secret, 0);
186
+ state = this.db.getIndexState(stream);
187
+ }
188
+ if (!state) return Result.ok(undefined);
189
+ if (this.metrics) {
190
+ const lag = Math.max(0, this.db.countUploadedSegments(stream) - state.indexed_through);
191
+ this.metrics.record("tieredstore.index.lag.segments", lag, "count", undefined, stream);
192
+ }
193
+ let indexedThrough = state.indexed_through;
194
+ for (;;) {
195
+ const uploadedCount = this.db.countUploadedSegments(stream);
196
+ if (uploadedCount < indexedThrough + this.span) return Result.ok(undefined);
197
+ const start = indexedThrough;
198
+ const end = start + this.span - 1;
199
+ const segments: SegmentRow[] = [];
200
+ let ok = true;
201
+ for (let i = start; i <= end; i++) {
202
+ const seg = this.db.getSegmentByIndex(stream, i);
203
+ if (!seg || !seg.r2_etag) {
204
+ ok = false;
205
+ break;
206
+ }
207
+ segments.push(seg);
208
+ }
209
+ if (!ok) return Result.ok(undefined);
210
+ const t0 = Date.now();
211
+ const runRes = await this.buildL0RunResult(stream, start, segments, state.index_secret);
212
+ if (Result.isError(runRes)) return runRes;
213
+ const run = runRes.value;
214
+ const elapsedNs = BigInt(Date.now() - t0) * 1_000_000n;
215
+ const persistRes = await this.persistRunResult(run, stream);
216
+ if (Result.isError(persistRes)) return persistRes;
217
+ this.db.insertIndexRun({
218
+ run_id: run.meta.runId,
219
+ stream,
220
+ level: run.meta.level,
221
+ start_segment: run.meta.startSegment,
222
+ end_segment: run.meta.endSegment,
223
+ object_key: run.meta.objectKey,
224
+ filter_len: run.meta.filterLen,
225
+ record_count: run.meta.recordCount,
226
+ });
227
+ if (this.metrics) {
228
+ this.metrics.record("tieredstore.index.build.latency", Number(elapsedNs), "ns", { level: String(run.meta.level) }, stream);
229
+ this.metrics.record("tieredstore.index.runs.built", 1, "count", { level: String(run.meta.level) }, stream);
230
+ this.recordActiveRuns(stream);
231
+ }
232
+ indexedThrough = end + 1;
233
+ this.db.updateIndexedThrough(stream, indexedThrough);
234
+ state.indexed_through = indexedThrough;
235
+ if (this.publishManifest) {
236
+ try {
237
+ await this.publishManifest(stream);
238
+ } catch {
239
+ // ignore manifest publish errors; will be retried by uploader/indexer
240
+ }
241
+ }
242
+ }
243
+ } finally {
244
+ this.building.delete(stream);
245
+ }
246
+ }
247
+
248
+ private async maybeCompactRuns(stream: string): Promise<Result<void, IndexBuildError>> {
249
+ if (this.span <= 0) return Result.ok(undefined);
250
+ if (this.compactionFanout <= 1) return Result.ok(undefined);
251
+ if (this.compacting.has(stream)) return Result.ok(undefined);
252
+ this.compacting.add(stream);
253
+ try {
254
+ for (;;) {
255
+ const group = this.findCompactionGroup(stream);
256
+ if (!group) {
257
+ await this.gcRetiredRuns(stream);
258
+ return Result.ok(undefined);
259
+ }
260
+ const t0 = Date.now();
261
+ const { level, runs } = group;
262
+ const runRes = await this.buildCompactedRunResult(stream, level + 1, runs);
263
+ if (Result.isError(runRes)) return runRes;
264
+ const run = runRes.value;
265
+ const elapsedNs = BigInt(Date.now() - t0) * 1_000_000n;
266
+ const persistRes = await this.persistRunResult(run, stream);
267
+ if (Result.isError(persistRes)) return persistRes;
268
+ this.db.insertIndexRun({
269
+ run_id: run.meta.runId,
270
+ stream,
271
+ level: run.meta.level,
272
+ start_segment: run.meta.startSegment,
273
+ end_segment: run.meta.endSegment,
274
+ object_key: run.meta.objectKey,
275
+ filter_len: run.meta.filterLen,
276
+ record_count: run.meta.recordCount,
277
+ });
278
+ const state = this.db.getIndexState(stream);
279
+ if (state && run.meta.endSegment + 1 > state.indexed_through) {
280
+ this.db.updateIndexedThrough(stream, run.meta.endSegment + 1);
281
+ state.indexed_through = run.meta.endSegment + 1;
282
+ }
283
+ const manifestRow = this.db.getManifestRow(stream);
284
+ const retiredGen = manifestRow.generation + 1;
285
+ const nowMs = this.db.nowMs();
286
+ this.db.retireIndexRuns(
287
+ runs.map((r) => r.run_id),
288
+ retiredGen,
289
+ nowMs
290
+ );
291
+ if (this.metrics) {
292
+ this.metrics.record("tieredstore.index.compact.latency", Number(elapsedNs), "ns", { level: String(run.meta.level) }, stream);
293
+ this.metrics.record("tieredstore.index.runs.compacted", 1, "count", { level: String(run.meta.level) }, stream);
294
+ this.recordActiveRuns(stream);
295
+ }
296
+ for (const r of runs) {
297
+ this.runCache.remove(r.object_key);
298
+ this.runDiskCache?.remove(r.object_key);
299
+ }
300
+ if (this.publishManifest) {
301
+ try {
302
+ await this.publishManifest(stream);
303
+ } catch {
304
+ // ignore manifest publish errors; will be retried
305
+ }
306
+ }
307
+ await this.gcRetiredRuns(stream);
308
+ }
309
+ } finally {
310
+ this.compacting.delete(stream);
311
+ }
312
+ }
313
+
314
+ private findCompactionGroup(stream: string): { level: number; runs: IndexRunRow[] } | null {
315
+ const runs = this.db.listIndexRuns(stream);
316
+ if (runs.length < this.compactionFanout) return null;
317
+ const byLevel = new Map<number, IndexRunRow[]>();
318
+ for (const r of runs) {
319
+ const arr = byLevel.get(r.level) ?? [];
320
+ arr.push(r);
321
+ byLevel.set(r.level, arr);
322
+ }
323
+ for (let level = 0; level <= this.maxLevel; level++) {
324
+ const levelRuns = byLevel.get(level);
325
+ if (!levelRuns || levelRuns.length < this.compactionFanout) continue;
326
+ const span = this.levelSpan(level);
327
+ for (let i = 0; i + this.compactionFanout <= levelRuns.length; i++) {
328
+ const base = levelRuns[i].start_segment;
329
+ let ok = true;
330
+ for (let j = 0; j < this.compactionFanout; j++) {
331
+ const r = levelRuns[i + j];
332
+ const expectStart = base + j * span;
333
+ if (r.level !== level || r.start_segment !== expectStart || r.end_segment !== expectStart + span - 1) {
334
+ ok = false;
335
+ break;
336
+ }
337
+ }
338
+ if (ok) return { level, runs: levelRuns.slice(i, i + this.compactionFanout) };
339
+ }
340
+ }
341
+ return null;
342
+ }
343
+
344
+ private levelSpan(level: number): number {
345
+ let span = this.span;
346
+ for (let i = 0; i < level; i++) span *= this.compactionFanout;
347
+ return span;
348
+ }
349
+
350
+ private async buildCompactedRunResult(
351
+ stream: string,
352
+ level: number,
353
+ inputs: IndexRunRow[]
354
+ ): Promise<Result<IndexRun, IndexBuildError>> {
355
+ if (inputs.length === 0) return invalidIndexBuild("compact: missing inputs");
356
+ const segments = new Map<bigint, Set<number>>();
357
+ const addSegment = (fp: bigint, seg: number) => {
358
+ let set = segments.get(fp);
359
+ if (!set) {
360
+ set = new Set<number>();
361
+ segments.set(fp, set);
362
+ }
363
+ set.add(seg);
364
+ };
365
+
366
+ const pending = inputs.slice();
367
+ const results: Array<{ meta: IndexRunRow; run: IndexRun }> = [];
368
+ const workers = Math.min(this.compactionConcurrency, pending.length);
369
+ let buildError: string | null = null;
370
+ const workerTasks: Promise<void>[] = [];
371
+ for (let w = 0; w < workers; w++) {
372
+ workerTasks.push(
373
+ (async () => {
374
+ for (;;) {
375
+ if (buildError) return;
376
+ const meta = pending.shift();
377
+ if (!meta) return;
378
+ const runRes = await this.loadRunResult(meta);
379
+ if (Result.isError(runRes)) {
380
+ buildError = runRes.error.message;
381
+ return;
382
+ }
383
+ const run = runRes.value;
384
+ if (!run) {
385
+ buildError = `missing run ${meta.run_id}`;
386
+ return;
387
+ }
388
+ results.push({ meta, run });
389
+ }
390
+ })()
391
+ );
392
+ }
393
+ await Promise.all(workerTasks);
394
+ if (buildError) return invalidIndexBuild(buildError);
395
+
396
+ for (const res of results) {
397
+ const run = res.run;
398
+ const meta = res.meta;
399
+ if (run.runType === RUN_TYPE_MASK16 && run.masks) {
400
+ for (let i = 0; i < run.fingerprints.length; i++) {
401
+ const fp = run.fingerprints[i];
402
+ const mask = run.masks[i];
403
+ for (let bit = 0; bit < 16; bit++) {
404
+ if ((mask & (1 << bit)) === 0) continue;
405
+ addSegment(fp, meta.start_segment + bit);
406
+ }
407
+ }
408
+ } else if (run.runType === RUN_TYPE_POSTINGS && run.postings) {
409
+ for (let i = 0; i < run.fingerprints.length; i++) {
410
+ const fp = run.fingerprints[i];
411
+ const postings = run.postings[i];
412
+ for (const rel of postings) addSegment(fp, meta.start_segment + rel);
413
+ }
414
+ } else {
415
+ return invalidIndexBuild(`unknown run type ${run.runType}`);
416
+ }
417
+ }
418
+
419
+ const startSegment = inputs[0].start_segment;
420
+ const endSegment = inputs[inputs.length - 1].end_segment;
421
+ const pairs = Array.from(segments.entries())
422
+ .map(([fp, set]) => {
423
+ const list = Array.from(set);
424
+ list.sort((a, b) => a - b);
425
+ const rel = list.map((seg) => seg - startSegment);
426
+ return { fp, rel };
427
+ })
428
+ .sort((a, b) => (a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0));
429
+
430
+ const fingerprints: bigint[] = [];
431
+ const postings: number[][] = [];
432
+ for (const p of pairs) {
433
+ fingerprints.push(p.fp);
434
+ postings.push(p.rel);
435
+ }
436
+
437
+ const fuseRes = buildBinaryFuseResult(fingerprints);
438
+ if (Result.isError(fuseRes)) return invalidIndexBuild(fuseRes.error.message);
439
+ const { filter, bytes } = fuseRes.value;
440
+ const shash = streamHash16Hex(stream);
441
+ const runId = `l${level}-${startSegment.toString().padStart(16, "0")}-${endSegment.toString().padStart(16, "0")}-${Date.now()}`;
442
+ const objectKey = indexRunObjectKey(shash, runId);
443
+ return Result.ok({
444
+ meta: {
445
+ runId,
446
+ level,
447
+ startSegment,
448
+ endSegment,
449
+ objectKey,
450
+ filterLen: bytes.byteLength,
451
+ recordCount: fingerprints.length,
452
+ },
453
+ runType: RUN_TYPE_POSTINGS,
454
+ filterBytes: bytes,
455
+ filter,
456
+ fingerprints,
457
+ postings,
458
+ });
459
+ }
460
+
461
+ private async gcRetiredRuns(stream: string): Promise<void> {
462
+ const retired = this.db.listRetiredIndexRuns(stream);
463
+ if (retired.length === 0) return;
464
+ const manifest = this.db.getManifestRow(stream);
465
+ const nowMs = this.db.nowMs();
466
+ const cutoffGen = this.retireGenWindow > 0 && manifest.generation > this.retireGenWindow ? manifest.generation - this.retireGenWindow : 0;
467
+ const toDelete: IndexRunRow[] = [];
468
+ for (const r of retired) {
469
+ const expiredByGen = r.retired_gen != null && r.retired_gen > 0 && r.retired_gen <= cutoffGen;
470
+ const expiredByTTL = r.retired_at_ms != null && r.retired_at_ms + BigInt(this.retireMinMs) <= nowMs;
471
+ if (expiredByGen || expiredByTTL) toDelete.push(r);
472
+ }
473
+ if (toDelete.length === 0) return;
474
+ for (const r of toDelete) {
475
+ try {
476
+ await this.os.delete(r.object_key);
477
+ } catch {
478
+ // ignore deletion errors
479
+ }
480
+ this.runCache.remove(r.object_key);
481
+ this.runDiskCache?.remove(r.object_key);
482
+ }
483
+ this.db.deleteIndexRuns(toDelete.map((r) => r.run_id));
484
+ }
485
+
486
+ private async buildL0RunResult(
487
+ stream: string,
488
+ startSegment: number,
489
+ segments: SegmentRow[],
490
+ secret: Uint8Array
491
+ ): Promise<Result<IndexRun, IndexBuildError>> {
492
+ const maskByFp = new Map<bigint, number>();
493
+ const pending = segments.slice();
494
+ const concurrency = Math.max(1, Math.min(this.buildConcurrency, pending.length));
495
+ const results: Array<Map<bigint, number>> = [];
496
+ let buildError: string | null = null;
497
+ const workers: Promise<void>[] = [];
498
+ for (let i = 0; i < concurrency; i++) {
499
+ workers.push(
500
+ (async () => {
501
+ for (;;) {
502
+ if (buildError) return;
503
+ const seg = pending.shift();
504
+ if (!seg) return;
505
+ const segBytesRes = await this.loadSegmentBytesResult(seg);
506
+ if (Result.isError(segBytesRes)) {
507
+ buildError = segBytesRes.error.message;
508
+ return;
509
+ }
510
+ const segBytes = segBytesRes.value;
511
+ const bit = seg.segment_index - startSegment;
512
+ const maskBit = 1 << bit;
513
+ const local = new Map<bigint, number>();
514
+ for (const blockRes of iterateBlocksResult(segBytes)) {
515
+ if (Result.isError(blockRes)) {
516
+ buildError = blockRes.error.message;
517
+ return;
518
+ }
519
+ const { decoded } = blockRes.value;
520
+ for (const rec of decoded.records) {
521
+ if (rec.routingKey.byteLength === 0) continue;
522
+ const fp = siphash24(secret, rec.routingKey);
523
+ const prev = local.get(fp) ?? 0;
524
+ local.set(fp, prev | maskBit);
525
+ }
526
+ }
527
+ results.push(local);
528
+ }
529
+ })()
530
+ );
531
+ }
532
+ await Promise.all(workers);
533
+ if (buildError) return invalidIndexBuild(buildError);
534
+ for (const local of results) {
535
+ for (const [fp, mask] of local.entries()) {
536
+ const prev = maskByFp.get(fp) ?? 0;
537
+ maskByFp.set(fp, prev | mask);
538
+ }
539
+ }
540
+ const entries = Array.from(maskByFp.entries()).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
541
+ const fingerprints = entries.map(([fp]) => fp);
542
+ const masks = entries.map(([, mask]) => mask);
543
+ const fuseRes = buildBinaryFuseResult(fingerprints);
544
+ if (Result.isError(fuseRes)) return invalidIndexBuild(fuseRes.error.message);
545
+ const { filter, bytes } = fuseRes.value;
546
+ const shash = streamHash16Hex(stream);
547
+ const endSegment = startSegment + this.span - 1;
548
+ const runId = `l0-${startSegment.toString().padStart(16, "0")}-${endSegment.toString().padStart(16, "0")}-${Date.now()}`;
549
+ const objectKey = indexRunObjectKey(shash, runId);
550
+ const run: IndexRun = {
551
+ meta: {
552
+ runId,
553
+ level: 0,
554
+ startSegment,
555
+ endSegment,
556
+ objectKey,
557
+ filterLen: bytes.byteLength,
558
+ recordCount: fingerprints.length,
559
+ },
560
+ runType: RUN_TYPE_MASK16,
561
+ filterBytes: bytes,
562
+ filter,
563
+ fingerprints,
564
+ masks,
565
+ };
566
+ return Result.ok(run);
567
+ }
568
+
569
+ private async persistRunResult(run: IndexRun, stream?: string): Promise<Result<void, IndexBuildError>> {
570
+ const payloadRes = encodeIndexRunResult(run);
571
+ if (Result.isError(payloadRes)) return invalidIndexBuild(payloadRes.error.message);
572
+ const payload = payloadRes.value;
573
+ if (this.metrics) {
574
+ this.metrics.record("tieredstore.index.bytes.written", payload.byteLength, "bytes", { level: String(run.meta.level) }, stream);
575
+ }
576
+ try {
577
+ await retry(
578
+ () => this.os.put(run.meta.objectKey, payload, { contentLength: payload.byteLength }),
579
+ {
580
+ retries: this.cfg.objectStoreRetries,
581
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
582
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
583
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
584
+ }
585
+ );
586
+ } catch (e: any) {
587
+ return invalidIndexBuild(String(e?.message ?? e));
588
+ }
589
+ this.runDiskCache?.put(run.meta.objectKey, payload);
590
+ this.runCache.put(run.meta.objectKey, run);
591
+ return Result.ok(undefined);
592
+ }
593
+
594
+ private async loadRunResult(meta: IndexRunRow): Promise<Result<IndexRun | null, IndexBuildError>> {
595
+ const cached = this.runCache.get(meta.object_key);
596
+ if (cached) return Result.ok(cached);
597
+ let bytes: Uint8Array | null = null;
598
+ if (this.runDiskCache) {
599
+ try {
600
+ bytes = this.runDiskCache.get(meta.object_key);
601
+ } catch {
602
+ this.runDiskCache.remove(meta.object_key);
603
+ }
604
+ }
605
+ if (!bytes) {
606
+ try {
607
+ bytes = await retry(
608
+ async () => {
609
+ const data = await this.os.get(meta.object_key);
610
+ if (!data) throw dsError(`missing index run ${meta.object_key}`);
611
+ return data;
612
+ },
613
+ {
614
+ retries: this.cfg.objectStoreRetries,
615
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
616
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
617
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
618
+ }
619
+ );
620
+ } catch (e: unknown) {
621
+ return invalidIndexBuild(errorMessage(e));
622
+ }
623
+ if (this.metrics) {
624
+ this.metrics.record("tieredstore.index.bytes.read", bytes.byteLength, "bytes", { level: String(meta.level) }, meta.stream);
625
+ }
626
+ this.runDiskCache?.put(meta.object_key, bytes);
627
+ }
628
+ const runRes = decodeIndexRunResult(bytes);
629
+ if (Result.isError(runRes)) {
630
+ this.runDiskCache?.remove(meta.object_key);
631
+ return Result.ok(null);
632
+ }
633
+ const run = runRes.value;
634
+ run.meta.runId = meta.run_id;
635
+ run.meta.objectKey = meta.object_key;
636
+ run.meta.level = meta.level;
637
+ run.meta.startSegment = meta.start_segment;
638
+ run.meta.endSegment = meta.end_segment;
639
+ run.meta.filterLen = meta.filter_len;
640
+ run.meta.recordCount = meta.record_count;
641
+ this.runCache.put(meta.object_key, run);
642
+ return Result.ok(run);
643
+ }
644
+
645
+ private async loadSegmentBytesResult(seg: SegmentRow): Promise<Result<Uint8Array, IndexBuildError>> {
646
+ if (seg.local_path && seg.local_path.length > 0) {
647
+ try {
648
+ return Result.ok(new Uint8Array(readFileSync(seg.local_path)));
649
+ } catch {
650
+ // fall through
651
+ }
652
+ }
653
+ const diskCache = this.segmentCache;
654
+ const key = segmentObjectKey(streamHash16Hex(seg.stream), seg.segment_index);
655
+ if (diskCache && diskCache.has(key)) {
656
+ diskCache.recordHit();
657
+ diskCache.touch(key);
658
+ try {
659
+ return Result.ok(new Uint8Array(readFileSync(diskCache.getPath(key))));
660
+ } catch {
661
+ diskCache.remove(key);
662
+ }
663
+ }
664
+ if (diskCache) diskCache.recordMiss();
665
+ try {
666
+ const data = await retry(
667
+ async () => {
668
+ const objectBytes = await this.os.get(key);
669
+ if (!objectBytes) throw dsError(`missing segment ${seg.segment_id}`);
670
+ if (diskCache) diskCache.put(key, objectBytes);
671
+ return objectBytes;
672
+ },
673
+ {
674
+ retries: this.cfg.objectStoreRetries,
675
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
676
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
677
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
678
+ }
679
+ );
680
+ return Result.ok(data);
681
+ } catch (e: unknown) {
682
+ return invalidIndexBuild(errorMessage(e));
683
+ }
684
+ }
685
+
686
+ private recordCacheStats(): void {
687
+ if (!this.metrics) return;
688
+ const mem = this.runCache.stats();
689
+ this.metrics.record("tieredstore.index.run_cache.used_bytes", mem.usedBytes, "bytes", { cache: "mem" });
690
+ this.metrics.record("tieredstore.index.run_cache.entries", mem.entries, "count", { cache: "mem" });
691
+ const deltaHits = mem.hits - this.lastRunCacheHits;
692
+ const deltaMisses = mem.misses - this.lastRunCacheMisses;
693
+ const deltaEvict = mem.evictions - this.lastRunCacheEvictions;
694
+ if (deltaHits > 0) this.metrics.record("tieredstore.index.run_cache.hits", deltaHits, "count", { cache: "mem" });
695
+ if (deltaMisses > 0) this.metrics.record("tieredstore.index.run_cache.misses", deltaMisses, "count", { cache: "mem" });
696
+ if (deltaEvict > 0) this.metrics.record("tieredstore.index.run_cache.evictions", deltaEvict, "count", { cache: "mem" });
697
+ this.lastRunCacheHits = mem.hits;
698
+ this.lastRunCacheMisses = mem.misses;
699
+ this.lastRunCacheEvictions = mem.evictions;
700
+
701
+ if (this.runDiskCache) {
702
+ const disk = this.runDiskCache.stats();
703
+ this.metrics.record("tieredstore.index.run_cache.used_bytes", disk.usedBytes, "bytes", { cache: "disk" });
704
+ this.metrics.record("tieredstore.index.run_cache.entries", disk.entryCount, "count", { cache: "disk" });
705
+ const dh = disk.hits - this.lastDiskHits;
706
+ const dm = disk.misses - this.lastDiskMisses;
707
+ const de = disk.evictions - this.lastDiskEvictions;
708
+ const db = disk.bytesAdded - this.lastDiskBytesAdded;
709
+ if (dh > 0) this.metrics.record("tieredstore.index.run_cache.hits", dh, "count", { cache: "disk" });
710
+ if (dm > 0) this.metrics.record("tieredstore.index.run_cache.misses", dm, "count", { cache: "disk" });
711
+ if (de > 0) this.metrics.record("tieredstore.index.run_cache.evictions", de, "count", { cache: "disk" });
712
+ if (db > 0) this.metrics.record("tieredstore.index.run_cache.bytes_added", db, "bytes", { cache: "disk" });
713
+ this.lastDiskHits = disk.hits;
714
+ this.lastDiskMisses = disk.misses;
715
+ this.lastDiskEvictions = disk.evictions;
716
+ this.lastDiskBytesAdded = disk.bytesAdded;
717
+ }
718
+ }
719
+
720
+ private recordActiveRuns(stream: string): void {
721
+ if (!this.metrics) return;
722
+ const runs = this.db.listIndexRuns(stream);
723
+ this.metrics.record("tieredstore.index.active_runs", runs.length, "count", undefined, stream);
724
+ const byLevel = new Map<number, number>();
725
+ for (const r of runs) byLevel.set(r.level, (byLevel.get(r.level) ?? 0) + 1);
726
+ for (const [level, count] of byLevel.entries()) {
727
+ this.metrics.record("tieredstore.index.active_runs", count, "count", { level: String(level) }, stream);
728
+ }
729
+ }
730
+ }
731
+
732
+ function binarySearch(arr: bigint[], target: bigint): number {
733
+ let lo = 0;
734
+ let hi = arr.length - 1;
735
+ while (lo <= hi) {
736
+ const mid = (lo + hi) >> 1;
737
+ const v = arr[mid];
738
+ if (v === target) return mid;
739
+ if (v < target) lo = mid + 1;
740
+ else hi = mid - 1;
741
+ }
742
+ return -1;
743
+ }
744
+
745
+ // segmentObjectKey handles stream hash + path.