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