@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,771 @@
1
+ type TouchHit = {
2
+ generation: number;
3
+ keyId: number;
4
+ bucketMaxSourceOffsetSeq: bigint;
5
+ flushAtMs: number;
6
+ bucketStartMs: number;
7
+ } | null;
8
+
9
+ type Waiter = {
10
+ afterGeneration: number;
11
+ keys: number[];
12
+ exactKeys: string[] | null;
13
+ // For huge keysets, we avoid per-key indexing and instead scan on flush.
14
+ broad: boolean;
15
+ deadlineMs: number;
16
+ heapIndex: number;
17
+ done: boolean;
18
+ cleanup: (hit: TouchHit) => void;
19
+ };
20
+
21
+ type ExactGeneration = {
22
+ generation: number;
23
+ keys: Set<string>;
24
+ overflow: boolean;
25
+ };
26
+
27
+ type IntervalStats = {
28
+ timeoutsFired: number;
29
+ timeoutSweeps: number;
30
+ timeoutSweepMsSum: number;
31
+ timeoutSweepMsMax: number;
32
+ notifyWakeups: number;
33
+ notifyFlushes: number;
34
+ notifyWakeMsSum: number;
35
+ notifyWakeMsMax: number;
36
+ heapSize: number;
37
+ };
38
+
39
+ export type TouchJournalIntervalStats = IntervalStats;
40
+
41
+ type TotalStats = {
42
+ timeoutsFired: number;
43
+ timeoutSweeps: number;
44
+ timeoutSweepMsSum: number;
45
+ timeoutSweepMsMax: number;
46
+ notifyWakeups: number;
47
+ notifyFlushes: number;
48
+ notifyWakeMsSum: number;
49
+ notifyWakeMsMax: number;
50
+ flushes: number;
51
+ };
52
+
53
+ function u32(x: number): number {
54
+ return x >>> 0;
55
+ }
56
+
57
+ function mix32(x: number): number {
58
+ // Murmur3 finalizer-ish avalanche mix.
59
+ let y = u32(x);
60
+ y ^= y >>> 16;
61
+ y = Math.imul(y, 0x85ebca6b) >>> 0;
62
+ y ^= y >>> 13;
63
+ y = Math.imul(y, 0xc2b2ae35) >>> 0;
64
+ y ^= y >>> 16;
65
+ return y >>> 0;
66
+ }
67
+
68
+ function newEpochHex16(): string {
69
+ const buf = new Uint32Array(2);
70
+ crypto.getRandomValues(buf);
71
+ return buf[0]!.toString(16).padStart(8, "0") + buf[1]!.toString(16).padStart(8, "0");
72
+ }
73
+
74
+ export type TouchJournalMeta = {
75
+ mode: "memory";
76
+ cursor: string;
77
+ epoch: string;
78
+ generation: number;
79
+ bucketMs: number;
80
+ coalesceMs: number;
81
+ filterSize: number;
82
+ k: number;
83
+ pendingKeys: number;
84
+ overflowBuckets: number;
85
+ activeWaiters: number;
86
+ bucketMaxSourceOffsetSeq: string;
87
+ lastFlushAtMs: number;
88
+ flushIntervalMsMaxLast10s: number;
89
+ flushIntervalMsP95Last10s: number;
90
+ };
91
+
92
+ export type TouchWaitResult =
93
+ | { stale: true; cursor: string; epoch: string; generation: number }
94
+ | { stale: false; touched: boolean; cursor: string };
95
+
96
+ export function parseTouchCursor(raw: string): { epoch: string; generation: number } | null {
97
+ const s = raw.trim();
98
+ if (s === "") return null;
99
+ const idx = s.indexOf(":");
100
+ if (idx <= 0) return null;
101
+ const epoch = s.slice(0, idx);
102
+ const genRaw = s.slice(idx + 1);
103
+ if (!/^[0-9a-f]{16}$/i.test(epoch)) return null;
104
+ if (!/^[0-9]+$/.test(genRaw)) return null;
105
+ const gen = Number(genRaw);
106
+ if (!Number.isFinite(gen) || gen < 0) return null;
107
+ return { epoch: epoch.toLowerCase(), generation: Math.floor(gen) };
108
+ }
109
+
110
+ export function formatTouchCursor(epoch: string, generation: number): string {
111
+ return `${epoch}:${Math.max(0, Math.floor(generation))}`;
112
+ }
113
+
114
+ /**
115
+ * Memory-only touch journal:
116
+ * - fixed-size time-aware bloom filter: lastSet[pos] = generation
117
+ * - bucketed flush (default 100ms) to avoid mid-bucket false negatives
118
+ * - waiter index + single global deadline heap for reliable timeouts under load
119
+ *
120
+ * Safety model:
121
+ * - No false negatives within an epoch (process lifetime), except if generation wraps (uint32).
122
+ * - False positives are allowed (extra invalidations).
123
+ */
124
+ export class TouchJournal {
125
+ private static readonly EXACT_RECENT_WINDOW_MS = 15_000;
126
+
127
+ private readonly epoch: string;
128
+ private generation: number;
129
+ private readonly bucketMs: number;
130
+ private coalesceMs: number;
131
+
132
+ private readonly k: number;
133
+ private readonly mask: number;
134
+ private readonly lastSet: Uint32Array;
135
+
136
+ private readonly pending = new Set<number>();
137
+ private readonly pendingExact = new Set<string>();
138
+ private pendingBucketStartMs = 0;
139
+ private pendingMaxSourceOffsetSeq: bigint = -1n;
140
+ private lastFlushedSourceOffsetSeq: bigint = -1n;
141
+ private overflow = false;
142
+ private overflowBuckets = 0;
143
+ private lastOverflowGeneration = 0;
144
+ private lastFlushAtMs = 0;
145
+ private lastBucketStartMs = 0;
146
+ private readonly flushIntervalsLast10s: Array<{ atMs: number; intervalMs: number }> = [];
147
+
148
+ private flushTimer: any | null = null;
149
+
150
+ private readonly byKey = new Map<number, Set<Waiter>>();
151
+ private readonly byExactKey = new Map<string, Set<Waiter>>();
152
+ private readonly broad = new Set<Waiter>();
153
+ private readonly exactRecent: ExactGeneration[] = [];
154
+ private readonly exactRecentMaxGenerations: number;
155
+ private activeWaiters = 0;
156
+
157
+ // Single global deadline heap + timer for waiter expiry.
158
+ private readonly deadlineHeap: Waiter[] = [];
159
+ private timeoutTimer: any | null = null;
160
+ private scheduledDeadlineMs: number | null = null;
161
+
162
+ private interval: IntervalStats = {
163
+ timeoutsFired: 0,
164
+ timeoutSweeps: 0,
165
+ timeoutSweepMsSum: 0,
166
+ timeoutSweepMsMax: 0,
167
+ notifyWakeups: 0,
168
+ notifyFlushes: 0,
169
+ notifyWakeMsSum: 0,
170
+ notifyWakeMsMax: 0,
171
+ heapSize: 0,
172
+ };
173
+ private totals: TotalStats = {
174
+ timeoutsFired: 0,
175
+ timeoutSweeps: 0,
176
+ timeoutSweepMsSum: 0,
177
+ timeoutSweepMsMax: 0,
178
+ notifyWakeups: 0,
179
+ notifyFlushes: 0,
180
+ notifyWakeMsSum: 0,
181
+ notifyWakeMsMax: 0,
182
+ flushes: 0,
183
+ };
184
+
185
+ // Hard bound for unique keys per bucket. If exceeded we treat the whole bucket as "overflow"
186
+ // and wake all waiters (lossy but safe).
187
+ private readonly pendingMaxKeys: number;
188
+ private readonly keyIndexMaxKeys: number;
189
+
190
+ constructor(opts: {
191
+ bucketMs: number;
192
+ filterPow2: number;
193
+ k: number;
194
+ pendingMaxKeys: number;
195
+ keyIndexMaxKeys: number;
196
+ }) {
197
+ this.epoch = newEpochHex16();
198
+ this.generation = 0;
199
+ this.bucketMs = Math.max(1, Math.floor(opts.bucketMs));
200
+ this.coalesceMs = this.bucketMs;
201
+ const pow2 = Math.max(10, Math.min(30, Math.floor(opts.filterPow2)));
202
+ const size = 1 << pow2;
203
+ this.k = Math.max(1, Math.min(8, Math.floor(opts.k)));
204
+ this.mask = size - 1;
205
+ this.lastSet = new Uint32Array(size);
206
+ this.pendingMaxKeys = Math.max(1, Math.floor(opts.pendingMaxKeys));
207
+ this.keyIndexMaxKeys = Math.max(1, Math.floor(opts.keyIndexMaxKeys));
208
+ this.exactRecentMaxGenerations = Math.max(16, Math.ceil(TouchJournal.EXACT_RECENT_WINDOW_MS / this.bucketMs));
209
+ }
210
+
211
+ stop(): void {
212
+ if (this.flushTimer) clearTimeout(this.flushTimer);
213
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
214
+ this.flushTimer = null;
215
+ this.timeoutTimer = null;
216
+ this.scheduledDeadlineMs = null;
217
+ this.pending.clear();
218
+ this.pendingExact.clear();
219
+ this.pendingBucketStartMs = 0;
220
+ this.pendingMaxSourceOffsetSeq = -1n;
221
+ this.lastFlushedSourceOffsetSeq = -1n;
222
+ this.lastFlushAtMs = 0;
223
+ this.lastBucketStartMs = 0;
224
+ this.flushIntervalsLast10s.length = 0;
225
+ this.byKey.clear();
226
+ this.byExactKey.clear();
227
+ this.broad.clear();
228
+ this.exactRecent.length = 0;
229
+ this.deadlineHeap.length = 0;
230
+ this.activeWaiters = 0;
231
+ }
232
+
233
+ getEpoch(): string {
234
+ return this.epoch;
235
+ }
236
+
237
+ getGeneration(): number {
238
+ return this.generation >>> 0;
239
+ }
240
+
241
+ getCursor(): string {
242
+ return formatTouchCursor(this.epoch, this.getGeneration());
243
+ }
244
+
245
+ getLastFlushedSourceOffsetSeq(): bigint {
246
+ return this.lastFlushedSourceOffsetSeq;
247
+ }
248
+
249
+ getActiveWaiters(): number {
250
+ return this.activeWaiters;
251
+ }
252
+
253
+ snapshotAndResetIntervalStats(): IntervalStats {
254
+ const out = { ...this.interval, heapSize: this.deadlineHeap.length };
255
+ this.interval = {
256
+ timeoutsFired: 0,
257
+ timeoutSweeps: 0,
258
+ timeoutSweepMsSum: 0,
259
+ timeoutSweepMsMax: 0,
260
+ notifyWakeups: 0,
261
+ notifyFlushes: 0,
262
+ notifyWakeMsSum: 0,
263
+ notifyWakeMsMax: 0,
264
+ heapSize: 0,
265
+ };
266
+ return out;
267
+ }
268
+
269
+ getTotalStats(): TotalStats {
270
+ return { ...this.totals };
271
+ }
272
+
273
+ getMeta(): TouchJournalMeta {
274
+ const nowMs = Date.now();
275
+ this.pruneFlushIntervals(nowMs);
276
+ const intervals = this.flushIntervalsLast10s.map((x) => x.intervalMs);
277
+ return {
278
+ mode: "memory",
279
+ cursor: this.getCursor(),
280
+ epoch: this.epoch,
281
+ generation: this.getGeneration(),
282
+ bucketMs: this.bucketMs,
283
+ coalesceMs: this.coalesceMs,
284
+ filterSize: this.lastSet.length,
285
+ k: this.k,
286
+ pendingKeys: this.pending.size,
287
+ overflowBuckets: this.overflowBuckets,
288
+ activeWaiters: this.activeWaiters,
289
+ bucketMaxSourceOffsetSeq: this.lastFlushedSourceOffsetSeq.toString(),
290
+ lastFlushAtMs: this.lastFlushAtMs,
291
+ flushIntervalMsMaxLast10s: intervals.length > 0 ? Math.max(...intervals) : 0,
292
+ flushIntervalMsP95Last10s: percentile(intervals, 0.95),
293
+ };
294
+ }
295
+
296
+ getFilterBytes(): number {
297
+ return this.lastSet.byteLength;
298
+ }
299
+
300
+ touch(keyId: number, sourceOffsetSeq?: bigint, routingKey?: string): void {
301
+ if (this.pending.size === 0 && !this.overflow && this.pendingBucketStartMs <= 0) {
302
+ this.pendingBucketStartMs = Date.now();
303
+ }
304
+ if (this.pending.size >= this.pendingMaxKeys) {
305
+ // We may drop fine touches once we overflow, but we must treat the bucket
306
+ // as a broadcast invalidation to avoid false negatives.
307
+ this.overflow = true;
308
+ } else {
309
+ this.pending.add(u32(keyId));
310
+ }
311
+ if (typeof routingKey === "string" && /^[0-9a-f]{16}$/i.test(routingKey.trim())) {
312
+ this.pendingExact.add(routingKey.trim().toLowerCase());
313
+ }
314
+ if (typeof sourceOffsetSeq === "bigint" && sourceOffsetSeq > this.pendingMaxSourceOffsetSeq) {
315
+ this.pendingMaxSourceOffsetSeq = sourceOffsetSeq;
316
+ }
317
+ this.ensureFlushScheduled();
318
+ }
319
+
320
+ setCoalesceMs(ms: number): void {
321
+ const next = Math.max(1, Math.min(this.bucketMs, Math.floor(ms)));
322
+ this.coalesceMs = next;
323
+ }
324
+
325
+ /**
326
+ * Best-effort membership query: "maybe touched since sinceGeneration".
327
+ * False positives are allowed; false negatives within epoch are not (except overflow / generation wrap).
328
+ */
329
+ maybeTouchedSince(keyId: number, sinceGeneration: number): boolean {
330
+ const since = u32(sinceGeneration);
331
+ if (since < u32(this.lastOverflowGeneration)) return true;
332
+ const h1 = u32(keyId);
333
+ let h2 = mix32(h1);
334
+ if (h2 === 0) h2 = 0x9e3779b9; // avoid zero stride
335
+ let min = 0xffffffff;
336
+ for (let i = 0; i < this.k; i++) {
337
+ const pos = u32(h1 + Math.imul(i, h2)) & this.mask;
338
+ const g = this.lastSet[pos]!;
339
+ if (g < min) min = g;
340
+ }
341
+ return u32(min) > since;
342
+ }
343
+
344
+ maybeTouchedSinceAny(keyIds: number[], sinceGeneration: number): boolean {
345
+ const since = u32(sinceGeneration);
346
+ if (since < u32(this.lastOverflowGeneration)) return true;
347
+ for (let i = 0; i < keyIds.length; i++) {
348
+ if (this.maybeTouchedSince(keyIds[i]!, since)) return true;
349
+ }
350
+ return false;
351
+ }
352
+
353
+ exactTouchedSinceAny(routingKeys: string[], sinceGeneration: number): boolean | null {
354
+ const normalized = Array.from(
355
+ new Set(routingKeys.map((key) => key.trim().toLowerCase()).filter((key) => /^[0-9a-f]{16}$/.test(key)))
356
+ );
357
+ if (normalized.length === 0) return false;
358
+ if (sinceGeneration >= this.getGeneration()) return false;
359
+ if (sinceGeneration < this.lastOverflowGeneration) return null;
360
+ if (this.exactRecent.length === 0) return null;
361
+
362
+ const firstNeededGeneration = Math.max(0, Math.floor(sinceGeneration) + 1);
363
+ const oldestRetainedGeneration = this.exactRecent[0]!.generation;
364
+ if (firstNeededGeneration < oldestRetainedGeneration) return null;
365
+
366
+ let expectedGeneration = firstNeededGeneration;
367
+ for (const entry of this.exactRecent) {
368
+ if (entry.generation < firstNeededGeneration) continue;
369
+ if (entry.generation !== expectedGeneration) return null;
370
+ if (entry.overflow) return null;
371
+ for (const routingKey of normalized) {
372
+ if (entry.keys.has(routingKey)) return true;
373
+ }
374
+ expectedGeneration += 1;
375
+ }
376
+ return expectedGeneration > this.getGeneration() ? false : null;
377
+ }
378
+
379
+ /**
380
+ * Wait for any of `keys` to be touched in a bucket generation strictly greater than `afterGeneration`.
381
+ *
382
+ * Returns:
383
+ * - `{generation, keyId}` when touched
384
+ * - `null` on timeout or abort
385
+ */
386
+ waitForAny(args: {
387
+ keys: number[];
388
+ exactKeys?: string[] | null;
389
+ afterGeneration: number;
390
+ timeoutMs: number;
391
+ signal?: AbortSignal;
392
+ }): Promise<TouchHit> {
393
+ const exactKeys = Array.isArray(args.exactKeys)
394
+ ? Array.from(new Set(args.exactKeys.map((key) => key.trim().toLowerCase()).filter((key) => /^[0-9a-f]{16}$/.test(key))))
395
+ : [];
396
+ if (args.keys.length === 0 && exactKeys.length === 0) return Promise.resolve(null);
397
+ if (args.signal?.aborted) return Promise.resolve(null);
398
+ const timeoutMs = Math.max(0, Math.floor(args.timeoutMs));
399
+ if (timeoutMs <= 0) return Promise.resolve(null);
400
+
401
+ const keys = Array.from(new Set(args.keys.map(u32)));
402
+ const broad = keys.length > this.keyIndexMaxKeys;
403
+
404
+ return new Promise((resolve) => {
405
+ const waiter: Waiter = {
406
+ afterGeneration: u32(args.afterGeneration),
407
+ keys,
408
+ exactKeys: exactKeys.length > 0 ? exactKeys : null,
409
+ broad,
410
+ deadlineMs: Date.now() + timeoutMs,
411
+ heapIndex: -1,
412
+ done: false,
413
+ cleanup: (hit) => {
414
+ if (waiter.done) return;
415
+ waiter.done = true;
416
+
417
+ if (waiter.exactKeys) {
418
+ for (const routingKey of waiter.exactKeys) {
419
+ const s = this.byExactKey.get(routingKey);
420
+ if (!s) continue;
421
+ s.delete(waiter);
422
+ if (s.size === 0) this.byExactKey.delete(routingKey);
423
+ }
424
+ }
425
+
426
+ if (waiter.broad) {
427
+ this.broad.delete(waiter);
428
+ } else {
429
+ for (const k of waiter.keys) {
430
+ const s = this.byKey.get(k);
431
+ if (!s) continue;
432
+ s.delete(waiter);
433
+ if (s.size === 0) this.byKey.delete(k);
434
+ }
435
+ }
436
+
437
+ this.activeWaiters = Math.max(0, this.activeWaiters - 1);
438
+
439
+ const removedRoot = this.heapRemove(waiter);
440
+ if (args.signal) args.signal.removeEventListener("abort", onAbort);
441
+ if (removedRoot) this.rescheduleTimeoutTimer();
442
+ resolve(hit);
443
+ },
444
+ };
445
+
446
+ if (waiter.exactKeys) {
447
+ for (const routingKey of waiter.exactKeys) {
448
+ const set = this.byExactKey.get(routingKey) ?? new Set<Waiter>();
449
+ set.add(waiter);
450
+ this.byExactKey.set(routingKey, set);
451
+ }
452
+ }
453
+
454
+ if (waiter.broad) {
455
+ this.broad.add(waiter);
456
+ } else {
457
+ for (const k of waiter.keys) {
458
+ const set = this.byKey.get(k) ?? new Set<Waiter>();
459
+ set.add(waiter);
460
+ this.byKey.set(k, set);
461
+ }
462
+ }
463
+ this.activeWaiters += 1;
464
+
465
+ const onAbort = () => waiter.cleanup(null);
466
+ if (args.signal) args.signal.addEventListener("abort", onAbort, { once: true });
467
+
468
+ this.heapPush(waiter);
469
+ this.rescheduleTimeoutTimer();
470
+ });
471
+ }
472
+
473
+ private ensureFlushScheduled(): void {
474
+ if (this.flushTimer) return;
475
+ this.flushTimer = setTimeout(() => this.flushBucket(), this.coalesceMs);
476
+ }
477
+
478
+ private flushBucket(): void {
479
+ this.flushTimer = null;
480
+
481
+ const hasTouches = this.pending.size > 0 || this.overflow;
482
+ if (!hasTouches) return;
483
+
484
+ // Advance generation only at bucket boundaries so cursors are safe.
485
+ this.generation = u32(this.generation + 1);
486
+ const gen = this.getGeneration();
487
+ const bucketMaxSourceOffsetSeq = this.pendingMaxSourceOffsetSeq;
488
+ if (bucketMaxSourceOffsetSeq > this.lastFlushedSourceOffsetSeq) this.lastFlushedSourceOffsetSeq = bucketMaxSourceOffsetSeq;
489
+ const flushAtMs = Date.now();
490
+ const bucketStartMs = this.pendingBucketStartMs > 0 ? this.pendingBucketStartMs : flushAtMs;
491
+ if (this.lastFlushAtMs > 0 && flushAtMs >= this.lastFlushAtMs) {
492
+ this.flushIntervalsLast10s.push({ atMs: flushAtMs, intervalMs: flushAtMs - this.lastFlushAtMs });
493
+ this.pruneFlushIntervals(flushAtMs);
494
+ }
495
+ this.lastFlushAtMs = flushAtMs;
496
+ this.lastBucketStartMs = bucketStartMs;
497
+ this.totals.flushes += 1;
498
+
499
+ if (this.overflow) {
500
+ this.overflowBuckets += 1;
501
+ this.lastOverflowGeneration = gen;
502
+ }
503
+ this.exactRecent.push({
504
+ generation: gen,
505
+ keys: new Set(this.pendingExact),
506
+ overflow: this.overflow,
507
+ });
508
+ while (this.exactRecent.length > this.exactRecentMaxGenerations) {
509
+ this.exactRecent.shift();
510
+ }
511
+
512
+ // Update bloom filter for touched keys. We still update for the keys we captured even on overflow;
513
+ // the overflow marker is what preserves correctness for dropped keys.
514
+ for (const keyId of this.pending) {
515
+ const h1 = u32(keyId);
516
+ let h2 = mix32(h1);
517
+ if (h2 === 0) h2 = 0x9e3779b9;
518
+ for (let i = 0; i < this.k; i++) {
519
+ const pos = u32(h1 + Math.imul(i, h2)) & this.mask;
520
+ this.lastSet[pos] = gen;
521
+ }
522
+ }
523
+
524
+ if (this.overflow) {
525
+ // Broadcast wakeup: resolve all waiters (safe, lossy).
526
+ const wakeStartMs = Date.now();
527
+ let wakeups = 0;
528
+ const all = new Set<Waiter>();
529
+ for (const s of this.byKey.values()) for (const w of s) all.add(w);
530
+ for (const s of this.byExactKey.values()) for (const w of s) all.add(w);
531
+ for (const w of this.broad) all.add(w);
532
+ for (const w of all) {
533
+ if (w.done) continue;
534
+ if (gen > w.afterGeneration) {
535
+ wakeups += 1;
536
+ w.cleanup({ generation: gen, keyId: 0, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
537
+ }
538
+ }
539
+ if (wakeups > 0) {
540
+ const wakeMs = Date.now() - wakeStartMs;
541
+ this.interval.notifyWakeups += wakeups;
542
+ this.interval.notifyFlushes += 1;
543
+ this.interval.notifyWakeMsSum += wakeMs;
544
+ this.interval.notifyWakeMsMax = Math.max(this.interval.notifyWakeMsMax, wakeMs);
545
+ this.totals.notifyWakeups += wakeups;
546
+ this.totals.notifyFlushes += 1;
547
+ this.totals.notifyWakeMsSum += wakeMs;
548
+ this.totals.notifyWakeMsMax = Math.max(this.totals.notifyWakeMsMax, wakeMs);
549
+ }
550
+ } else {
551
+ // Wake keyed waiters by touched key id.
552
+ const wakeStartMs = Date.now();
553
+ let wakeups = 0;
554
+ const exactWoken = new Set<Waiter>();
555
+ for (const routingKey of this.pendingExact) {
556
+ const set = this.byExactKey.get(routingKey);
557
+ if (!set || set.size === 0) continue;
558
+ for (const w of set) {
559
+ if (w.done || exactWoken.has(w)) continue;
560
+ if (gen > w.afterGeneration) {
561
+ exactWoken.add(w);
562
+ wakeups += 1;
563
+ w.cleanup({ generation: gen, keyId: 0, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
564
+ }
565
+ }
566
+ }
567
+ for (const keyId of this.pending) {
568
+ const set = this.byKey.get(keyId);
569
+ if (!set || set.size === 0) continue;
570
+ for (const w of set) {
571
+ if (w.done) continue;
572
+ if (gen > w.afterGeneration) {
573
+ wakeups += 1;
574
+ w.cleanup({ generation: gen, keyId, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
575
+ }
576
+ }
577
+ }
578
+
579
+ // Wake broad waiters by scanning bloom membership for their keysets.
580
+ if (this.broad.size > 0) {
581
+ for (const w of this.broad) {
582
+ if (w.done) continue;
583
+ if (gen <= w.afterGeneration) continue;
584
+ let hit = false;
585
+ for (let i = 0; i < w.keys.length; i++) {
586
+ if (this.maybeTouchedSince(w.keys[i]!, w.afterGeneration)) {
587
+ hit = true;
588
+ break;
589
+ }
590
+ }
591
+ if (hit) {
592
+ wakeups += 1;
593
+ w.cleanup({ generation: gen, keyId: 0, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
594
+ }
595
+ }
596
+ }
597
+ if (wakeups > 0) {
598
+ const wakeMs = Date.now() - wakeStartMs;
599
+ this.interval.notifyWakeups += wakeups;
600
+ this.interval.notifyFlushes += 1;
601
+ this.interval.notifyWakeMsSum += wakeMs;
602
+ this.interval.notifyWakeMsMax = Math.max(this.interval.notifyWakeMsMax, wakeMs);
603
+ this.totals.notifyWakeups += wakeups;
604
+ this.totals.notifyFlushes += 1;
605
+ this.totals.notifyWakeMsSum += wakeMs;
606
+ this.totals.notifyWakeMsMax = Math.max(this.totals.notifyWakeMsMax, wakeMs);
607
+ }
608
+ }
609
+
610
+ this.pending.clear();
611
+ this.pendingExact.clear();
612
+ this.pendingBucketStartMs = 0;
613
+ this.pendingMaxSourceOffsetSeq = -1n;
614
+ this.overflow = false;
615
+ }
616
+
617
+ getLastFlushAtMs(): number {
618
+ return this.lastFlushAtMs;
619
+ }
620
+
621
+ getLastBucketStartMs(): number {
622
+ return this.lastBucketStartMs;
623
+ }
624
+
625
+ private pruneFlushIntervals(nowMs: number): void {
626
+ const cutoff = nowMs - 10_000;
627
+ while (this.flushIntervalsLast10s.length > 0 && this.flushIntervalsLast10s[0]!.atMs < cutoff) {
628
+ this.flushIntervalsLast10s.shift();
629
+ }
630
+ }
631
+
632
+ private rescheduleTimeoutTimer(): void {
633
+ const next = this.deadlineHeap[0];
634
+ if (!next) {
635
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
636
+ this.timeoutTimer = null;
637
+ this.scheduledDeadlineMs = null;
638
+ return;
639
+ }
640
+
641
+ if (this.timeoutTimer && this.scheduledDeadlineMs != null && this.scheduledDeadlineMs === next.deadlineMs) return;
642
+
643
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
644
+ this.scheduledDeadlineMs = next.deadlineMs;
645
+ const delayMs = Math.max(0, next.deadlineMs - Date.now());
646
+ this.timeoutTimer = setTimeout(() => this.expireDueWaiters(), delayMs);
647
+ }
648
+
649
+ private expireDueWaiters(): void {
650
+ this.timeoutTimer = null;
651
+ this.scheduledDeadlineMs = null;
652
+
653
+ const start = Date.now();
654
+ const now = start;
655
+ let expired = 0;
656
+
657
+ for (;;) {
658
+ const head = this.deadlineHeap[0];
659
+ if (!head) break;
660
+ if (head.deadlineMs > now) break;
661
+
662
+ const w = this.heapPopMin();
663
+ if (!w) break;
664
+ if (w.done) continue;
665
+
666
+ expired += 1;
667
+ w.cleanup(null);
668
+ }
669
+
670
+ if (expired > 0) {
671
+ const sweepMs = Date.now() - start;
672
+ this.interval.timeoutsFired += expired;
673
+ this.interval.timeoutSweeps += 1;
674
+ this.interval.timeoutSweepMsSum += sweepMs;
675
+ this.interval.timeoutSweepMsMax = Math.max(this.interval.timeoutSweepMsMax, sweepMs);
676
+ this.totals.timeoutsFired += expired;
677
+ this.totals.timeoutSweeps += 1;
678
+ this.totals.timeoutSweepMsSum += sweepMs;
679
+ this.totals.timeoutSweepMsMax = Math.max(this.totals.timeoutSweepMsMax, sweepMs);
680
+ }
681
+
682
+ this.rescheduleTimeoutTimer();
683
+ }
684
+
685
+ private heapSwap(i: number, j: number): void {
686
+ const a = this.deadlineHeap[i]!;
687
+ const b = this.deadlineHeap[j]!;
688
+ this.deadlineHeap[i] = b;
689
+ this.deadlineHeap[j] = a;
690
+ a.heapIndex = j;
691
+ b.heapIndex = i;
692
+ }
693
+
694
+ private heapLess(i: number, j: number): boolean {
695
+ const a = this.deadlineHeap[i]!;
696
+ const b = this.deadlineHeap[j]!;
697
+ return a.deadlineMs < b.deadlineMs;
698
+ }
699
+
700
+ private heapSiftUp(i: number): void {
701
+ let idx = i;
702
+ while (idx > 0) {
703
+ const parent = (idx - 1) >> 1;
704
+ if (!this.heapLess(idx, parent)) break;
705
+ this.heapSwap(idx, parent);
706
+ idx = parent;
707
+ }
708
+ }
709
+
710
+ private heapSiftDown(i: number): void {
711
+ let idx = i;
712
+ for (;;) {
713
+ const left = idx * 2 + 1;
714
+ const right = left + 1;
715
+ if (left >= this.deadlineHeap.length) break;
716
+ let smallest = left;
717
+ if (right < this.deadlineHeap.length && this.heapLess(right, left)) smallest = right;
718
+ if (!this.heapLess(smallest, idx)) break;
719
+ this.heapSwap(idx, smallest);
720
+ idx = smallest;
721
+ }
722
+ }
723
+
724
+ private heapPush(w: Waiter): void {
725
+ if (w.heapIndex >= 0) return;
726
+ w.heapIndex = this.deadlineHeap.length;
727
+ this.deadlineHeap.push(w);
728
+ this.heapSiftUp(w.heapIndex);
729
+ }
730
+
731
+ // Returns true if the root was removed.
732
+ private heapRemove(w: Waiter): boolean {
733
+ const idx = w.heapIndex;
734
+ if (idx < 0) return false;
735
+
736
+ const lastIdx = this.deadlineHeap.length - 1;
737
+ const removedRoot = idx === 0;
738
+ if (idx !== lastIdx) this.heapSwap(idx, lastIdx);
739
+ this.deadlineHeap.pop();
740
+ w.heapIndex = -1;
741
+
742
+ if (idx < this.deadlineHeap.length) {
743
+ this.heapSiftDown(idx);
744
+ this.heapSiftUp(idx);
745
+ }
746
+ return removedRoot;
747
+ }
748
+
749
+ private heapPopMin(): Waiter | null {
750
+ if (this.deadlineHeap.length === 0) return null;
751
+ const w = this.deadlineHeap[0]!;
752
+ const last = this.deadlineHeap.length - 1;
753
+ if (last === 0) {
754
+ this.deadlineHeap.pop();
755
+ w.heapIndex = -1;
756
+ return w;
757
+ }
758
+ this.heapSwap(0, last);
759
+ this.deadlineHeap.pop();
760
+ w.heapIndex = -1;
761
+ this.heapSiftDown(0);
762
+ return w;
763
+ }
764
+ }
765
+
766
+ function percentile(values: number[], p: number): number {
767
+ if (values.length === 0) return 0;
768
+ const sorted = [...values].sort((a, b) => a - b);
769
+ const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p)));
770
+ return sorted[idx] ?? 0;
771
+ }