@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,858 @@
1
+ import type { IngestQueue } from "../ingest";
2
+ import type { SqliteDurableStore } from "../db/db";
3
+ import { STREAM_FLAG_TOUCH } from "../db/db";
4
+ import { encodeOffset } from "../offset";
5
+ import type { TouchConfig } from "./spec";
6
+ import type { TemplateLifecycleEvent } from "./live_templates";
7
+ import { resolveTouchStreamName } from "./naming";
8
+ import type { RoutingKeyNotifier } from "./routing_key_notifier";
9
+ import type { TouchJournalIntervalStats, TouchJournalMeta } from "./touch_journal";
10
+ import { Result } from "better-result";
11
+
12
+ export type TouchKind = "table" | "template";
13
+
14
+ export type TouchEventPayload = {
15
+ sourceOffset: string;
16
+ entity: string;
17
+ kind: TouchKind;
18
+ templateId?: string;
19
+ };
20
+
21
+ type WaitOutcome = "touched" | "timeout" | "stale";
22
+ type EnsureLiveMetricsStreamError = {
23
+ kind: "live_metrics_stream_content_type_mismatch";
24
+ message: string;
25
+ };
26
+
27
+ type LatencyHistogram = {
28
+ bounds: number[];
29
+ counts: number[];
30
+ record: (ms: number) => void;
31
+ p50: () => number;
32
+ p95: () => number;
33
+ p99: () => number;
34
+ reset: () => void;
35
+ };
36
+
37
+ function makeLatencyHistogram(): LatencyHistogram {
38
+ const bounds = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 30_000, 120_000];
39
+ const counts = new Array(bounds.length + 1).fill(0);
40
+ const record = (ms: number) => {
41
+ const x = Math.max(0, Math.floor(ms));
42
+ let i = 0;
43
+ while (i < bounds.length && x > bounds[i]) i++;
44
+ counts[i] += 1;
45
+ };
46
+ const quantile = (q: number) => {
47
+ const total = counts.reduce((a, b) => a + b, 0);
48
+ if (total === 0) return 0;
49
+ const target = Math.ceil(total * q);
50
+ let acc = 0;
51
+ for (let i = 0; i < counts.length; i++) {
52
+ acc += counts[i];
53
+ if (acc >= target) {
54
+ return i < bounds.length ? bounds[i] : bounds[bounds.length - 1];
55
+ }
56
+ }
57
+ return bounds[bounds.length - 1];
58
+ };
59
+ const reset = () => {
60
+ for (let i = 0; i < counts.length; i++) counts[i] = 0;
61
+ };
62
+ return { bounds, counts, record, p50: () => quantile(0.5), p95: () => quantile(0.95), p99: () => quantile(0.99), reset };
63
+ }
64
+
65
+ function nowIso(ms: number): string {
66
+ return new Date(ms).toISOString();
67
+ }
68
+
69
+ function envString(name: string): string | null {
70
+ const v = process.env[name];
71
+ return v && v.trim() !== "" ? v.trim() : null;
72
+ }
73
+
74
+ function getInstanceId(): string {
75
+ return envString("DS_INSTANCE_ID") ?? envString("HOSTNAME") ?? "local";
76
+ }
77
+
78
+ function getRegion(): string {
79
+ return envString("DS_REGION") ?? "local";
80
+ }
81
+
82
+ type StreamCounters = {
83
+ touch: {
84
+ coarseIntervalMs: number;
85
+ coalesceWindowMs: number;
86
+ mode: "idle" | "fine" | "restricted" | "coarseOnly";
87
+ hotFineKeys: number;
88
+ hotTemplates: number;
89
+ hotFineKeysActive: number;
90
+ hotFineKeysGrace: number;
91
+ hotTemplatesActive: number;
92
+ hotTemplatesGrace: number;
93
+ fineWaitersActive: number;
94
+ coarseWaitersActive: number;
95
+ broadFineWaitersActive: number;
96
+ touchesEmitted: number;
97
+ uniqueKeysTouched: number;
98
+ tableTouchesEmitted: number;
99
+ templateTouchesEmitted: number;
100
+ staleResponses: number;
101
+ fineTouchesDroppedDueToBudget: number;
102
+ fineTouchesSkippedColdTemplate: number;
103
+ fineTouchesSkippedColdKey: number;
104
+ fineTouchesSkippedTemplateBucket: number;
105
+ fineTouchesSuppressedBatchesDueToLag: number;
106
+ fineTouchesSuppressedMsDueToLag: number;
107
+ fineTouchesSuppressedBatchesDueToBudget: number;
108
+ };
109
+ gc: {
110
+ baseWalGcCalls: number;
111
+ baseWalGcDeletedRows: number;
112
+ baseWalGcDeletedBytes: number;
113
+ baseWalGcMsSum: number;
114
+ baseWalGcMsMax: number;
115
+ };
116
+ templates: {
117
+ activated: number;
118
+ retired: number;
119
+ evicted: number;
120
+ activationDenied: number;
121
+ };
122
+ wait: {
123
+ calls: number;
124
+ keysWatchedTotal: number;
125
+ touched: number;
126
+ timeout: number;
127
+ stale: number;
128
+ latencySumMs: number;
129
+ latencyHist: LatencyHistogram;
130
+ };
131
+ interpreter: {
132
+ eventsIn: number;
133
+ changesOut: number;
134
+ errors: number;
135
+ lagSourceOffsets: number;
136
+ scannedBatches: number;
137
+ scannedButEmitted0Batches: number;
138
+ noInterestFastForwardBatches: number;
139
+ interpretedThroughDelta: number;
140
+ touchesEmittedDelta: number;
141
+ commitLagSamples: number;
142
+ commitLagMsSum: number;
143
+ commitLagHist: LatencyHistogram;
144
+ };
145
+ };
146
+
147
+ function defaultCounters(touchCfg: TouchConfig): StreamCounters {
148
+ return {
149
+ touch: {
150
+ coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
151
+ coalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
152
+ mode: "idle",
153
+ hotFineKeys: 0,
154
+ hotTemplates: 0,
155
+ hotFineKeysActive: 0,
156
+ hotFineKeysGrace: 0,
157
+ hotTemplatesActive: 0,
158
+ hotTemplatesGrace: 0,
159
+ fineWaitersActive: 0,
160
+ coarseWaitersActive: 0,
161
+ broadFineWaitersActive: 0,
162
+ touchesEmitted: 0,
163
+ uniqueKeysTouched: 0,
164
+ tableTouchesEmitted: 0,
165
+ templateTouchesEmitted: 0,
166
+ staleResponses: 0,
167
+ fineTouchesDroppedDueToBudget: 0,
168
+ fineTouchesSkippedColdTemplate: 0,
169
+ fineTouchesSkippedColdKey: 0,
170
+ fineTouchesSkippedTemplateBucket: 0,
171
+ fineTouchesSuppressedBatchesDueToLag: 0,
172
+ fineTouchesSuppressedMsDueToLag: 0,
173
+ fineTouchesSuppressedBatchesDueToBudget: 0,
174
+ },
175
+ gc: {
176
+ baseWalGcCalls: 0,
177
+ baseWalGcDeletedRows: 0,
178
+ baseWalGcDeletedBytes: 0,
179
+ baseWalGcMsSum: 0,
180
+ baseWalGcMsMax: 0,
181
+ },
182
+ templates: { activated: 0, retired: 0, evicted: 0, activationDenied: 0 },
183
+ wait: { calls: 0, keysWatchedTotal: 0, touched: 0, timeout: 0, stale: 0, latencySumMs: 0, latencyHist: makeLatencyHistogram() },
184
+ interpreter: {
185
+ eventsIn: 0,
186
+ changesOut: 0,
187
+ errors: 0,
188
+ lagSourceOffsets: 0,
189
+ scannedBatches: 0,
190
+ scannedButEmitted0Batches: 0,
191
+ noInterestFastForwardBatches: 0,
192
+ interpretedThroughDelta: 0,
193
+ touchesEmittedDelta: 0,
194
+ commitLagSamples: 0,
195
+ commitLagMsSum: 0,
196
+ commitLagHist: makeLatencyHistogram(),
197
+ },
198
+ };
199
+ }
200
+
201
+ export class LiveMetricsV2 {
202
+ private readonly db: SqliteDurableStore;
203
+ private readonly ingest: IngestQueue;
204
+ private readonly metricsStream: string;
205
+ private readonly enabled: boolean;
206
+ private readonly intervalMs: number;
207
+ private readonly snapshotIntervalMs: number;
208
+ private readonly snapshotChunkSize: number;
209
+ private readonly retentionMs: number;
210
+ private readonly routingKeyNotifier?: RoutingKeyNotifier;
211
+ private readonly getTouchJournal?: (derivedStream: string) => { meta: TouchJournalMeta; interval: TouchJournalIntervalStats } | null;
212
+ private timer: any | null = null;
213
+ private snapshotTimer: any | null = null;
214
+ private retentionTimer: any | null = null;
215
+ private lagTimer: any | null = null;
216
+
217
+ private readonly instanceId = getInstanceId();
218
+ private readonly region = getRegion();
219
+
220
+ private readonly counters = new Map<string, StreamCounters>();
221
+
222
+ private lagExpectedMs = 0;
223
+ private lagMaxMs = 0;
224
+ private lagSumMs = 0;
225
+ private lagSamples = 0;
226
+
227
+ constructor(
228
+ db: SqliteDurableStore,
229
+ ingest: IngestQueue,
230
+ opts?: {
231
+ enabled?: boolean;
232
+ stream?: string;
233
+ intervalMs?: number;
234
+ snapshotIntervalMs?: number;
235
+ snapshotChunkSize?: number;
236
+ retentionMs?: number;
237
+ routingKeyNotifier?: RoutingKeyNotifier;
238
+ getTouchJournal?: (derivedStream: string) => { meta: TouchJournalMeta; interval: TouchJournalIntervalStats } | null;
239
+ }
240
+ ) {
241
+ this.db = db;
242
+ this.ingest = ingest;
243
+ this.enabled = opts?.enabled !== false;
244
+ this.metricsStream = opts?.stream ?? "live.metrics";
245
+ this.intervalMs = opts?.intervalMs ?? 1000;
246
+ this.snapshotIntervalMs = opts?.snapshotIntervalMs ?? 60_000;
247
+ this.snapshotChunkSize = opts?.snapshotChunkSize ?? 200;
248
+ this.retentionMs = opts?.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
249
+ this.routingKeyNotifier = opts?.routingKeyNotifier;
250
+ this.getTouchJournal = opts?.getTouchJournal;
251
+ }
252
+
253
+ start(): void {
254
+ if (!this.enabled) return;
255
+ if (this.timer) return;
256
+ this.timer = setInterval(() => {
257
+ void this.flushTick();
258
+ }, this.intervalMs);
259
+ this.snapshotTimer = setInterval(() => {
260
+ void this.emitSnapshots();
261
+ }, this.snapshotIntervalMs);
262
+ // Retention trims are best-effort; 60s granularity is fine.
263
+ this.retentionTimer = setInterval(() => {
264
+ try {
265
+ this.db.trimWalByAge(this.metricsStream, this.retentionMs);
266
+ } catch {
267
+ // ignore
268
+ }
269
+ }, 60_000);
270
+
271
+ // Track event-loop lag at a tighter cadence than the tick interval to
272
+ // debug cases where timeouts/fire events are delayed under load.
273
+ const lagIntervalMs = 100;
274
+ this.lagExpectedMs = Date.now() + lagIntervalMs;
275
+ this.lagMaxMs = 0;
276
+ this.lagSumMs = 0;
277
+ this.lagSamples = 0;
278
+ this.lagTimer = setInterval(() => {
279
+ const now = Date.now();
280
+ const lag = Math.max(0, now - this.lagExpectedMs);
281
+ this.lagMaxMs = Math.max(this.lagMaxMs, lag);
282
+ this.lagSumMs += lag;
283
+ this.lagSamples += 1;
284
+ this.lagExpectedMs += lagIntervalMs;
285
+ // If the loop was paused for a long time, avoid building up a huge debt.
286
+ if (this.lagExpectedMs < now - 5 * lagIntervalMs) this.lagExpectedMs = now + lagIntervalMs;
287
+ }, lagIntervalMs);
288
+ }
289
+
290
+ stop(): void {
291
+ if (this.timer) clearInterval(this.timer);
292
+ if (this.snapshotTimer) clearInterval(this.snapshotTimer);
293
+ if (this.retentionTimer) clearInterval(this.retentionTimer);
294
+ if (this.lagTimer) clearInterval(this.lagTimer);
295
+ this.timer = null;
296
+ this.snapshotTimer = null;
297
+ this.retentionTimer = null;
298
+ this.lagTimer = null;
299
+ }
300
+
301
+ ensureStreamResult(): Result<void, EnsureLiveMetricsStreamError> {
302
+ if (!this.enabled) return Result.ok(undefined);
303
+ const existing = this.db.getStream(this.metricsStream);
304
+ if (existing) {
305
+ if (String(existing.content_type) !== "application/json") {
306
+ return Result.err({
307
+ kind: "live_metrics_stream_content_type_mismatch",
308
+ message: `live metrics stream content-type mismatch: ${existing.content_type}`,
309
+ });
310
+ }
311
+ if ((existing.stream_flags & STREAM_FLAG_TOUCH) === 0) this.db.addStreamFlags(this.metricsStream, STREAM_FLAG_TOUCH);
312
+ return Result.ok(undefined);
313
+ }
314
+ // Treat live.metrics as WAL-only (like touch streams) so age-based retention
315
+ // is enforceable without segment/object-store GC.
316
+ this.db.ensureStream(this.metricsStream, { contentType: "application/json", streamFlags: STREAM_FLAG_TOUCH });
317
+ return Result.ok(undefined);
318
+ }
319
+
320
+ private get(stream: string, touchCfg: TouchConfig): StreamCounters {
321
+ const existing = this.counters.get(stream);
322
+ if (existing) return existing;
323
+ const c = defaultCounters(touchCfg);
324
+ this.counters.set(stream, c);
325
+ return c;
326
+ }
327
+
328
+ private ensure(stream: string): StreamCounters {
329
+ const existing = this.counters.get(stream);
330
+ if (existing) return existing;
331
+ // Use defaults; actual config values will be filled in when we observe the stream.
332
+ const c = defaultCounters({ enabled: true } as TouchConfig);
333
+ this.counters.set(stream, c);
334
+ return c;
335
+ }
336
+
337
+ recordInterpreterError(stream: string, touchCfg: TouchConfig): void {
338
+ const c = this.get(stream, touchCfg);
339
+ c.interpreter.errors += 1;
340
+ }
341
+
342
+ recordInterpreterBatch(args: {
343
+ stream: string;
344
+ touchCfg: TouchConfig;
345
+ rowsRead: number;
346
+ changes: number;
347
+ touches: Array<{ keyId: number; kind: TouchKind }>;
348
+ lagSourceOffsets: number;
349
+ touchMode: "idle" | "fine" | "restricted" | "coarseOnly";
350
+ hotFineKeys?: number;
351
+ hotTemplates?: number;
352
+ hotFineKeysActive?: number;
353
+ hotFineKeysGrace?: number;
354
+ hotTemplatesActive?: number;
355
+ hotTemplatesGrace?: number;
356
+ fineWaitersActive?: number;
357
+ coarseWaitersActive?: number;
358
+ broadFineWaitersActive?: number;
359
+ commitLagMs?: number;
360
+ fineTouchesDroppedDueToBudget?: number;
361
+ fineTouchesSkippedColdTemplate?: number;
362
+ fineTouchesSkippedColdKey?: number;
363
+ fineTouchesSkippedTemplateBucket?: number;
364
+ fineTouchesSuppressedDueToLag?: boolean;
365
+ fineTouchesSuppressedDueToLagMs?: number;
366
+ fineTouchesSuppressedDueToBudget?: boolean;
367
+ scannedButEmitted0?: boolean;
368
+ noInterestFastForward?: boolean;
369
+ interpretedThroughDelta?: number;
370
+ touchesEmittedDelta?: number;
371
+ }): void {
372
+ const c = this.get(args.stream, args.touchCfg);
373
+ c.touch.coarseIntervalMs = args.touchCfg.coarseIntervalMs ?? c.touch.coarseIntervalMs;
374
+ c.touch.coalesceWindowMs = args.touchCfg.touchCoalesceWindowMs ?? c.touch.coalesceWindowMs;
375
+ c.touch.mode = args.touchMode;
376
+ c.touch.hotFineKeys = Math.max(c.touch.hotFineKeys, Math.max(0, Math.floor(args.hotFineKeys ?? 0)));
377
+ c.touch.hotTemplates = Math.max(c.touch.hotTemplates, Math.max(0, Math.floor(args.hotTemplates ?? 0)));
378
+ c.touch.hotFineKeysActive = Math.max(c.touch.hotFineKeysActive, Math.max(0, Math.floor(args.hotFineKeysActive ?? 0)));
379
+ c.touch.hotFineKeysGrace = Math.max(c.touch.hotFineKeysGrace, Math.max(0, Math.floor(args.hotFineKeysGrace ?? 0)));
380
+ c.touch.hotTemplatesActive = Math.max(c.touch.hotTemplatesActive, Math.max(0, Math.floor(args.hotTemplatesActive ?? 0)));
381
+ c.touch.hotTemplatesGrace = Math.max(c.touch.hotTemplatesGrace, Math.max(0, Math.floor(args.hotTemplatesGrace ?? 0)));
382
+ c.touch.fineWaitersActive = Math.max(c.touch.fineWaitersActive, Math.max(0, Math.floor(args.fineWaitersActive ?? 0)));
383
+ c.touch.coarseWaitersActive = Math.max(c.touch.coarseWaitersActive, Math.max(0, Math.floor(args.coarseWaitersActive ?? 0)));
384
+ c.touch.broadFineWaitersActive = Math.max(c.touch.broadFineWaitersActive, Math.max(0, Math.floor(args.broadFineWaitersActive ?? 0)));
385
+ c.interpreter.eventsIn += Math.max(0, args.rowsRead);
386
+ c.interpreter.changesOut += Math.max(0, args.changes);
387
+ c.interpreter.lagSourceOffsets = Math.max(c.interpreter.lagSourceOffsets, Math.max(0, args.lagSourceOffsets));
388
+ c.interpreter.scannedBatches += 1;
389
+ if (args.scannedButEmitted0) c.interpreter.scannedButEmitted0Batches += 1;
390
+ if (args.noInterestFastForward) c.interpreter.noInterestFastForwardBatches += 1;
391
+ c.interpreter.interpretedThroughDelta += Math.max(0, Math.floor(args.interpretedThroughDelta ?? 0));
392
+ c.interpreter.touchesEmittedDelta += Math.max(0, Math.floor(args.touchesEmittedDelta ?? 0));
393
+ if (args.commitLagMs != null && Number.isFinite(args.commitLagMs) && args.commitLagMs >= 0) {
394
+ c.interpreter.commitLagSamples += 1;
395
+ c.interpreter.commitLagMsSum += args.commitLagMs;
396
+ c.interpreter.commitLagHist.record(args.commitLagMs);
397
+ }
398
+ c.touch.fineTouchesDroppedDueToBudget += Math.max(0, args.fineTouchesDroppedDueToBudget ?? 0);
399
+ c.touch.fineTouchesSkippedColdTemplate += Math.max(0, args.fineTouchesSkippedColdTemplate ?? 0);
400
+ c.touch.fineTouchesSkippedColdKey += Math.max(0, args.fineTouchesSkippedColdKey ?? 0);
401
+ c.touch.fineTouchesSkippedTemplateBucket += Math.max(0, args.fineTouchesSkippedTemplateBucket ?? 0);
402
+ if (args.fineTouchesSuppressedDueToLag) c.touch.fineTouchesSuppressedBatchesDueToLag += 1;
403
+ c.touch.fineTouchesSuppressedMsDueToLag += Math.max(0, args.fineTouchesSuppressedDueToLagMs ?? 0);
404
+ if (args.fineTouchesSuppressedDueToBudget) c.touch.fineTouchesSuppressedBatchesDueToBudget += 1;
405
+
406
+ const unique = new Set<number>();
407
+ let table = 0;
408
+ let tpl = 0;
409
+ for (const t of args.touches) {
410
+ unique.add(t.keyId >>> 0);
411
+ if (t.kind === "table") table++;
412
+ else tpl++;
413
+ }
414
+ c.touch.touchesEmitted += args.touches.length;
415
+ c.touch.uniqueKeysTouched += unique.size;
416
+ c.touch.tableTouchesEmitted += table;
417
+ c.touch.templateTouchesEmitted += tpl;
418
+ }
419
+
420
+ recordWait(stream: string, touchCfg: TouchConfig, keysCount: number, outcome: WaitOutcome, latencyMs: number): void {
421
+ const c = this.get(stream, touchCfg);
422
+ c.wait.calls += 1;
423
+ c.wait.keysWatchedTotal += Math.max(0, keysCount);
424
+ c.wait.latencySumMs += Math.max(0, latencyMs);
425
+ c.wait.latencyHist.record(latencyMs);
426
+ if (outcome === "touched") c.wait.touched += 1;
427
+ else if (outcome === "timeout") c.wait.timeout += 1;
428
+ else c.wait.stale += 1;
429
+ if (outcome === "stale") c.touch.staleResponses += 1;
430
+ }
431
+
432
+ recordBaseWalGc(stream: string, args: { deletedRows: number; deletedBytes: number; durationMs: number }): void {
433
+ const c = this.ensure(stream);
434
+ c.gc.baseWalGcCalls += 1;
435
+ c.gc.baseWalGcDeletedRows += Math.max(0, args.deletedRows);
436
+ c.gc.baseWalGcDeletedBytes += Math.max(0, args.deletedBytes);
437
+ c.gc.baseWalGcMsSum += Math.max(0, args.durationMs);
438
+ c.gc.baseWalGcMsMax = Math.max(c.gc.baseWalGcMsMax, Math.max(0, args.durationMs));
439
+ }
440
+
441
+ async emitLifecycle(events: TemplateLifecycleEvent[]): Promise<void> {
442
+ if (!this.enabled) return;
443
+ if (events.length === 0) return;
444
+
445
+ const rows = events.map((e) => ({
446
+ routingKey: new TextEncoder().encode(`${e.stream}|${e.type}`),
447
+ contentType: "application/json",
448
+ payload: new TextEncoder().encode(
449
+ JSON.stringify({
450
+ ...e,
451
+ liveSystemVersion: "v2",
452
+ instanceId: this.instanceId,
453
+ region: this.region,
454
+ })
455
+ ),
456
+ }));
457
+
458
+ for (const e of events) {
459
+ const c = this.ensure(e.stream);
460
+ if (e.type === "live.template_activated") c.templates.activated += 1;
461
+ else if (e.type === "live.template_retired") c.templates.retired += 1;
462
+ else if (e.type === "live.template_evicted") c.templates.evicted += 1;
463
+ }
464
+
465
+ try {
466
+ await this.ingest.appendInternal({
467
+ stream: this.metricsStream,
468
+ baseAppendMs: BigInt(Date.now()),
469
+ rows,
470
+ contentType: "application/json",
471
+ });
472
+ } catch {
473
+ // best-effort
474
+ }
475
+ }
476
+
477
+ recordActivationDenied(stream: string, touchCfg: TouchConfig, n = 1): void {
478
+ const c = this.get(stream, touchCfg);
479
+ c.templates.activationDenied += Math.max(0, n);
480
+ }
481
+
482
+ private async flushTick(): Promise<void> {
483
+ if (!this.enabled) return;
484
+ const nowMs = Date.now();
485
+ const clampBigInt = (v: bigint): number => {
486
+ if (v <= 0n) return 0;
487
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
488
+ return v > max ? Number.MAX_SAFE_INTEGER : Number(v);
489
+ };
490
+
491
+ const states = this.db.listStreamInterpreters();
492
+ if (states.length === 0) return;
493
+
494
+ const rows: Array<{ routingKey: Uint8Array | null; contentType: string; payload: Uint8Array }> = [];
495
+ const encoder = new TextEncoder();
496
+
497
+ const loopLagMax = this.lagMaxMs;
498
+ const loopLagAvg = this.lagSamples > 0 ? this.lagSumMs / this.lagSamples : 0;
499
+ this.lagMaxMs = 0;
500
+ this.lagSumMs = 0;
501
+ this.lagSamples = 0;
502
+
503
+ const rkInterval = this.routingKeyNotifier?.snapshotAndResetIntervalStats() ?? null;
504
+
505
+ for (const st of states) {
506
+ const stream = st.stream;
507
+ const regRow = this.db.getStream(stream);
508
+ if (!regRow) continue;
509
+
510
+ const touchCfg = ((): TouchConfig | null => {
511
+ try {
512
+ const row = this.db.getSchemaRegistry(stream);
513
+ if (!row) return null;
514
+ const raw = JSON.parse(row.registry_json);
515
+ const cfg = raw?.interpreter?.touch;
516
+ if (!cfg || !cfg.enabled) return null;
517
+ return cfg as TouchConfig;
518
+ } catch {
519
+ return null;
520
+ }
521
+ })();
522
+ if (!touchCfg) continue;
523
+
524
+ const c = this.get(stream, touchCfg);
525
+ const storage = (touchCfg.storage ?? "memory") as "memory" | "sqlite";
526
+ const derived = resolveTouchStreamName(stream, touchCfg);
527
+ const journal = storage === "memory" ? this.getTouchJournal?.(derived) ?? null : null;
528
+ const trow = (() => {
529
+ try {
530
+ return this.db.getStream(derived);
531
+ } catch {
532
+ return null;
533
+ }
534
+ })();
535
+ const touchTailSeq = trow ? (trow.next_offset > 0n ? trow.next_offset - 1n : -1n) : -1n;
536
+ let touchWalOldestOffset: string | null = null;
537
+ try {
538
+ const oldest = this.db.getWalOldestOffset(derived);
539
+ touchWalOldestOffset = oldest == null || !trow ? null : encodeOffset(trow.epoch, oldest);
540
+ } catch {
541
+ touchWalOldestOffset = null;
542
+ }
543
+ const waitActive =
544
+ storage === "memory" ? (journal?.meta.activeWaiters ?? 0) : this.routingKeyNotifier ? this.routingKeyNotifier.getActiveWaiters(derived) : 0;
545
+ const tailSeq = regRow.next_offset > 0n ? regRow.next_offset - 1n : -1n;
546
+ const interpretedThrough = st.interpreted_through;
547
+ const gcThrough = interpretedThrough < regRow.uploaded_through ? interpretedThrough : regRow.uploaded_through;
548
+ const backlog = tailSeq >= interpretedThrough ? tailSeq - interpretedThrough : 0n;
549
+ const backlogNum = backlog > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(backlog);
550
+ let walOldestOffset: string | null = null;
551
+ try {
552
+ const oldest = this.db.getWalOldestOffset(stream);
553
+ walOldestOffset = oldest == null ? null : encodeOffset(regRow.epoch, oldest);
554
+ } catch {
555
+ walOldestOffset = null;
556
+ }
557
+ const activeTemplates = (() => {
558
+ try {
559
+ const row = this.db.db.query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`).get(stream) as any;
560
+ return Number(row?.cnt ?? 0);
561
+ } catch {
562
+ return 0;
563
+ }
564
+ })();
565
+
566
+ const tick = {
567
+ type: "live.tick",
568
+ ts: nowIso(nowMs),
569
+ stream,
570
+ liveSystemVersion: "v2",
571
+ instanceId: this.instanceId,
572
+ region: this.region,
573
+ touch: {
574
+ storage,
575
+ coarseIntervalMs: c.touch.coarseIntervalMs,
576
+ coalesceWindowMs: c.touch.coalesceWindowMs,
577
+ mode: c.touch.mode,
578
+ hotFineKeys: c.touch.hotFineKeys,
579
+ hotTemplates: c.touch.hotTemplates,
580
+ hotFineKeysActive: c.touch.hotFineKeysActive,
581
+ hotFineKeysGrace: c.touch.hotFineKeysGrace,
582
+ hotTemplatesActive: c.touch.hotTemplatesActive,
583
+ hotTemplatesGrace: c.touch.hotTemplatesGrace,
584
+ fineWaitersActive: c.touch.fineWaitersActive,
585
+ coarseWaitersActive: c.touch.coarseWaitersActive,
586
+ broadFineWaitersActive: c.touch.broadFineWaitersActive,
587
+ touchesEmitted: c.touch.touchesEmitted,
588
+ uniqueKeysTouched: c.touch.uniqueKeysTouched,
589
+ tableTouchesEmitted: c.touch.tableTouchesEmitted,
590
+ templateTouchesEmitted: c.touch.templateTouchesEmitted,
591
+ staleResponses: c.touch.staleResponses,
592
+ fineTouchesDroppedDueToBudget: c.touch.fineTouchesDroppedDueToBudget,
593
+ fineTouchesSkippedColdTemplate: c.touch.fineTouchesSkippedColdTemplate,
594
+ fineTouchesSkippedColdKey: c.touch.fineTouchesSkippedColdKey,
595
+ fineTouchesSkippedTemplateBucket: c.touch.fineTouchesSkippedTemplateBucket,
596
+ fineTouchesSuppressedBatchesDueToLag: c.touch.fineTouchesSuppressedBatchesDueToLag,
597
+ fineTouchesSuppressedSecondsDueToLag: c.touch.fineTouchesSuppressedMsDueToLag / 1000,
598
+ fineTouchesSuppressedBatchesDueToBudget: c.touch.fineTouchesSuppressedBatchesDueToBudget,
599
+ cursor: storage === "memory" ? (journal?.meta.cursor ?? null) : null,
600
+ epoch: storage === "memory" ? (journal?.meta.epoch ?? null) : null,
601
+ generation: storage === "memory" ? (journal?.meta.generation ?? null) : null,
602
+ pendingKeys: storage === "memory" ? (journal?.meta.pendingKeys ?? 0) : 0,
603
+ overflowBuckets: storage === "memory" ? (journal?.meta.overflowBuckets ?? 0) : 0,
604
+ walTailOffset: trow ? encodeOffset(trow.epoch, touchTailSeq) : null,
605
+ walNextOffset: trow ? encodeOffset(trow.epoch, trow.next_offset) : null,
606
+ walOldestOffset: touchWalOldestOffset,
607
+ walRetainedRows: trow ? clampBigInt(trow.wal_rows) : 0,
608
+ walRetainedBytes: trow ? clampBigInt(trow.wal_bytes) : 0,
609
+ },
610
+ templates: {
611
+ active: activeTemplates,
612
+ activated: c.templates.activated,
613
+ retired: c.templates.retired,
614
+ evicted: c.templates.evicted,
615
+ activationDenied: c.templates.activationDenied,
616
+ },
617
+ wait: {
618
+ calls: c.wait.calls,
619
+ keysWatchedTotal: c.wait.keysWatchedTotal,
620
+ avgKeysPerCall: c.wait.calls > 0 ? c.wait.keysWatchedTotal / c.wait.calls : 0,
621
+ touched: c.wait.touched,
622
+ timeout: c.wait.timeout,
623
+ stale: c.wait.stale,
624
+ avgLatencyMs: c.wait.calls > 0 ? c.wait.latencySumMs / c.wait.calls : 0,
625
+ p95LatencyMs: c.wait.latencyHist.p95(),
626
+ activeWaiters: waitActive,
627
+ timeoutsFired: storage === "memory" ? (journal?.interval.timeoutsFired ?? 0) : rkInterval?.timeoutsFired ?? 0,
628
+ timeoutSweeps: storage === "memory" ? (journal?.interval.timeoutSweeps ?? 0) : rkInterval?.timeoutSweeps ?? 0,
629
+ timeoutSweepMsSum: storage === "memory" ? (journal?.interval.timeoutSweepMsSum ?? 0) : rkInterval?.timeoutSweepMsSum ?? 0,
630
+ timeoutSweepMsMax: storage === "memory" ? (journal?.interval.timeoutSweepMsMax ?? 0) : rkInterval?.timeoutSweepMsMax ?? 0,
631
+ notifyWakeups: storage === "memory" ? (journal?.interval.notifyWakeups ?? 0) : 0,
632
+ notifyFlushes: storage === "memory" ? (journal?.interval.notifyFlushes ?? 0) : 0,
633
+ notifyWakeMsSum: storage === "memory" ? (journal?.interval.notifyWakeMsSum ?? 0) : 0,
634
+ notifyWakeMsMax: storage === "memory" ? (journal?.interval.notifyWakeMsMax ?? 0) : 0,
635
+ timeoutHeapSize: storage === "memory" ? (journal?.interval.heapSize ?? 0) : rkInterval?.heapSize ?? 0,
636
+ },
637
+ interpreter: {
638
+ eventsIn: c.interpreter.eventsIn,
639
+ changesOut: c.interpreter.changesOut,
640
+ errors: c.interpreter.errors,
641
+ lagSourceOffsets: c.interpreter.lagSourceOffsets,
642
+ scannedBatches: c.interpreter.scannedBatches,
643
+ scannedButEmitted0Batches: c.interpreter.scannedButEmitted0Batches,
644
+ noInterestFastForwardBatches: c.interpreter.noInterestFastForwardBatches,
645
+ interpretedThroughDelta: c.interpreter.interpretedThroughDelta,
646
+ touchesEmittedDelta: c.interpreter.touchesEmittedDelta,
647
+ commitLagMsAvg: c.interpreter.commitLagSamples > 0 ? c.interpreter.commitLagMsSum / c.interpreter.commitLagSamples : 0,
648
+ commitLagMsP50: c.interpreter.commitLagHist.p50(),
649
+ commitLagMsP95: c.interpreter.commitLagHist.p95(),
650
+ commitLagMsP99: c.interpreter.commitLagHist.p99(),
651
+ },
652
+ base: {
653
+ tailOffset: encodeOffset(regRow.epoch, tailSeq),
654
+ nextOffset: encodeOffset(regRow.epoch, regRow.next_offset),
655
+ sealedThrough: encodeOffset(regRow.epoch, regRow.sealed_through),
656
+ uploadedThrough: encodeOffset(regRow.epoch, regRow.uploaded_through),
657
+ interpretedThrough: encodeOffset(regRow.epoch, interpretedThrough),
658
+ gcThrough: encodeOffset(regRow.epoch, gcThrough),
659
+ walOldestOffset,
660
+ walRetainedRows: clampBigInt(regRow.wal_rows),
661
+ walRetainedBytes: clampBigInt(regRow.wal_bytes),
662
+ gc: {
663
+ calls: c.gc.baseWalGcCalls,
664
+ deletedRows: c.gc.baseWalGcDeletedRows,
665
+ deletedBytes: c.gc.baseWalGcDeletedBytes,
666
+ msSum: c.gc.baseWalGcMsSum,
667
+ msMax: c.gc.baseWalGcMsMax,
668
+ },
669
+ backlogSourceOffsets: backlogNum,
670
+ },
671
+ process: {
672
+ eventLoopLagMsMax: loopLagMax,
673
+ eventLoopLagMsAvg: loopLagAvg,
674
+ },
675
+ };
676
+
677
+ rows.push({
678
+ routingKey: encoder.encode(`${stream}|live.tick`),
679
+ contentType: "application/json",
680
+ payload: encoder.encode(JSON.stringify(tick)),
681
+ });
682
+
683
+ // Reset interval counters (keep config).
684
+ c.touch.hotFineKeys = 0;
685
+ c.touch.hotTemplates = 0;
686
+ c.touch.hotFineKeysActive = 0;
687
+ c.touch.hotFineKeysGrace = 0;
688
+ c.touch.hotTemplatesActive = 0;
689
+ c.touch.hotTemplatesGrace = 0;
690
+ c.touch.fineWaitersActive = 0;
691
+ c.touch.coarseWaitersActive = 0;
692
+ c.touch.broadFineWaitersActive = 0;
693
+ c.touch.touchesEmitted = 0;
694
+ c.touch.uniqueKeysTouched = 0;
695
+ c.touch.tableTouchesEmitted = 0;
696
+ c.touch.templateTouchesEmitted = 0;
697
+ c.touch.staleResponses = 0;
698
+ c.touch.fineTouchesDroppedDueToBudget = 0;
699
+ c.touch.fineTouchesSkippedColdTemplate = 0;
700
+ c.touch.fineTouchesSkippedColdKey = 0;
701
+ c.touch.fineTouchesSkippedTemplateBucket = 0;
702
+ c.touch.fineTouchesSuppressedBatchesDueToLag = 0;
703
+ c.touch.fineTouchesSuppressedMsDueToLag = 0;
704
+ c.touch.fineTouchesSuppressedBatchesDueToBudget = 0;
705
+ c.touch.mode = "idle";
706
+ c.templates.activated = 0;
707
+ c.templates.retired = 0;
708
+ c.templates.evicted = 0;
709
+ c.templates.activationDenied = 0;
710
+ c.wait.calls = 0;
711
+ c.wait.keysWatchedTotal = 0;
712
+ c.wait.touched = 0;
713
+ c.wait.timeout = 0;
714
+ c.wait.stale = 0;
715
+ c.wait.latencySumMs = 0;
716
+ c.wait.latencyHist.reset();
717
+ c.interpreter.eventsIn = 0;
718
+ c.interpreter.changesOut = 0;
719
+ c.interpreter.errors = 0;
720
+ c.interpreter.lagSourceOffsets = 0;
721
+ c.interpreter.scannedBatches = 0;
722
+ c.interpreter.scannedButEmitted0Batches = 0;
723
+ c.interpreter.noInterestFastForwardBatches = 0;
724
+ c.interpreter.interpretedThroughDelta = 0;
725
+ c.interpreter.touchesEmittedDelta = 0;
726
+ c.interpreter.commitLagSamples = 0;
727
+ c.interpreter.commitLagMsSum = 0;
728
+ c.interpreter.commitLagHist.reset();
729
+ c.gc.baseWalGcCalls = 0;
730
+ c.gc.baseWalGcDeletedRows = 0;
731
+ c.gc.baseWalGcDeletedBytes = 0;
732
+ c.gc.baseWalGcMsSum = 0;
733
+ c.gc.baseWalGcMsMax = 0;
734
+ }
735
+
736
+ if (rows.length === 0) return;
737
+ try {
738
+ await this.ingest.appendInternal({
739
+ stream: this.metricsStream,
740
+ baseAppendMs: BigInt(nowMs),
741
+ rows,
742
+ contentType: "application/json",
743
+ });
744
+ } catch {
745
+ // best-effort
746
+ }
747
+ }
748
+
749
+ private async emitSnapshots(): Promise<void> {
750
+ if (!this.enabled) return;
751
+ const nowMs = Date.now();
752
+ const streams = this.db.listStreamInterpreters().map((r) => r.stream);
753
+ if (streams.length === 0) return;
754
+
755
+ const encoder = new TextEncoder();
756
+ const rows: Array<{ routingKey: Uint8Array | null; contentType: string; payload: Uint8Array }> = [];
757
+
758
+ for (const stream of streams) {
759
+ let templates: any[] = [];
760
+ try {
761
+ templates = this.db.db
762
+ .query(
763
+ `SELECT template_id, entity, fields_json, last_seen_at_ms, state
764
+ FROM live_templates
765
+ WHERE stream=? AND state='active'
766
+ ORDER BY entity ASC, template_id ASC;`
767
+ )
768
+ .all(stream) as any[];
769
+ } catch {
770
+ continue;
771
+ }
772
+
773
+ const snapshotId = `s-${stream}-${nowMs}`;
774
+ const activeTemplates = templates.length;
775
+ rows.push({
776
+ routingKey: encoder.encode(`${stream}|live.templates_snapshot_start`),
777
+ contentType: "application/json",
778
+ payload: encoder.encode(
779
+ JSON.stringify({
780
+ type: "live.templates_snapshot_start",
781
+ ts: nowIso(nowMs),
782
+ stream,
783
+ liveSystemVersion: "v2",
784
+ instanceId: this.instanceId,
785
+ region: this.region,
786
+ snapshotId,
787
+ activeTemplates,
788
+ chunkSize: this.snapshotChunkSize,
789
+ })
790
+ ),
791
+ });
792
+
793
+ let chunkIndex = 0;
794
+ for (let i = 0; i < templates.length; i += this.snapshotChunkSize) {
795
+ const slice = templates.slice(i, i + this.snapshotChunkSize);
796
+ const payloadTemplates = slice.map((t) => {
797
+ const templateId = String(t.template_id);
798
+ const entity = String(t.entity);
799
+ let fields: string[] = [];
800
+ try {
801
+ const f = JSON.parse(String(t.fields_json));
802
+ if (Array.isArray(f)) fields = f.map(String);
803
+ } catch {
804
+ // ignore
805
+ }
806
+ const lastSeenAgoMs = Math.max(0, nowMs - Number(t.last_seen_at_ms));
807
+ return { templateId, entity, fields, lastSeenAgoMs, state: "active" };
808
+ });
809
+ rows.push({
810
+ routingKey: encoder.encode(`${stream}|live.templates_snapshot_chunk`),
811
+ contentType: "application/json",
812
+ payload: encoder.encode(
813
+ JSON.stringify({
814
+ type: "live.templates_snapshot_chunk",
815
+ ts: nowIso(nowMs),
816
+ stream,
817
+ liveSystemVersion: "v2",
818
+ instanceId: this.instanceId,
819
+ region: this.region,
820
+ snapshotId,
821
+ chunkIndex,
822
+ templates: payloadTemplates,
823
+ })
824
+ ),
825
+ });
826
+ chunkIndex++;
827
+ }
828
+
829
+ rows.push({
830
+ routingKey: encoder.encode(`${stream}|live.templates_snapshot_end`),
831
+ contentType: "application/json",
832
+ payload: encoder.encode(
833
+ JSON.stringify({
834
+ type: "live.templates_snapshot_end",
835
+ ts: nowIso(nowMs),
836
+ stream,
837
+ liveSystemVersion: "v2",
838
+ instanceId: this.instanceId,
839
+ region: this.region,
840
+ snapshotId,
841
+ })
842
+ ),
843
+ });
844
+ }
845
+
846
+ if (rows.length === 0) return;
847
+ try {
848
+ await this.ingest.appendInternal({
849
+ stream: this.metricsStream,
850
+ baseAppendMs: BigInt(nowMs),
851
+ rows,
852
+ contentType: "application/json",
853
+ });
854
+ } catch {
855
+ // best-effort
856
+ }
857
+ }
858
+ }