@prisma/streams-server 0.0.1 → 0.1.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.
Files changed (85) 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 +1983 -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 +1440 -0
  15. package/src/db/schema.ts +619 -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 +459 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +858 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1341 -0
  58. package/src/touch/naming.ts +13 -0
  59. package/src/touch/routing_key_notifier.ts +275 -0
  60. package/src/touch/spec.ts +526 -0
  61. package/src/touch/touch_journal.ts +671 -0
  62. package/src/touch/touch_key_id.ts +20 -0
  63. package/src/touch/worker_pool.ts +189 -0
  64. package/src/touch/worker_protocol.ts +58 -0
  65. package/src/types/proper-lockfile.d.ts +1 -0
  66. package/src/uploader.ts +317 -0
  67. package/src/util/base32_crockford.ts +81 -0
  68. package/src/util/bloom256.ts +67 -0
  69. package/src/util/cleanup.ts +22 -0
  70. package/src/util/crc32c.ts +29 -0
  71. package/src/util/ds_error.ts +15 -0
  72. package/src/util/duration.ts +17 -0
  73. package/src/util/endian.ts +53 -0
  74. package/src/util/json_pointer.ts +148 -0
  75. package/src/util/log.ts +25 -0
  76. package/src/util/lru.ts +45 -0
  77. package/src/util/retry.ts +35 -0
  78. package/src/util/siphash.ts +71 -0
  79. package/src/util/stream_paths.ts +31 -0
  80. package/src/util/time.ts +14 -0
  81. package/src/util/yield.ts +3 -0
  82. package/build/index.d.mts +0 -1
  83. package/build/index.d.ts +0 -1
  84. package/build/index.js +0 -0
  85. package/build/index.mjs +0 -1
@@ -0,0 +1,1341 @@
1
+ import type { Config } from "../config";
2
+ import type { SqliteDurableStore } from "../db/db";
3
+ import { STREAM_FLAG_TOUCH } from "../db/db";
4
+ import type { IngestQueue } from "../ingest";
5
+ import type { StreamNotifier } from "../notifier";
6
+ import type { SchemaRegistryStore } from "../schema/registry";
7
+ import { isTouchEnabled } from "./spec";
8
+ import { TouchInterpreterWorkerPool } from "./worker_pool";
9
+ import { LruCache } from "../util/lru";
10
+ import type { BackpressureGate } from "../backpressure";
11
+ import { resolveTouchStreamName } from "./naming";
12
+ import { LiveTemplateRegistry, type TemplateDecl } from "./live_templates";
13
+ import { LiveMetricsV2 } from "./live_metrics";
14
+ import type { TouchConfig } from "./spec";
15
+ import type { RoutingKeyNotifier } from "./routing_key_notifier";
16
+ import { TouchJournal } from "./touch_journal";
17
+ import { Result } from "better-result";
18
+
19
+ const BASE_WAL_GC_INTERVAL_MS = (() => {
20
+ const raw = process.env.DS_BASE_WAL_GC_INTERVAL_MS;
21
+ if (raw == null || raw.trim() === "") return 1000;
22
+ const n = Number(raw);
23
+ if (!Number.isFinite(n) || n < 0) {
24
+ // eslint-disable-next-line no-console
25
+ console.error(`invalid DS_BASE_WAL_GC_INTERVAL_MS: ${raw}`);
26
+ return 1000;
27
+ }
28
+ return Math.floor(n);
29
+ })();
30
+
31
+ const BASE_WAL_GC_CHUNK_OFFSETS = (() => {
32
+ const raw = process.env.DS_BASE_WAL_GC_CHUNK_OFFSETS;
33
+ if (raw == null || raw.trim() === "") return 100_000;
34
+ const n = Number(raw);
35
+ if (!Number.isFinite(n) || n <= 0) {
36
+ // eslint-disable-next-line no-console
37
+ console.error(`invalid DS_BASE_WAL_GC_CHUNK_OFFSETS: ${raw}`);
38
+ return 100_000;
39
+ }
40
+ return Math.floor(n);
41
+ })();
42
+
43
+ type HotFineState = {
44
+ keyActiveCountsById: Map<number, number>;
45
+ keyGraceExpiryMsById: Map<number, number>;
46
+ templateActiveCountsById: Map<string, number>;
47
+ templateGraceExpiryMsById: Map<string, number>;
48
+ fineWaitersActive: number;
49
+ coarseWaitersActive: number;
50
+ broadFineWaitersActive: number;
51
+ nextSweepAtMs: number;
52
+ keysOverCapacity: boolean;
53
+ templatesOverCapacity: boolean;
54
+ };
55
+
56
+ type HotFineSnapshot = {
57
+ hotTemplateIdsForWorker: string[] | null;
58
+ hotKeyActiveSet: ReadonlyMap<number, number> | null;
59
+ hotKeyGraceSet: ReadonlyMap<number, number> | null;
60
+ hotTemplateActiveCount: number;
61
+ hotTemplateGraceCount: number;
62
+ hotKeyActiveCount: number;
63
+ hotKeyGraceCount: number;
64
+ hotTemplateCount: number;
65
+ hotKeyCount: number;
66
+ fineWaitersActive: number;
67
+ coarseWaitersActive: number;
68
+ broadFineWaitersActive: number;
69
+ templateFilteringEnabled: boolean;
70
+ keyFilteringEnabled: boolean;
71
+ };
72
+
73
+ const HOT_INTEREST_MAX_KEYS = 64;
74
+
75
+ type TouchRecord = {
76
+ keyId: number;
77
+ key?: string;
78
+ watermark: string;
79
+ entity: string;
80
+ kind: "table" | "template";
81
+ templateId?: string;
82
+ };
83
+
84
+ type EnsureTouchStreamError = {
85
+ kind: "touch_stream_content_type_mismatch";
86
+ message: string;
87
+ };
88
+
89
+ type RestrictedTemplateBucketState = {
90
+ bucketId: number;
91
+ templateKeyIds: Set<number>;
92
+ };
93
+
94
+ type StreamRuntimeTotals = {
95
+ scanRowsTotal: number;
96
+ scanBatchesTotal: number;
97
+ scannedButEmitted0BatchesTotal: number;
98
+ interpretedThroughDeltaTotal: number;
99
+ touchesEmittedTotal: number;
100
+ touchesTableTotal: number;
101
+ touchesTemplateTotal: number;
102
+ fineTouchesDroppedDueToBudgetTotal: number;
103
+ fineTouchesSkippedColdTemplateTotal: number;
104
+ fineTouchesSkippedColdKeyTotal: number;
105
+ fineTouchesSkippedTemplateBucketTotal: number;
106
+ waitTouchedTotal: number;
107
+ waitTimeoutTotal: number;
108
+ waitStaleTotal: number;
109
+ };
110
+
111
+ export class TouchInterpreterManager {
112
+ private readonly cfg: Config;
113
+ private readonly db: SqliteDurableStore;
114
+ private readonly ingest: IngestQueue;
115
+ private readonly notifier: StreamNotifier;
116
+ private readonly registry: SchemaRegistryStore;
117
+ private readonly backpressure?: BackpressureGate;
118
+ private readonly pool: TouchInterpreterWorkerPool;
119
+ private timer: any | null = null;
120
+ private running = false;
121
+ private stopping = false;
122
+ private readonly dirty = new Set<string>();
123
+ private readonly failures = new FailureTracker(1024);
124
+ private readonly lastTrimMs = new LruCache<string, number>(1024);
125
+ private readonly lastBaseWalGc = new LruCache<string, { atMs: number; through: bigint }>(1024);
126
+ private readonly templates: LiveTemplateRegistry;
127
+ private readonly liveMetrics: LiveMetricsV2;
128
+ private readonly lastTemplateGcMsByStream = new LruCache<string, number>(1024);
129
+ private readonly routingKeyNotifier?: RoutingKeyNotifier;
130
+ private readonly journals = new Map<string, TouchJournal>();
131
+ private readonly fineLagCoarseOnlyByStream = new Map<string, boolean>();
132
+ private readonly touchModeByStream = new Map<string, "idle" | "fine" | "restricted" | "coarseOnly">();
133
+ private readonly fineTokenBucketsByStream = new Map<string, { tokens: number; lastRefillMs: number }>();
134
+ private readonly hotFineByStream = new Map<string, HotFineState>();
135
+ private readonly lagSourceOffsetsByStream = new Map<string, number>();
136
+ private readonly restrictedTemplateBucketStateByStream = new Map<string, RestrictedTemplateBucketState>();
137
+ private readonly runtimeTotalsByStream = new Map<string, StreamRuntimeTotals>();
138
+ private readonly zeroRowBacklogStreakByStream = new Map<string, number>();
139
+ private streamScanCursor = 0;
140
+ private restartWorkerPoolRequested = false;
141
+ private lastWorkerPoolRestartAtMs = 0;
142
+
143
+ constructor(
144
+ cfg: Config,
145
+ db: SqliteDurableStore,
146
+ ingest: IngestQueue,
147
+ notifier: StreamNotifier,
148
+ registry: SchemaRegistryStore,
149
+ backpressure?: BackpressureGate,
150
+ routingKeyNotifier?: RoutingKeyNotifier
151
+ ) {
152
+ this.cfg = cfg;
153
+ this.db = db;
154
+ this.ingest = ingest;
155
+ this.notifier = notifier;
156
+ this.registry = registry;
157
+ this.backpressure = backpressure;
158
+ this.pool = new TouchInterpreterWorkerPool(cfg, cfg.interpreterWorkers);
159
+ this.templates = new LiveTemplateRegistry(db);
160
+ this.liveMetrics = new LiveMetricsV2(db, ingest, {
161
+ routingKeyNotifier,
162
+ getTouchJournal: (derivedStream) => {
163
+ const j = this.journals.get(derivedStream);
164
+ if (!j) return null;
165
+ return { meta: j.getMeta(), interval: j.snapshotAndResetIntervalStats() };
166
+ },
167
+ });
168
+ this.routingKeyNotifier = routingKeyNotifier;
169
+ }
170
+
171
+ start(): void {
172
+ if (this.timer) return;
173
+ this.stopping = false;
174
+ this.pool.start();
175
+ this.seedInterpretersFromRegistry();
176
+ const liveMetricsRes = this.liveMetrics.ensureStreamResult();
177
+ if (Result.isError(liveMetricsRes)) {
178
+ // eslint-disable-next-line no-console
179
+ console.error("touch live metrics stream validation failed", liveMetricsRes.error.message);
180
+ } else {
181
+ this.liveMetrics.start();
182
+ }
183
+ if (this.cfg.interpreterCheckIntervalMs > 0) {
184
+ this.timer = setInterval(() => {
185
+ void this.tick();
186
+ }, this.cfg.interpreterCheckIntervalMs);
187
+ }
188
+ }
189
+
190
+ stop(): void {
191
+ this.stopping = true;
192
+ if (this.timer) clearInterval(this.timer);
193
+ this.timer = null;
194
+ this.pool.stop();
195
+ this.liveMetrics.stop();
196
+ for (const j of this.journals.values()) j.stop();
197
+ this.journals.clear();
198
+ this.fineLagCoarseOnlyByStream.clear();
199
+ this.touchModeByStream.clear();
200
+ this.fineTokenBucketsByStream.clear();
201
+ this.hotFineByStream.clear();
202
+ this.lagSourceOffsetsByStream.clear();
203
+ this.restrictedTemplateBucketStateByStream.clear();
204
+ this.runtimeTotalsByStream.clear();
205
+ this.zeroRowBacklogStreakByStream.clear();
206
+ this.restartWorkerPoolRequested = false;
207
+ this.lastWorkerPoolRestartAtMs = 0;
208
+ }
209
+
210
+ notify(stream: string): void {
211
+ this.dirty.add(stream);
212
+ }
213
+
214
+ async tick(): Promise<void> {
215
+ if (this.stopping) return;
216
+ if (this.running) return;
217
+ if (this.cfg.interpreterWorkers <= 0) return;
218
+ this.running = true;
219
+ try {
220
+ const nowMs = Date.now();
221
+ const dirtyNow = new Set(this.dirty);
222
+ this.dirty.clear();
223
+ const states = this.db.listStreamInterpreters();
224
+ if (states.length === 0) return;
225
+ const stateByStream = new Map(states.map((s) => [s.stream, s]));
226
+
227
+ const ordered: string[] = [];
228
+ for (const s of dirtyNow) if (stateByStream.has(s)) ordered.push(s);
229
+ for (const s of stateByStream.keys()) if (!dirtyNow.has(s)) ordered.push(s);
230
+ const prioritized = this.prioritizeStreamsForProcessing(ordered, nowMs);
231
+
232
+ const maxConcurrent = Math.max(1, this.cfg.interpreterWorkers);
233
+ const tasks: Promise<void>[] = [];
234
+ if (prioritized.length > 0) {
235
+ const total = prioritized.length;
236
+ const start = this.streamScanCursor % total;
237
+ for (let i = 0; i < total && tasks.length < maxConcurrent; i++) {
238
+ if (this.stopping) break;
239
+ const stream = prioritized[(start + i) % total]!;
240
+ if (this.failures.shouldSkip(stream)) continue;
241
+ const st = stateByStream.get(stream);
242
+ if (!st) continue;
243
+ const p = this.processOne(stream, st.interpreted_through).catch((e) => {
244
+ this.failures.recordFailure(stream);
245
+ // eslint-disable-next-line no-console
246
+ console.error("touch interpreter failed", stream, e);
247
+ });
248
+ tasks.push(p);
249
+ }
250
+ this.streamScanCursor = (start + Math.max(1, tasks.length)) % total;
251
+ }
252
+ await Promise.all(tasks);
253
+ if (this.restartWorkerPoolRequested) {
254
+ this.restartWorkerPoolRequested = false;
255
+ try {
256
+ this.pool.restart();
257
+ this.lastWorkerPoolRestartAtMs = Date.now();
258
+ } catch (e) {
259
+ // eslint-disable-next-line no-console
260
+ console.error("touch interpreter worker-pool restart failed", e);
261
+ }
262
+ }
263
+
264
+ // Opportunistically enforce touch retention. This keeps internal touch
265
+ // WAL bounded even if the touch stream isn't being actively read.
266
+ for (const stream of stateByStream.keys()) {
267
+ if (this.stopping) break;
268
+ const srow = this.db.getStream(stream);
269
+ if (!srow || this.db.isDeleted(srow)) continue;
270
+ const regRes = this.registry.getRegistryResult(stream);
271
+ if (Result.isError(regRes)) {
272
+ // eslint-disable-next-line no-console
273
+ console.error("touch registry read failed", stream, regRes.error.message);
274
+ continue;
275
+ }
276
+ const reg = regRes.value;
277
+ if (!isTouchEnabled(reg.interpreter)) continue;
278
+ const derived = resolveTouchStreamName(stream, reg.interpreter.touch);
279
+ if ((reg.interpreter.touch.storage ?? "memory") === "sqlite") {
280
+ const ensureRes = this.ensureTouchStream(derived);
281
+ if (Result.isError(ensureRes)) {
282
+ // eslint-disable-next-line no-console
283
+ console.error("touch retention stream validation failed", stream, ensureRes.error.message);
284
+ continue;
285
+ }
286
+ const retentionMs = reg.interpreter.touch.retention?.maxAgeMs;
287
+ if (retentionMs != null) this.maybeTrimTouchStream(derived, retentionMs);
288
+ }
289
+ }
290
+
291
+ // Opportunistically GC base WAL beyond the interpreter checkpoint.
292
+ //
293
+ // commitManifest() already GC's on upload, but it can't retroactively GC
294
+ // rows that were held back by interpreter lag once the interpreter later
295
+ // catches up (unless another upload happens). This loop makes GC progress
296
+ // deterministic for "catch up after lag" scenarios.
297
+ for (const stream of stateByStream.keys()) {
298
+ if (this.stopping) break;
299
+ const srow = this.db.getStream(stream);
300
+ if (!srow || this.db.isDeleted(srow)) continue;
301
+ const interp = this.db.getStreamInterpreter(stream);
302
+ if (!interp) continue;
303
+ this.maybeGcBaseWal(stream, srow.uploaded_through, interp.interpreted_through);
304
+ }
305
+
306
+ // Template retirement GC + last-seen flush (sliding window).
307
+ const touchCfgByStream = new Map<string, TouchConfig>();
308
+ let persistIntervalMin = Number.POSITIVE_INFINITY;
309
+ for (const stream of stateByStream.keys()) {
310
+ if (this.stopping) break;
311
+ const regRes = this.registry.getRegistryResult(stream);
312
+ if (Result.isError(regRes)) {
313
+ // eslint-disable-next-line no-console
314
+ console.error("touch registry read failed", stream, regRes.error.message);
315
+ continue;
316
+ }
317
+ const reg = regRes.value;
318
+ if (!isTouchEnabled(reg.interpreter)) continue;
319
+ const touchCfg = reg.interpreter.touch;
320
+ touchCfgByStream.set(stream, touchCfg);
321
+ const persistInterval = touchCfg.templates?.lastSeenPersistIntervalMs ?? 5 * 60 * 1000;
322
+ if (persistInterval < persistIntervalMin) persistIntervalMin = persistInterval;
323
+ }
324
+
325
+ if (touchCfgByStream.size > 0 && Number.isFinite(persistIntervalMin)) {
326
+ this.templates.flushLastSeen(nowMs, persistIntervalMin);
327
+ }
328
+
329
+ for (const [stream, touchCfg] of touchCfgByStream.entries()) {
330
+ if (this.stopping) break;
331
+ const gcInterval = touchCfg.templates?.gcIntervalMs ?? 60_000;
332
+ const last = this.lastTemplateGcMsByStream.get(stream) ?? 0;
333
+ if (nowMs - last < gcInterval) continue;
334
+ this.lastTemplateGcMsByStream.set(stream, nowMs);
335
+
336
+ const retired = this.templates.gcRetireExpired(stream, nowMs);
337
+ if (retired.retired.length > 0) {
338
+ void this.liveMetrics.emitLifecycle(retired.retired);
339
+ }
340
+ }
341
+ } finally {
342
+ this.running = false;
343
+ }
344
+ }
345
+
346
+ private async processOne(stream: string, interpretedThrough: bigint): Promise<void> {
347
+ const srow = this.db.getStream(stream);
348
+ if (!srow || this.db.isDeleted(srow)) {
349
+ this.db.deleteStreamInterpreter(stream);
350
+ return;
351
+ }
352
+
353
+ const next = srow.next_offset;
354
+ if (next <= 0n) return;
355
+ const fromOffset = interpretedThrough + 1n;
356
+ const toOffset = next - 1n;
357
+ if (fromOffset > toOffset) return;
358
+
359
+ const regRes = this.registry.getRegistryResult(stream);
360
+ if (Result.isError(regRes)) {
361
+ // eslint-disable-next-line no-console
362
+ console.error("touch registry read failed", stream, regRes.error.message);
363
+ this.db.deleteStreamInterpreter(stream);
364
+ return;
365
+ }
366
+ const reg = regRes.value;
367
+ if (!isTouchEnabled(reg.interpreter)) {
368
+ this.db.deleteStreamInterpreter(stream);
369
+ return;
370
+ }
371
+ const touchCfg = reg.interpreter.touch;
372
+ const touchStorage = touchCfg.storage ?? "memory";
373
+ const failProcessing = (message: string): void => {
374
+ this.failures.recordFailure(stream);
375
+ this.liveMetrics.recordInterpreterError(stream, touchCfg);
376
+ // eslint-disable-next-line no-console
377
+ console.error("touch interpreter failed", stream, message);
378
+ };
379
+
380
+ const nowMs = Date.now();
381
+ const hotFine = touchStorage === "memory" ? this.getHotFineSnapshot(stream, touchCfg, nowMs) : null;
382
+ const fineWaitersActive = hotFine?.fineWaitersActive ?? 0;
383
+ const coarseWaitersActive = hotFine?.coarseWaitersActive ?? 0;
384
+ const hasAnyWaiters = touchStorage === "memory" ? fineWaitersActive + coarseWaitersActive > 0 : true;
385
+ const hasFineDemand =
386
+ touchStorage === "memory"
387
+ ? fineWaitersActive > 0 || (hotFine?.broadFineWaitersActive ?? 0) > 0 || (hotFine?.hotKeyCount ?? 0) > 0 || (hotFine?.hotTemplateCount ?? 0) > 0
388
+ : true;
389
+
390
+ // Guardrail: when lag/backlog grows too large, temporarily suppress
391
+ // fine/template touches (coarse table touches are still emitted).
392
+ const lagAtStart = toOffset >= interpretedThrough ? toOffset - interpretedThrough : 0n;
393
+ const suppressFineDueToLag = this.computeSuppressFineDueToLag(stream, touchCfg, lagAtStart, hasFineDemand);
394
+ if (touchStorage === "memory") {
395
+ const derived = resolveTouchStreamName(stream, touchCfg);
396
+ const j = this.getOrCreateJournal(derived, touchCfg);
397
+ j.setCoalesceMs(this.computeAdaptiveCoalesceMs(touchCfg, lagAtStart, hasAnyWaiters));
398
+ }
399
+
400
+ const fineBudgetPerBatch = Math.max(0, Math.floor(touchCfg.fineTouchBudgetPerBatch ?? 2000));
401
+ const lagReservedFineBudgetPerBatch = Math.max(0, Math.floor(touchCfg.lagReservedFineTouchBudgetPerBatch ?? 200));
402
+ let fineBudget = !hasFineDemand ? 0 : suppressFineDueToLag ? lagReservedFineBudgetPerBatch : fineBudgetPerBatch;
403
+ let tokenLimited = false;
404
+ let refundFineTokens: ((used: number) => void) | null = null;
405
+ if (fineBudget > 0) {
406
+ const tokenGrant = this.reserveFineTokens(stream, touchCfg, fineBudget, nowMs);
407
+ fineBudget = tokenGrant.granted;
408
+ tokenLimited = tokenGrant.tokenLimited;
409
+ refundFineTokens = tokenGrant.refund;
410
+ }
411
+
412
+ let emitFineTouches = hasFineDemand && fineBudget > 0;
413
+ let fineGranularity: "key" | "template" = "key";
414
+ const batchStartMs = Date.now();
415
+ if (
416
+ emitFineTouches &&
417
+ hotFine &&
418
+ hotFine.hotKeyCount === 0 &&
419
+ hotFine.hotTemplateCount === 0 &&
420
+ hotFine.broadFineWaitersActive === 0 &&
421
+ hotFine.keyFilteringEnabled &&
422
+ !hotFine.templateFilteringEnabled
423
+ ) {
424
+ // No observed waiters/interests for fine keys/templates: coarse-only is cheaper.
425
+ emitFineTouches = false;
426
+ }
427
+ if (emitFineTouches && suppressFineDueToLag) fineGranularity = "template";
428
+ if (fineGranularity !== "template") {
429
+ this.restrictedTemplateBucketStateByStream.delete(stream);
430
+ }
431
+ const interpretMode: "full" | "hotTemplatesOnly" = fineGranularity === "template" ? "hotTemplatesOnly" : "full";
432
+ const touchMode: "idle" | "fine" | "restricted" | "coarseOnly" = !hasAnyWaiters ? "idle" : emitFineTouches ? (suppressFineDueToLag ? "restricted" : "fine") : "coarseOnly";
433
+ this.touchModeByStream.set(stream, touchMode);
434
+
435
+ const processRes = await this.pool.processResult({
436
+ stream,
437
+ fromOffset,
438
+ toOffset,
439
+ interpreter: reg.interpreter,
440
+ maxRows: Math.max(1, this.cfg.interpreterMaxBatchRows),
441
+ maxBytes: Math.max(1, this.cfg.interpreterMaxBatchBytes),
442
+ emitFineTouches,
443
+ fineTouchBudget: emitFineTouches ? fineBudget : 0,
444
+ fineGranularity,
445
+ interpretMode,
446
+ filterHotTemplates: !!(hotFine && hotFine.templateFilteringEnabled),
447
+ hotTemplateIds: hotFine?.hotTemplateIdsForWorker ?? null,
448
+ });
449
+ if (Result.isError(processRes)) {
450
+ failProcessing(processRes.error.message);
451
+ return;
452
+ }
453
+ const res = processRes.value;
454
+ if (res.stats.rowsRead === 0 && toOffset >= fromOffset && this.rangeLikelyHasRows(stream, fromOffset, toOffset)) {
455
+ const nextStreak = (this.zeroRowBacklogStreakByStream.get(stream) ?? 0) + 1;
456
+ this.zeroRowBacklogStreakByStream.set(stream, nextStreak);
457
+ if (nextStreak >= 5) {
458
+ const now = Date.now();
459
+ if (now - this.lastWorkerPoolRestartAtMs >= 30_000) {
460
+ this.restartWorkerPoolRequested = true;
461
+ // eslint-disable-next-line no-console
462
+ console.error(
463
+ "touch interpreter produced zero-row batch despite WAL backlog; scheduling worker-pool restart",
464
+ stream,
465
+ `from=${fromOffset.toString()}`,
466
+ `to=${toOffset.toString()}`
467
+ );
468
+ }
469
+ }
470
+ } else {
471
+ this.zeroRowBacklogStreakByStream.delete(stream);
472
+ }
473
+ if (refundFineTokens) {
474
+ refundFineTokens(Math.max(0, res.stats.templateTouchesEmitted ?? 0));
475
+ }
476
+ const batchDurationMs = Math.max(0, Date.now() - batchStartMs);
477
+
478
+ let touches = res.touches;
479
+ const fineDroppedDueToBudget = Math.max(0, res.stats.fineTouchesDroppedDueToBudget ?? 0);
480
+ let fineSkippedColdKey = 0;
481
+ let fineSkippedTemplateBucket = 0;
482
+
483
+ if (touchStorage === "memory" && hotFine && hotFine.keyFilteringEnabled && fineGranularity !== "template") {
484
+ const keyActiveSet = hotFine.hotKeyActiveSet;
485
+ const keyGraceSet = hotFine.hotKeyGraceSet;
486
+ const keyCount = (keyActiveSet?.size ?? 0) + (keyGraceSet?.size ?? 0);
487
+ if (keyCount === 0) {
488
+ for (const t of touches) if (t.kind === "template") fineSkippedColdKey += 1;
489
+ touches = touches.filter((t) => t.kind === "table");
490
+ } else {
491
+ const filtered: typeof touches = [];
492
+ for (const t of touches) {
493
+ if (t.kind !== "template") {
494
+ filtered.push(t);
495
+ continue;
496
+ }
497
+ const keyId = t.keyId >>> 0;
498
+ if ((keyActiveSet && keyActiveSet.has(keyId)) || (keyGraceSet && keyGraceSet.has(keyId))) {
499
+ filtered.push(t);
500
+ } else fineSkippedColdKey += 1;
501
+ }
502
+ touches = filtered;
503
+ }
504
+ }
505
+
506
+ if (touchStorage === "memory" && fineGranularity === "template" && touches.length > 0) {
507
+ const coalesced = this.coalesceRestrictedTemplateTouches(stream, touchCfg, touches);
508
+ touches = coalesced.touches;
509
+ fineSkippedTemplateBucket = coalesced.dropped;
510
+ }
511
+
512
+ if (touches.length > 0) {
513
+ const derived = res.derivedStream;
514
+ if (touchStorage === "sqlite") {
515
+ const ensureRes = this.ensureTouchStream(derived);
516
+ if (Result.isError(ensureRes)) {
517
+ failProcessing(ensureRes.error.message);
518
+ return;
519
+ }
520
+ const encoder = new TextEncoder();
521
+ for (const t of touches) {
522
+ if (!t.key) {
523
+ failProcessing("sqlite touch storage requires routing key strings");
524
+ return;
525
+ }
526
+ }
527
+ const rows = touches.map((t) => ({
528
+ routingKey: encoder.encode(t.key!),
529
+ contentType: "application/json",
530
+ payload: encoder.encode(
531
+ JSON.stringify({
532
+ sourceOffset: t.watermark,
533
+ entity: t.entity,
534
+ kind: t.kind,
535
+ ...(t.kind === "template" && t.templateId ? { templateId: t.templateId } : {}),
536
+ })
537
+ ),
538
+ }));
539
+ const appendRes = await this.ingest.appendInternal({
540
+ stream: derived,
541
+ baseAppendMs: this.db.nowMs(),
542
+ rows,
543
+ contentType: "application/json",
544
+ });
545
+ if (Result.isError(appendRes)) {
546
+ failProcessing(`touch append failed: ${appendRes.error.kind}`);
547
+ return;
548
+ }
549
+ if (appendRes.value.appendedRows > 0) {
550
+ this.notifier.notify(derived, appendRes.value.lastOffset);
551
+ if (this.routingKeyNotifier) {
552
+ const appended = appendRes.value.appendedRows;
553
+ const start = appendRes.value.lastOffset - BigInt(appended) + 1n;
554
+ let keys = touches.map((t) => t.key ?? "");
555
+ // Be defensive: only notify for the rows we actually appended.
556
+ if (keys.length !== appended) keys = keys.slice(Math.max(0, keys.length - appended));
557
+ let seq = start;
558
+ for (const k of keys) {
559
+ if (!k) continue;
560
+ this.routingKeyNotifier.notify(derived, k, seq);
561
+ seq += 1n;
562
+ }
563
+ }
564
+ }
565
+ } else {
566
+ const j = this.getOrCreateJournal(derived, touchCfg);
567
+ for (const t of touches) {
568
+ let sourceOffsetSeq: bigint | undefined;
569
+ try {
570
+ sourceOffsetSeq = BigInt(t.watermark);
571
+ } catch {
572
+ sourceOffsetSeq = undefined;
573
+ }
574
+ j.touch(t.keyId >>> 0, sourceOffsetSeq);
575
+ }
576
+ }
577
+ }
578
+
579
+ // Live Query metrics are best-effort; do not affect processing.
580
+ try {
581
+ const lag = toOffset >= res.processedThrough ? toOffset - res.processedThrough : 0n;
582
+ const lagNum = lag > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(lag);
583
+ const effectiveLag = hasFineDemand ? lagNum : 0;
584
+ this.lagSourceOffsetsByStream.set(stream, effectiveLag);
585
+ const maxSourceTsMs = Number(res.stats.maxSourceTsMs ?? 0);
586
+ const commitLagMs = maxSourceTsMs > 0 ? Math.max(0, Date.now() - maxSourceTsMs) : undefined;
587
+ this.liveMetrics.recordInterpreterBatch({
588
+ stream,
589
+ touchCfg,
590
+ rowsRead: res.stats.rowsRead,
591
+ changes: res.stats.changes,
592
+ touches: touches.map((t) => ({ keyId: t.keyId >>> 0, kind: t.kind })),
593
+ lagSourceOffsets: effectiveLag,
594
+ commitLagMs,
595
+ fineTouchesDroppedDueToBudget: fineDroppedDueToBudget,
596
+ fineTouchesSkippedColdTemplate: Math.max(0, res.stats.fineTouchesSkippedColdTemplate ?? 0),
597
+ fineTouchesSkippedColdKey: fineSkippedColdKey,
598
+ fineTouchesSkippedTemplateBucket: fineSkippedTemplateBucket,
599
+ fineTouchesSuppressedDueToLag: suppressFineDueToLag,
600
+ fineTouchesSuppressedDueToLagMs: suppressFineDueToLag ? batchDurationMs : 0,
601
+ fineTouchesSuppressedDueToBudget: !!res.stats.fineTouchesSuppressedDueToBudget || tokenLimited,
602
+ touchMode,
603
+ hotFineKeys: hotFine?.hotKeyCount ?? 0,
604
+ hotTemplates: hotFine?.hotTemplateCount ?? 0,
605
+ hotFineKeysActive: hotFine?.hotKeyActiveCount ?? 0,
606
+ hotFineKeysGrace: hotFine?.hotKeyGraceCount ?? 0,
607
+ hotTemplatesActive: hotFine?.hotTemplateActiveCount ?? 0,
608
+ hotTemplatesGrace: hotFine?.hotTemplateGraceCount ?? 0,
609
+ fineWaitersActive,
610
+ coarseWaitersActive,
611
+ broadFineWaitersActive: hotFine?.broadFineWaitersActive ?? 0,
612
+ scannedButEmitted0: res.stats.rowsRead > 0 && touches.length === 0,
613
+ noInterestFastForward: false,
614
+ interpretedThroughDelta:
615
+ res.processedThrough >= interpretedThrough
616
+ ? Number((res.processedThrough - interpretedThrough) > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : res.processedThrough - interpretedThrough)
617
+ : 0,
618
+ touchesEmittedDelta: touches.length,
619
+ });
620
+ } catch {
621
+ // ignore
622
+ }
623
+
624
+ const interpretedDelta =
625
+ res.processedThrough >= interpretedThrough
626
+ ? Number((res.processedThrough - interpretedThrough) > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : res.processedThrough - interpretedThrough)
627
+ : 0;
628
+ const totals = this.getOrCreateRuntimeTotals(stream);
629
+ totals.scanBatchesTotal += 1;
630
+ totals.scanRowsTotal += Math.max(0, res.stats.rowsRead);
631
+ if (res.stats.rowsRead > 0 && touches.length === 0) totals.scannedButEmitted0BatchesTotal += 1;
632
+ totals.interpretedThroughDeltaTotal += interpretedDelta;
633
+ totals.touchesEmittedTotal += touches.length;
634
+ let tableTouches = 0;
635
+ let templateTouches = 0;
636
+ for (const t of touches) {
637
+ if (t.kind === "table") tableTouches += 1;
638
+ else templateTouches += 1;
639
+ }
640
+ totals.touchesTableTotal += tableTouches;
641
+ totals.touchesTemplateTotal += templateTouches;
642
+ totals.fineTouchesDroppedDueToBudgetTotal += fineDroppedDueToBudget;
643
+ totals.fineTouchesSkippedColdTemplateTotal += Math.max(0, res.stats.fineTouchesSkippedColdTemplate ?? 0);
644
+ totals.fineTouchesSkippedColdKeyTotal += fineSkippedColdKey;
645
+ totals.fineTouchesSkippedTemplateBucketTotal += fineSkippedTemplateBucket;
646
+
647
+ this.db.updateStreamInterpreterThrough(stream, res.processedThrough);
648
+ if (res.processedThrough < toOffset) this.dirty.add(stream);
649
+ this.failures.recordSuccess(stream);
650
+ }
651
+
652
+ private ensureTouchStream(stream: string): Result<void, EnsureTouchStreamError> {
653
+ const existing = this.db.getStream(stream);
654
+ if (existing) {
655
+ if (String(existing.content_type) !== "application/json") {
656
+ return Result.err({
657
+ kind: "touch_stream_content_type_mismatch",
658
+ message: `touch stream content-type mismatch: ${existing.content_type}`,
659
+ });
660
+ }
661
+ if ((existing.stream_flags & STREAM_FLAG_TOUCH) === 0) this.db.addStreamFlags(stream, STREAM_FLAG_TOUCH);
662
+ return Result.ok(undefined);
663
+ }
664
+ this.db.ensureStream(stream, { contentType: "application/json", streamFlags: STREAM_FLAG_TOUCH });
665
+ return Result.ok(undefined);
666
+ }
667
+
668
+ private maybeTrimTouchStream(stream: string, maxAgeMs: number): void {
669
+ const now = Date.now();
670
+ const last = this.lastTrimMs.get(stream) ?? 0;
671
+ // Throttle trims; tick can run frequently.
672
+ if (now - last < 10_000) return;
673
+ this.lastTrimMs.set(stream, now);
674
+
675
+ try {
676
+ const res = this.db.trimWalByAge(stream, maxAgeMs);
677
+ if (res.trimmedBytes > 0 && this.backpressure) this.backpressure.adjustOnWalTrim(res.trimmedBytes);
678
+ } catch (e) {
679
+ // eslint-disable-next-line no-console
680
+ console.error("touch retention trim failed", stream, e);
681
+ }
682
+ }
683
+
684
+ private maybeGcBaseWal(stream: string, uploadedThrough: bigint, interpretedThrough: bigint): void {
685
+ const gcTargetThrough = interpretedThrough < uploadedThrough ? interpretedThrough : uploadedThrough;
686
+ if (gcTargetThrough < 0n) return;
687
+
688
+ const now = Date.now();
689
+ const last = this.lastBaseWalGc.get(stream) ?? { atMs: 0, through: -1n };
690
+ // Avoid doing heavy DELETE work too frequently.
691
+ if (now - last.atMs < BASE_WAL_GC_INTERVAL_MS) return;
692
+ if (gcTargetThrough <= last.through) {
693
+ this.lastBaseWalGc.set(stream, { atMs: now, through: last.through });
694
+ return;
695
+ }
696
+
697
+ // Chunk deletes to avoid long event-loop stalls on "catch up after lag" runs.
698
+ const chunk = BigInt(BASE_WAL_GC_CHUNK_OFFSETS);
699
+ const maxThroughThisSweep = chunk > 0n ? last.through + chunk : gcTargetThrough;
700
+ const gcThrough = gcTargetThrough > maxThroughThisSweep ? maxThroughThisSweep : gcTargetThrough;
701
+
702
+ try {
703
+ const start = Date.now();
704
+ const res = this.db.deleteWalThrough(stream, gcThrough);
705
+ const durationMs = Date.now() - start;
706
+ if (res.deletedRows > 0 || res.deletedBytes > 0) {
707
+ this.liveMetrics.recordBaseWalGc(stream, { deletedRows: res.deletedRows, deletedBytes: res.deletedBytes, durationMs });
708
+ }
709
+ this.lastBaseWalGc.set(stream, { atMs: now, through: gcThrough });
710
+ } catch (e) {
711
+ // eslint-disable-next-line no-console
712
+ console.error("base WAL gc failed", stream, e);
713
+ this.lastBaseWalGc.set(stream, { atMs: now, through: last.through });
714
+ }
715
+ }
716
+
717
+ private seedInterpretersFromRegistry(): void {
718
+ // Bootstrap support: bootstrapFromR2 restores schema registry JSON but does not
719
+ // populate stream_interpreters. Seeding here makes interpreters start working
720
+ // after bootstraps and restarts without requiring a no-op config update.
721
+ try {
722
+ const rows = this.db.db.query(`SELECT stream, schema_json FROM schemas;`).all() as any[];
723
+ for (const row of rows) {
724
+ const stream = String(row.stream);
725
+ const json = String(row.schema_json ?? "");
726
+ let raw: any;
727
+ try {
728
+ raw = JSON.parse(json);
729
+ } catch {
730
+ continue;
731
+ }
732
+ const enabled = !!raw?.interpreter?.touch?.enabled;
733
+ if (enabled) this.db.ensureStreamInterpreter(stream);
734
+ else this.db.deleteStreamInterpreter(stream);
735
+ }
736
+ } catch {
737
+ // ignore
738
+ }
739
+ }
740
+
741
+ activateTemplates(args: {
742
+ stream: string;
743
+ touchCfg: TouchConfig;
744
+ baseStreamNextOffset: bigint;
745
+ activeFromTouchOffset: string;
746
+ templates: TemplateDecl[];
747
+ inactivityTtlMs: number;
748
+ }): { activated: Array<{ templateId: string; state: "active"; activeFromTouchOffset: string }>; denied: Array<{ templateId: string; reason: string }> } {
749
+ const nowMs = Date.now();
750
+ const limits = {
751
+ maxActiveTemplatesPerStream: args.touchCfg.templates?.maxActiveTemplatesPerStream ?? 2048,
752
+ maxActiveTemplatesPerEntity: args.touchCfg.templates?.maxActiveTemplatesPerEntity ?? 256,
753
+ activationRateLimitPerMinute: args.touchCfg.templates?.activationRateLimitPerMinute ?? 100,
754
+ };
755
+
756
+ const res = this.templates.activate({
757
+ stream: args.stream,
758
+ activeFromTouchOffset: args.activeFromTouchOffset,
759
+ baseStreamNextOffset: args.baseStreamNextOffset,
760
+ templates: args.templates,
761
+ inactivityTtlMs: args.inactivityTtlMs,
762
+ limits,
763
+ nowMs,
764
+ });
765
+
766
+ const deniedRate = res.denied.filter((d) => d.reason === "rate_limited").length;
767
+ if (deniedRate > 0) this.liveMetrics.recordActivationDenied(args.stream, args.touchCfg, deniedRate);
768
+ if (res.lifecycle.length > 0) void this.liveMetrics.emitLifecycle(res.lifecycle);
769
+
770
+ return { activated: res.activated, denied: res.denied };
771
+ }
772
+
773
+ heartbeatTemplates(args: { stream: string; touchCfg: TouchConfig; templateIdsUsed: string[] }): void {
774
+ const nowMs = Date.now();
775
+ this.templates.heartbeat(args.stream, args.templateIdsUsed, nowMs);
776
+ const persistInterval = args.touchCfg.templates?.lastSeenPersistIntervalMs ?? 5 * 60 * 1000;
777
+ this.templates.flushLastSeen(nowMs, persistInterval);
778
+ }
779
+
780
+ beginHotWaitInterest(args: {
781
+ stream: string;
782
+ touchCfg: TouchConfig;
783
+ keyIds: number[];
784
+ templateIdsUsed: string[];
785
+ interestMode: "fine" | "coarse";
786
+ }): () => void {
787
+ if ((args.touchCfg.storage ?? "memory") !== "memory") return () => {};
788
+
789
+ const nowMs = Date.now();
790
+ const limits = this.getHotFineLimits(args.touchCfg);
791
+ const state = this.getOrCreateHotFineState(args.stream);
792
+ const isFine = args.interestMode === "fine";
793
+ if (isFine) state.fineWaitersActive += 1;
794
+ else state.coarseWaitersActive += 1;
795
+
796
+ const trackedKeyIds: number[] = [];
797
+ const trackedTemplateIds: string[] = [];
798
+ const broad = isFine && args.keyIds.length > HOT_INTEREST_MAX_KEYS;
799
+
800
+ if (!isFine) {
801
+ // coarse waits intentionally do not contribute fine-hot key/template sets.
802
+ } else if (broad) {
803
+ state.broadFineWaitersActive += 1;
804
+ } else {
805
+ const uniqueKeys = new Set(args.keyIds.map((raw) => Number(raw) >>> 0));
806
+ for (const keyId of uniqueKeys) {
807
+ if (this.acquireHotKey(state, keyId, limits.maxKeys)) trackedKeyIds.push(keyId);
808
+ }
809
+ }
810
+
811
+ if (isFine) {
812
+ const uniqueTemplates = new Set<string>();
813
+ for (const raw of args.templateIdsUsed) {
814
+ const templateId = String(raw).trim();
815
+ if (!/^[0-9a-f]{16}$/.test(templateId)) continue;
816
+ uniqueTemplates.add(templateId);
817
+ }
818
+ for (const templateId of uniqueTemplates) {
819
+ if (this.acquireHotTemplate(state, templateId, limits.maxTemplates)) trackedTemplateIds.push(templateId);
820
+ }
821
+ }
822
+
823
+ this.sweepHotFineState(args.stream, args.touchCfg, nowMs, true);
824
+
825
+ let released = false;
826
+ return () => {
827
+ if (released) return;
828
+ released = true;
829
+ const st = this.hotFineByStream.get(args.stream);
830
+ if (!st) return;
831
+ const releaseNowMs = Date.now();
832
+ if (isFine) st.fineWaitersActive = Math.max(0, st.fineWaitersActive - 1);
833
+ else st.coarseWaitersActive = Math.max(0, st.coarseWaitersActive - 1);
834
+ if (broad) st.broadFineWaitersActive = Math.max(0, st.broadFineWaitersActive - 1);
835
+ for (const keyId of trackedKeyIds) {
836
+ this.releaseHotKey(st, keyId, releaseNowMs, limits.keyGraceMs, limits.maxKeys);
837
+ }
838
+ for (const templateId of trackedTemplateIds) {
839
+ this.releaseHotTemplate(st, templateId, releaseNowMs, limits.templateGraceMs, limits.maxTemplates);
840
+ }
841
+ this.sweepHotFineState(args.stream, args.touchCfg, releaseNowMs, true);
842
+ };
843
+ }
844
+
845
+ getTouchRuntimeSnapshot(args: { stream: string; touchCfg: TouchConfig }): {
846
+ lagSourceOffsets: number;
847
+ touchMode: "idle" | "fine" | "restricted" | "coarseOnly";
848
+ hotFineKeys: number;
849
+ hotTemplates: number;
850
+ hotFineKeysActive: number;
851
+ hotFineKeysGrace: number;
852
+ hotTemplatesActive: number;
853
+ hotTemplatesGrace: number;
854
+ fineWaitersActive: number;
855
+ coarseWaitersActive: number;
856
+ broadFineWaitersActive: number;
857
+ hotKeyFilteringEnabled: boolean;
858
+ hotTemplateFilteringEnabled: boolean;
859
+ scanRowsTotal: number;
860
+ scanBatchesTotal: number;
861
+ scannedButEmitted0BatchesTotal: number;
862
+ interpretedThroughDeltaTotal: number;
863
+ touchesEmittedTotal: number;
864
+ touchesTableTotal: number;
865
+ touchesTemplateTotal: number;
866
+ fineTouchesDroppedDueToBudgetTotal: number;
867
+ fineTouchesSkippedColdTemplateTotal: number;
868
+ fineTouchesSkippedColdKeyTotal: number;
869
+ fineTouchesSkippedTemplateBucketTotal: number;
870
+ waitTouchedTotal: number;
871
+ waitTimeoutTotal: number;
872
+ waitStaleTotal: number;
873
+ journalFlushesTotal: number;
874
+ journalNotifyWakeupsTotal: number;
875
+ journalNotifyWakeMsTotal: number;
876
+ journalNotifyWakeMsMax: number;
877
+ journalTimeoutsFiredTotal: number;
878
+ journalTimeoutSweepMsTotal: number;
879
+ } {
880
+ const nowMs = Date.now();
881
+ const hot = (args.touchCfg.storage ?? "memory") === "memory" ? this.getHotFineSnapshot(args.stream, args.touchCfg, nowMs) : null;
882
+ const totals = this.getOrCreateRuntimeTotals(args.stream);
883
+ const derived = resolveTouchStreamName(args.stream, args.touchCfg);
884
+ const journal = (args.touchCfg.storage ?? "memory") === "memory" ? this.journals.get(derived) ?? null : null;
885
+ const journalTotals = journal?.getTotalStats();
886
+ return {
887
+ lagSourceOffsets: this.lagSourceOffsetsByStream.get(args.stream) ?? 0,
888
+ touchMode: this.touchModeByStream.get(args.stream) ?? (this.fineLagCoarseOnlyByStream.get(args.stream) ? "coarseOnly" : "fine"),
889
+ hotFineKeys: hot?.hotKeyCount ?? 0,
890
+ hotTemplates: hot?.hotTemplateCount ?? 0,
891
+ hotFineKeysActive: hot?.hotKeyActiveCount ?? 0,
892
+ hotFineKeysGrace: hot?.hotKeyGraceCount ?? 0,
893
+ hotTemplatesActive: hot?.hotTemplateActiveCount ?? 0,
894
+ hotTemplatesGrace: hot?.hotTemplateGraceCount ?? 0,
895
+ fineWaitersActive: hot?.fineWaitersActive ?? 0,
896
+ coarseWaitersActive: hot?.coarseWaitersActive ?? 0,
897
+ broadFineWaitersActive: hot?.broadFineWaitersActive ?? 0,
898
+ hotKeyFilteringEnabled: hot?.keyFilteringEnabled ?? false,
899
+ hotTemplateFilteringEnabled: hot?.templateFilteringEnabled ?? false,
900
+ scanRowsTotal: totals.scanRowsTotal,
901
+ scanBatchesTotal: totals.scanBatchesTotal,
902
+ scannedButEmitted0BatchesTotal: totals.scannedButEmitted0BatchesTotal,
903
+ interpretedThroughDeltaTotal: totals.interpretedThroughDeltaTotal,
904
+ touchesEmittedTotal: totals.touchesEmittedTotal,
905
+ touchesTableTotal: totals.touchesTableTotal,
906
+ touchesTemplateTotal: totals.touchesTemplateTotal,
907
+ fineTouchesDroppedDueToBudgetTotal: totals.fineTouchesDroppedDueToBudgetTotal,
908
+ fineTouchesSkippedColdTemplateTotal: totals.fineTouchesSkippedColdTemplateTotal,
909
+ fineTouchesSkippedColdKeyTotal: totals.fineTouchesSkippedColdKeyTotal,
910
+ fineTouchesSkippedTemplateBucketTotal: totals.fineTouchesSkippedTemplateBucketTotal,
911
+ waitTouchedTotal: totals.waitTouchedTotal,
912
+ waitTimeoutTotal: totals.waitTimeoutTotal,
913
+ waitStaleTotal: totals.waitStaleTotal,
914
+ journalFlushesTotal: journalTotals?.flushes ?? 0,
915
+ journalNotifyWakeupsTotal: journalTotals?.notifyWakeups ?? 0,
916
+ journalNotifyWakeMsTotal: journalTotals?.notifyWakeMsSum ?? 0,
917
+ journalNotifyWakeMsMax: journalTotals?.notifyWakeMsMax ?? 0,
918
+ journalTimeoutsFiredTotal: journalTotals?.timeoutsFired ?? 0,
919
+ journalTimeoutSweepMsTotal: journalTotals?.timeoutSweepMsSum ?? 0,
920
+ };
921
+ }
922
+
923
+ recordWaitMetrics(args: { stream: string; touchCfg: TouchConfig; keysCount: number; outcome: "touched" | "timeout" | "stale"; latencyMs: number }): void {
924
+ this.liveMetrics.recordWait(args.stream, args.touchCfg, args.keysCount, args.outcome, args.latencyMs);
925
+ const totals = this.getOrCreateRuntimeTotals(args.stream);
926
+ if (args.outcome === "touched") totals.waitTouchedTotal += 1;
927
+ else if (args.outcome === "timeout") totals.waitTimeoutTotal += 1;
928
+ else totals.waitStaleTotal += 1;
929
+ }
930
+
931
+ resolveTemplateEntitiesForWait(args: { stream: string; templateIdsUsed: string[] }): string[] {
932
+ const ids = Array.from(
933
+ new Set(args.templateIdsUsed.map((x) => String(x).trim()).filter((x) => /^[0-9a-f]{16}$/.test(x)))
934
+ );
935
+ if (ids.length === 0) return [];
936
+ const entities = new Set<string>();
937
+ const chunkSize = 200;
938
+ for (let i = 0; i < ids.length; i += chunkSize) {
939
+ const chunk = ids.slice(i, i + chunkSize);
940
+ const placeholders = chunk.map(() => "?").join(",");
941
+ const rows = this.db.db
942
+ .query(
943
+ `SELECT DISTINCT entity
944
+ FROM live_templates
945
+ WHERE stream=? AND state='active' AND template_id IN (${placeholders});`
946
+ )
947
+ .all(args.stream, ...chunk) as any[];
948
+ for (const row of rows) {
949
+ const entity = String(row?.entity ?? "").trim();
950
+ if (entity !== "") entities.add(entity);
951
+ }
952
+ }
953
+ return Array.from(entities);
954
+ }
955
+
956
+ getOrCreateJournal(derivedStream: string, touchCfg: TouchConfig): TouchJournal {
957
+ const existing = this.journals.get(derivedStream);
958
+ if (existing) return existing;
959
+ const mem = touchCfg.memory ?? {};
960
+ const j = new TouchJournal({
961
+ bucketMs: mem.bucketMs ?? 100,
962
+ filterPow2: mem.filterPow2 ?? 22,
963
+ k: mem.k ?? 4,
964
+ pendingMaxKeys: mem.pendingMaxKeys ?? 100_000,
965
+ keyIndexMaxKeys: mem.keyIndexMaxKeys ?? 32,
966
+ });
967
+ this.journals.set(derivedStream, j);
968
+ return j;
969
+ }
970
+
971
+ private computeAdaptiveCoalesceMs(touchCfg: TouchConfig, lagAtStart: bigint, hasAnyWaiters: boolean): number {
972
+ const maxCoalesceMs = Math.max(1, Math.floor(touchCfg.memory?.bucketMs ?? 100));
973
+ if (!hasAnyWaiters) return maxCoalesceMs;
974
+
975
+ const lagNum = lagAtStart > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(lagAtStart);
976
+ if (lagNum <= 0) return Math.min(maxCoalesceMs, 10);
977
+ if (lagNum <= 5_000) return Math.min(maxCoalesceMs, 50);
978
+ return maxCoalesceMs;
979
+ }
980
+
981
+ getJournalIfExists(derivedStream: string): TouchJournal | null {
982
+ return this.journals.get(derivedStream) ?? null;
983
+ }
984
+
985
+ private computeSuppressFineDueToLag(stream: string, touchCfg: TouchConfig, lagAtStart: bigint, hasFineDemand: boolean): boolean {
986
+ if (!hasFineDemand) {
987
+ this.fineLagCoarseOnlyByStream.set(stream, false);
988
+ return false;
989
+ }
990
+ const degradeRaw = Math.max(0, Math.floor(touchCfg.lagDegradeFineTouchesAtSourceOffsets ?? 5000));
991
+ if (degradeRaw <= 0) {
992
+ this.fineLagCoarseOnlyByStream.set(stream, false);
993
+ return false;
994
+ }
995
+ const recoverRaw = Math.max(0, Math.floor(touchCfg.lagRecoverFineTouchesAtSourceOffsets ?? 1000));
996
+ const recover = Math.min(degradeRaw, recoverRaw);
997
+ const lag = lagAtStart > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(lagAtStart);
998
+ const prev = this.fineLagCoarseOnlyByStream.get(stream) ?? false;
999
+ let next = prev;
1000
+ if (!prev && lag >= degradeRaw) next = true;
1001
+ else if (prev && lag <= recover) next = false;
1002
+ this.fineLagCoarseOnlyByStream.set(stream, next);
1003
+ return next;
1004
+ }
1005
+
1006
+ private prioritizeStreamsForProcessing(ordered: string[], nowMs: number): string[] {
1007
+ if (ordered.length <= 1) return ordered;
1008
+ const hot: string[] = [];
1009
+ const cold: string[] = [];
1010
+ for (const stream of ordered) {
1011
+ let hasActiveWaiters = false;
1012
+ const regRes = this.registry.getRegistryResult(stream);
1013
+ if (Result.isError(regRes)) {
1014
+ hasActiveWaiters = false;
1015
+ } else {
1016
+ const reg = regRes.value;
1017
+ if (isTouchEnabled(reg.interpreter) && (reg.interpreter.touch.storage ?? "memory") === "memory") {
1018
+ const snap = this.getHotFineSnapshot(stream, reg.interpreter.touch, nowMs);
1019
+ hasActiveWaiters = snap.fineWaitersActive + snap.coarseWaitersActive > 0;
1020
+ }
1021
+ }
1022
+ if (hasActiveWaiters) hot.push(stream);
1023
+ else cold.push(stream);
1024
+ }
1025
+ if (hot.length === 0) return ordered;
1026
+ return hot.concat(cold);
1027
+ }
1028
+
1029
+ private coalesceRestrictedTemplateTouches(stream: string, touchCfg: TouchConfig, touches: TouchRecord[]): { touches: TouchRecord[]; dropped: number } {
1030
+ const bucketMs = Math.max(1, Math.floor(touchCfg.memory?.bucketMs ?? 100));
1031
+ const bucketId = Math.floor(Date.now() / bucketMs);
1032
+ let state = this.restrictedTemplateBucketStateByStream.get(stream);
1033
+ if (!state || state.bucketId !== bucketId) {
1034
+ state = { bucketId, templateKeyIds: new Set<number>() };
1035
+ this.restrictedTemplateBucketStateByStream.set(stream, state);
1036
+ }
1037
+
1038
+ const out: TouchRecord[] = [];
1039
+ let dropped = 0;
1040
+ for (const touch of touches) {
1041
+ if (touch.kind !== "template") {
1042
+ out.push(touch);
1043
+ continue;
1044
+ }
1045
+ const keyId = touch.keyId >>> 0;
1046
+ if (state.templateKeyIds.has(keyId)) {
1047
+ dropped += 1;
1048
+ continue;
1049
+ }
1050
+ state.templateKeyIds.add(keyId);
1051
+ out.push(touch);
1052
+ }
1053
+ return { touches: out, dropped };
1054
+ }
1055
+
1056
+ private getHotFineSnapshot(stream: string, touchCfg: TouchConfig, nowMs: number): HotFineSnapshot {
1057
+ const state = this.sweepHotFineState(stream, touchCfg, nowMs, false);
1058
+ if (!state) {
1059
+ return {
1060
+ hotTemplateIdsForWorker: null,
1061
+ hotKeyActiveSet: null,
1062
+ hotKeyGraceSet: null,
1063
+ hotTemplateActiveCount: 0,
1064
+ hotTemplateGraceCount: 0,
1065
+ hotKeyActiveCount: 0,
1066
+ hotKeyGraceCount: 0,
1067
+ hotTemplateCount: 0,
1068
+ hotKeyCount: 0,
1069
+ fineWaitersActive: 0,
1070
+ coarseWaitersActive: 0,
1071
+ broadFineWaitersActive: 0,
1072
+ templateFilteringEnabled: false,
1073
+ keyFilteringEnabled: true,
1074
+ };
1075
+ }
1076
+
1077
+ const hotTemplateActiveCount = state.templateActiveCountsById.size;
1078
+ const hotTemplateGraceCount = state.templateGraceExpiryMsById.size;
1079
+ const hotKeyActiveCount = state.keyActiveCountsById.size;
1080
+ const hotKeyGraceCount = state.keyGraceExpiryMsById.size;
1081
+ const hotTemplateCount = hotTemplateActiveCount + hotTemplateGraceCount;
1082
+ const hotKeyCount = hotKeyActiveCount + hotKeyGraceCount;
1083
+ const templateFilteringEnabled = !state.templatesOverCapacity && hotTemplateCount > 0;
1084
+ const keyFilteringEnabled = !state.keysOverCapacity && state.broadFineWaitersActive === 0;
1085
+ const hotTemplateIdsForWorker =
1086
+ templateFilteringEnabled ? Array.from(new Set([...state.templateActiveCountsById.keys(), ...state.templateGraceExpiryMsById.keys()])) : null;
1087
+
1088
+ return {
1089
+ hotTemplateIdsForWorker,
1090
+ hotKeyActiveSet: keyFilteringEnabled ? state.keyActiveCountsById : null,
1091
+ hotKeyGraceSet: keyFilteringEnabled ? state.keyGraceExpiryMsById : null,
1092
+ hotTemplateActiveCount,
1093
+ hotTemplateGraceCount,
1094
+ hotKeyActiveCount,
1095
+ hotKeyGraceCount,
1096
+ hotTemplateCount,
1097
+ hotKeyCount,
1098
+ fineWaitersActive: state.fineWaitersActive,
1099
+ coarseWaitersActive: state.coarseWaitersActive,
1100
+ broadFineWaitersActive: state.broadFineWaitersActive,
1101
+ templateFilteringEnabled,
1102
+ keyFilteringEnabled,
1103
+ };
1104
+ }
1105
+
1106
+ private getOrCreateHotFineState(stream: string): HotFineState {
1107
+ const existing = this.hotFineByStream.get(stream);
1108
+ if (existing) return existing;
1109
+ const created: HotFineState = {
1110
+ keyActiveCountsById: new Map<number, number>(),
1111
+ keyGraceExpiryMsById: new Map<number, number>(),
1112
+ templateActiveCountsById: new Map<string, number>(),
1113
+ templateGraceExpiryMsById: new Map<string, number>(),
1114
+ fineWaitersActive: 0,
1115
+ coarseWaitersActive: 0,
1116
+ broadFineWaitersActive: 0,
1117
+ nextSweepAtMs: 0,
1118
+ keysOverCapacity: false,
1119
+ templatesOverCapacity: false,
1120
+ };
1121
+ this.hotFineByStream.set(stream, created);
1122
+ return created;
1123
+ }
1124
+
1125
+ private sweepHotFineState(stream: string, touchCfg: TouchConfig, nowMs: number, force: boolean): HotFineState | null {
1126
+ const state = this.hotFineByStream.get(stream);
1127
+ if (!state) return null;
1128
+ if (!force && nowMs < state.nextSweepAtMs) return state;
1129
+
1130
+ const limits = this.getHotFineLimits(touchCfg);
1131
+
1132
+ for (const [k, exp] of state.keyGraceExpiryMsById.entries()) {
1133
+ if (exp <= nowMs) state.keyGraceExpiryMsById.delete(k);
1134
+ }
1135
+ for (const [tpl, exp] of state.templateGraceExpiryMsById.entries()) {
1136
+ if (exp <= nowMs) state.templateGraceExpiryMsById.delete(tpl);
1137
+ }
1138
+
1139
+ if (state.keyActiveCountsById.size + state.keyGraceExpiryMsById.size < limits.maxKeys) state.keysOverCapacity = false;
1140
+ if (state.templateActiveCountsById.size + state.templateGraceExpiryMsById.size < limits.maxTemplates) state.templatesOverCapacity = false;
1141
+
1142
+ if (
1143
+ state.keyActiveCountsById.size === 0 &&
1144
+ state.keyGraceExpiryMsById.size === 0 &&
1145
+ state.templateActiveCountsById.size === 0 &&
1146
+ state.templateGraceExpiryMsById.size === 0 &&
1147
+ state.fineWaitersActive <= 0 &&
1148
+ state.coarseWaitersActive <= 0 &&
1149
+ state.broadFineWaitersActive <= 0
1150
+ ) {
1151
+ this.hotFineByStream.delete(stream);
1152
+ return null;
1153
+ }
1154
+
1155
+ const sweepEveryMs = Math.max(250, Math.min(limits.keyGraceMs, limits.templateGraceMs, 2000));
1156
+ state.nextSweepAtMs = nowMs + sweepEveryMs;
1157
+ return state;
1158
+ }
1159
+
1160
+ private getHotFineLimits(touchCfg: TouchConfig): { keyGraceMs: number; templateGraceMs: number; maxKeys: number; maxTemplates: number } {
1161
+ const mem = touchCfg.memory ?? {};
1162
+ return {
1163
+ keyGraceMs: Math.max(1, Math.floor(mem.hotKeyTtlMs ?? 10_000)),
1164
+ templateGraceMs: Math.max(1, Math.floor(mem.hotTemplateTtlMs ?? 10_000)),
1165
+ maxKeys: Math.max(1, Math.floor(mem.hotMaxKeys ?? 1_000_000)),
1166
+ maxTemplates: Math.max(1, Math.floor(mem.hotMaxTemplates ?? 4096)),
1167
+ };
1168
+ }
1169
+
1170
+ private acquireHotKey(state: HotFineState, keyId: number, maxKeys: number): boolean {
1171
+ const prev = state.keyActiveCountsById.get(keyId);
1172
+ if (prev != null) {
1173
+ state.keyActiveCountsById.set(keyId, prev + 1);
1174
+ state.keyGraceExpiryMsById.delete(keyId);
1175
+ return true;
1176
+ }
1177
+ if (state.keyActiveCountsById.size + state.keyGraceExpiryMsById.size >= maxKeys) {
1178
+ state.keysOverCapacity = true;
1179
+ return false;
1180
+ }
1181
+ state.keyActiveCountsById.set(keyId, 1);
1182
+ state.keyGraceExpiryMsById.delete(keyId);
1183
+ return true;
1184
+ }
1185
+
1186
+ private acquireHotTemplate(state: HotFineState, templateId: string, maxTemplates: number): boolean {
1187
+ const prev = state.templateActiveCountsById.get(templateId);
1188
+ if (prev != null) {
1189
+ state.templateActiveCountsById.set(templateId, prev + 1);
1190
+ state.templateGraceExpiryMsById.delete(templateId);
1191
+ return true;
1192
+ }
1193
+ if (state.templateActiveCountsById.size + state.templateGraceExpiryMsById.size >= maxTemplates) {
1194
+ state.templatesOverCapacity = true;
1195
+ return false;
1196
+ }
1197
+ state.templateActiveCountsById.set(templateId, 1);
1198
+ state.templateGraceExpiryMsById.delete(templateId);
1199
+ return true;
1200
+ }
1201
+
1202
+ private releaseHotKey(state: HotFineState, keyId: number, nowMs: number, keyGraceMs: number, maxKeys: number): void {
1203
+ const prev = state.keyActiveCountsById.get(keyId);
1204
+ if (prev == null) return;
1205
+ if (prev > 1) {
1206
+ state.keyActiveCountsById.set(keyId, prev - 1);
1207
+ return;
1208
+ }
1209
+ state.keyActiveCountsById.delete(keyId);
1210
+ if (keyGraceMs <= 0) {
1211
+ state.keyGraceExpiryMsById.delete(keyId);
1212
+ return;
1213
+ }
1214
+ if (state.keyActiveCountsById.size + state.keyGraceExpiryMsById.size >= maxKeys) {
1215
+ state.keysOverCapacity = true;
1216
+ return;
1217
+ }
1218
+ state.keyGraceExpiryMsById.set(keyId, nowMs + keyGraceMs);
1219
+ }
1220
+
1221
+ private releaseHotTemplate(state: HotFineState, templateId: string, nowMs: number, templateGraceMs: number, maxTemplates: number): void {
1222
+ const prev = state.templateActiveCountsById.get(templateId);
1223
+ if (prev == null) return;
1224
+ if (prev > 1) {
1225
+ state.templateActiveCountsById.set(templateId, prev - 1);
1226
+ return;
1227
+ }
1228
+ state.templateActiveCountsById.delete(templateId);
1229
+ if (templateGraceMs <= 0) {
1230
+ state.templateGraceExpiryMsById.delete(templateId);
1231
+ return;
1232
+ }
1233
+ if (state.templateActiveCountsById.size + state.templateGraceExpiryMsById.size >= maxTemplates) {
1234
+ state.templatesOverCapacity = true;
1235
+ return;
1236
+ }
1237
+ state.templateGraceExpiryMsById.set(templateId, nowMs + templateGraceMs);
1238
+ }
1239
+
1240
+ private reserveFineTokens(
1241
+ stream: string,
1242
+ touchCfg: TouchConfig,
1243
+ wanted: number,
1244
+ nowMs: number
1245
+ ): { granted: number; tokenLimited: boolean; refund: (used: number) => void } {
1246
+ const rate = Math.max(0, Math.floor(touchCfg.fineTokensPerSecond ?? 200_000));
1247
+ const burst = Math.max(0, Math.floor(touchCfg.fineBurstTokens ?? 400_000));
1248
+ if (wanted <= 0) return { granted: 0, tokenLimited: false, refund: () => {} };
1249
+ if (rate <= 0 || burst <= 0) return { granted: 0, tokenLimited: true, refund: () => {} };
1250
+
1251
+ const b = this.fineTokenBucketsByStream.get(stream) ?? { tokens: burst, lastRefillMs: nowMs };
1252
+ const elapsedMs = Math.max(0, nowMs - b.lastRefillMs);
1253
+ if (elapsedMs > 0) {
1254
+ const refill = (elapsedMs * rate) / 1000;
1255
+ b.tokens = Math.min(burst, b.tokens + refill);
1256
+ b.lastRefillMs = nowMs;
1257
+ }
1258
+
1259
+ const granted = Math.max(0, Math.min(wanted, Math.floor(b.tokens)));
1260
+ b.tokens = Math.max(0, b.tokens - granted);
1261
+ this.fineTokenBucketsByStream.set(stream, b);
1262
+
1263
+ return {
1264
+ granted,
1265
+ tokenLimited: granted < wanted,
1266
+ refund: (used: number) => {
1267
+ const u = Math.max(0, Math.floor(used));
1268
+ if (u >= granted) return;
1269
+ const addBack = granted - u;
1270
+ const cur = this.fineTokenBucketsByStream.get(stream);
1271
+ if (!cur) return;
1272
+ cur.tokens = Math.min(burst, cur.tokens + addBack);
1273
+ this.fineTokenBucketsByStream.set(stream, cur);
1274
+ },
1275
+ };
1276
+ }
1277
+
1278
+ private getOrCreateRuntimeTotals(stream: string): StreamRuntimeTotals {
1279
+ const existing = this.runtimeTotalsByStream.get(stream);
1280
+ if (existing) return existing;
1281
+ const created: StreamRuntimeTotals = {
1282
+ scanRowsTotal: 0,
1283
+ scanBatchesTotal: 0,
1284
+ scannedButEmitted0BatchesTotal: 0,
1285
+ interpretedThroughDeltaTotal: 0,
1286
+ touchesEmittedTotal: 0,
1287
+ touchesTableTotal: 0,
1288
+ touchesTemplateTotal: 0,
1289
+ fineTouchesDroppedDueToBudgetTotal: 0,
1290
+ fineTouchesSkippedColdTemplateTotal: 0,
1291
+ fineTouchesSkippedColdKeyTotal: 0,
1292
+ fineTouchesSkippedTemplateBucketTotal: 0,
1293
+ waitTouchedTotal: 0,
1294
+ waitTimeoutTotal: 0,
1295
+ waitStaleTotal: 0,
1296
+ };
1297
+ this.runtimeTotalsByStream.set(stream, created);
1298
+ return created;
1299
+ }
1300
+
1301
+ private rangeLikelyHasRows(stream: string, fromOffset: bigint, toOffset: bigint): boolean {
1302
+ try {
1303
+ const it = this.db.iterWalRange(stream, fromOffset, toOffset);
1304
+ const first = it.next();
1305
+ return !first.done;
1306
+ } catch {
1307
+ return false;
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ class FailureTracker {
1313
+ private readonly cache: LruCache<string, { attempts: number; untilMs: number }>;
1314
+
1315
+ constructor(maxEntries: number) {
1316
+ this.cache = new LruCache(maxEntries);
1317
+ }
1318
+
1319
+ shouldSkip(stream: string): boolean {
1320
+ const item = this.cache.get(stream);
1321
+ if (!item) return false;
1322
+ if (Date.now() >= item.untilMs) {
1323
+ this.cache.delete(stream);
1324
+ return false;
1325
+ }
1326
+ return true;
1327
+ }
1328
+
1329
+ recordFailure(stream: string): void {
1330
+ const now = Date.now();
1331
+ const item = this.cache.get(stream) ?? { attempts: 0, untilMs: now };
1332
+ item.attempts += 1;
1333
+ const backoff = Math.min(60_000, 500 * 2 ** (item.attempts - 1));
1334
+ item.untilMs = now + backoff;
1335
+ this.cache.set(stream, item);
1336
+ }
1337
+
1338
+ recordSuccess(stream: string): void {
1339
+ this.cache.delete(stream);
1340
+ }
1341
+ }