@prisma/streams-server 0.0.1 → 0.1.1

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