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