@prisma/streams-server 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +201 -0
  4. package/README.md +39 -2
  5. package/SECURITY.md +33 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +29 -34
  8. package/src/app.ts +74 -0
  9. package/src/app_core.ts +1983 -0
  10. package/src/app_local.ts +46 -0
  11. package/src/backpressure.ts +66 -0
  12. package/src/bootstrap.ts +239 -0
  13. package/src/config.ts +251 -0
  14. package/src/db/db.ts +1440 -0
  15. package/src/db/schema.ts +619 -0
  16. package/src/expiry_sweeper.ts +44 -0
  17. package/src/hist.ts +169 -0
  18. package/src/index/binary_fuse.ts +379 -0
  19. package/src/index/indexer.ts +745 -0
  20. package/src/index/run_cache.ts +84 -0
  21. package/src/index/run_format.ts +213 -0
  22. package/src/ingest.ts +655 -0
  23. package/src/lens/lens.ts +501 -0
  24. package/src/manifest.ts +114 -0
  25. package/src/memory.ts +155 -0
  26. package/src/metrics.ts +161 -0
  27. package/src/metrics_emitter.ts +50 -0
  28. package/src/notifier.ts +64 -0
  29. package/src/objectstore/interface.ts +13 -0
  30. package/src/objectstore/mock_r2.ts +269 -0
  31. package/src/objectstore/null.ts +32 -0
  32. package/src/objectstore/r2.ts +128 -0
  33. package/src/offset.ts +70 -0
  34. package/src/reader.ts +454 -0
  35. package/src/runtime/hash.ts +156 -0
  36. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  37. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  38. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  39. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  40. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  41. package/src/schema/lens_schema.ts +290 -0
  42. package/src/schema/proof.ts +547 -0
  43. package/src/schema/registry.ts +405 -0
  44. package/src/segment/cache.ts +179 -0
  45. package/src/segment/format.ts +331 -0
  46. package/src/segment/segmenter.ts +326 -0
  47. package/src/segment/segmenter_worker.ts +43 -0
  48. package/src/segment/segmenter_workers.ts +94 -0
  49. package/src/server.ts +326 -0
  50. package/src/sqlite/adapter.ts +164 -0
  51. package/src/stats.ts +205 -0
  52. package/src/touch/engine.ts +41 -0
  53. package/src/touch/interpreter_worker.ts +459 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +858 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1341 -0
  58. package/src/touch/naming.ts +13 -0
  59. package/src/touch/routing_key_notifier.ts +275 -0
  60. package/src/touch/spec.ts +526 -0
  61. package/src/touch/touch_journal.ts +671 -0
  62. package/src/touch/touch_key_id.ts +20 -0
  63. package/src/touch/worker_pool.ts +189 -0
  64. package/src/touch/worker_protocol.ts +58 -0
  65. package/src/types/proper-lockfile.d.ts +1 -0
  66. package/src/uploader.ts +317 -0
  67. package/src/util/base32_crockford.ts +81 -0
  68. package/src/util/bloom256.ts +67 -0
  69. package/src/util/cleanup.ts +22 -0
  70. package/src/util/crc32c.ts +29 -0
  71. package/src/util/ds_error.ts +15 -0
  72. package/src/util/duration.ts +17 -0
  73. package/src/util/endian.ts +53 -0
  74. package/src/util/json_pointer.ts +148 -0
  75. package/src/util/log.ts +25 -0
  76. package/src/util/lru.ts +45 -0
  77. package/src/util/retry.ts +35 -0
  78. package/src/util/siphash.ts +71 -0
  79. package/src/util/stream_paths.ts +31 -0
  80. package/src/util/time.ts +14 -0
  81. package/src/util/yield.ts +3 -0
  82. package/build/index.d.mts +0 -1
  83. package/build/index.d.ts +0 -1
  84. package/build/index.js +0 -0
  85. package/build/index.mjs +0 -1
@@ -0,0 +1,671 @@
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
+ // For huge keysets, we avoid per-key indexing and instead scan on flush.
13
+ broad: boolean;
14
+ deadlineMs: number;
15
+ heapIndex: number;
16
+ done: boolean;
17
+ cleanup: (hit: TouchHit) => void;
18
+ };
19
+
20
+ type IntervalStats = {
21
+ timeoutsFired: number;
22
+ timeoutSweeps: number;
23
+ timeoutSweepMsSum: number;
24
+ timeoutSweepMsMax: number;
25
+ notifyWakeups: number;
26
+ notifyFlushes: number;
27
+ notifyWakeMsSum: number;
28
+ notifyWakeMsMax: number;
29
+ heapSize: number;
30
+ };
31
+
32
+ export type TouchJournalIntervalStats = IntervalStats;
33
+
34
+ type TotalStats = {
35
+ timeoutsFired: number;
36
+ timeoutSweeps: number;
37
+ timeoutSweepMsSum: number;
38
+ timeoutSweepMsMax: number;
39
+ notifyWakeups: number;
40
+ notifyFlushes: number;
41
+ notifyWakeMsSum: number;
42
+ notifyWakeMsMax: number;
43
+ flushes: number;
44
+ };
45
+
46
+ function u32(x: number): number {
47
+ return x >>> 0;
48
+ }
49
+
50
+ function mix32(x: number): number {
51
+ // Murmur3 finalizer-ish avalanche mix.
52
+ let y = u32(x);
53
+ y ^= y >>> 16;
54
+ y = Math.imul(y, 0x85ebca6b) >>> 0;
55
+ y ^= y >>> 13;
56
+ y = Math.imul(y, 0xc2b2ae35) >>> 0;
57
+ y ^= y >>> 16;
58
+ return y >>> 0;
59
+ }
60
+
61
+ function newEpochHex16(): string {
62
+ const buf = new Uint32Array(2);
63
+ crypto.getRandomValues(buf);
64
+ return buf[0]!.toString(16).padStart(8, "0") + buf[1]!.toString(16).padStart(8, "0");
65
+ }
66
+
67
+ export type TouchJournalMeta = {
68
+ mode: "memory";
69
+ cursor: string;
70
+ epoch: string;
71
+ generation: number;
72
+ bucketMs: number;
73
+ coalesceMs: number;
74
+ filterSize: number;
75
+ k: number;
76
+ pendingKeys: number;
77
+ overflowBuckets: number;
78
+ activeWaiters: number;
79
+ bucketMaxSourceOffsetSeq: string;
80
+ lastFlushAtMs: number;
81
+ flushIntervalMsMaxLast10s: number;
82
+ flushIntervalMsP95Last10s: number;
83
+ };
84
+
85
+ export type TouchWaitResult =
86
+ | { stale: true; cursor: string; epoch: string; generation: number }
87
+ | { stale: false; touched: boolean; cursor: string };
88
+
89
+ export function parseTouchCursor(raw: string): { epoch: string; generation: number } | null {
90
+ const s = raw.trim();
91
+ if (s === "") return null;
92
+ const idx = s.indexOf(":");
93
+ if (idx <= 0) return null;
94
+ const epoch = s.slice(0, idx);
95
+ const genRaw = s.slice(idx + 1);
96
+ if (!/^[0-9a-f]{16}$/i.test(epoch)) return null;
97
+ if (!/^[0-9]+$/.test(genRaw)) return null;
98
+ const gen = Number(genRaw);
99
+ if (!Number.isFinite(gen) || gen < 0) return null;
100
+ return { epoch: epoch.toLowerCase(), generation: Math.floor(gen) };
101
+ }
102
+
103
+ export function formatTouchCursor(epoch: string, generation: number): string {
104
+ return `${epoch}:${Math.max(0, Math.floor(generation))}`;
105
+ }
106
+
107
+ /**
108
+ * Memory-only touch journal:
109
+ * - fixed-size time-aware bloom filter: lastSet[pos] = generation
110
+ * - bucketed flush (default 100ms) to avoid mid-bucket false negatives
111
+ * - waiter index + single global deadline heap for reliable timeouts under load
112
+ *
113
+ * Safety model:
114
+ * - No false negatives within an epoch (process lifetime), except if generation wraps (uint32).
115
+ * - False positives are allowed (extra invalidations).
116
+ */
117
+ export class TouchJournal {
118
+ private readonly epoch: string;
119
+ private generation: number;
120
+ private readonly bucketMs: number;
121
+ private coalesceMs: number;
122
+
123
+ private readonly k: number;
124
+ private readonly mask: number;
125
+ private readonly lastSet: Uint32Array;
126
+
127
+ private readonly pending = new Set<number>();
128
+ private pendingBucketStartMs = 0;
129
+ private pendingMaxSourceOffsetSeq: bigint = -1n;
130
+ private lastFlushedSourceOffsetSeq: bigint = -1n;
131
+ private overflow = false;
132
+ private overflowBuckets = 0;
133
+ private lastOverflowGeneration = 0;
134
+ private lastFlushAtMs = 0;
135
+ private lastBucketStartMs = 0;
136
+ private readonly flushIntervalsLast10s: Array<{ atMs: number; intervalMs: number }> = [];
137
+
138
+ private flushTimer: any | null = null;
139
+
140
+ private readonly byKey = new Map<number, Set<Waiter>>();
141
+ private readonly broad = new Set<Waiter>();
142
+ private activeWaiters = 0;
143
+
144
+ // Single global deadline heap + timer for waiter expiry.
145
+ private readonly deadlineHeap: Waiter[] = [];
146
+ private timeoutTimer: any | null = null;
147
+ private scheduledDeadlineMs: number | null = null;
148
+
149
+ private interval: IntervalStats = {
150
+ timeoutsFired: 0,
151
+ timeoutSweeps: 0,
152
+ timeoutSweepMsSum: 0,
153
+ timeoutSweepMsMax: 0,
154
+ notifyWakeups: 0,
155
+ notifyFlushes: 0,
156
+ notifyWakeMsSum: 0,
157
+ notifyWakeMsMax: 0,
158
+ heapSize: 0,
159
+ };
160
+ private totals: TotalStats = {
161
+ timeoutsFired: 0,
162
+ timeoutSweeps: 0,
163
+ timeoutSweepMsSum: 0,
164
+ timeoutSweepMsMax: 0,
165
+ notifyWakeups: 0,
166
+ notifyFlushes: 0,
167
+ notifyWakeMsSum: 0,
168
+ notifyWakeMsMax: 0,
169
+ flushes: 0,
170
+ };
171
+
172
+ // Hard bound for unique keys per bucket. If exceeded we treat the whole bucket as "overflow"
173
+ // and wake all waiters (lossy but safe).
174
+ private readonly pendingMaxKeys: number;
175
+ private readonly keyIndexMaxKeys: number;
176
+
177
+ constructor(opts: {
178
+ bucketMs: number;
179
+ filterPow2: number;
180
+ k: number;
181
+ pendingMaxKeys: number;
182
+ keyIndexMaxKeys: number;
183
+ }) {
184
+ this.epoch = newEpochHex16();
185
+ this.generation = 0;
186
+ this.bucketMs = Math.max(1, Math.floor(opts.bucketMs));
187
+ this.coalesceMs = this.bucketMs;
188
+ const pow2 = Math.max(10, Math.min(30, Math.floor(opts.filterPow2)));
189
+ const size = 1 << pow2;
190
+ this.k = Math.max(1, Math.min(8, Math.floor(opts.k)));
191
+ this.mask = size - 1;
192
+ this.lastSet = new Uint32Array(size);
193
+ this.pendingMaxKeys = Math.max(1, Math.floor(opts.pendingMaxKeys));
194
+ this.keyIndexMaxKeys = Math.max(1, Math.floor(opts.keyIndexMaxKeys));
195
+ }
196
+
197
+ stop(): void {
198
+ if (this.flushTimer) clearTimeout(this.flushTimer);
199
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
200
+ this.flushTimer = null;
201
+ this.timeoutTimer = null;
202
+ this.scheduledDeadlineMs = null;
203
+ this.pending.clear();
204
+ this.pendingBucketStartMs = 0;
205
+ this.pendingMaxSourceOffsetSeq = -1n;
206
+ this.lastFlushedSourceOffsetSeq = -1n;
207
+ this.lastFlushAtMs = 0;
208
+ this.lastBucketStartMs = 0;
209
+ this.flushIntervalsLast10s.length = 0;
210
+ this.byKey.clear();
211
+ this.broad.clear();
212
+ this.deadlineHeap.length = 0;
213
+ this.activeWaiters = 0;
214
+ }
215
+
216
+ getEpoch(): string {
217
+ return this.epoch;
218
+ }
219
+
220
+ getGeneration(): number {
221
+ return this.generation >>> 0;
222
+ }
223
+
224
+ getCursor(): string {
225
+ return formatTouchCursor(this.epoch, this.getGeneration());
226
+ }
227
+
228
+ getLastFlushedSourceOffsetSeq(): bigint {
229
+ return this.lastFlushedSourceOffsetSeq;
230
+ }
231
+
232
+ getActiveWaiters(): number {
233
+ return this.activeWaiters;
234
+ }
235
+
236
+ snapshotAndResetIntervalStats(): IntervalStats {
237
+ const out = { ...this.interval, heapSize: this.deadlineHeap.length };
238
+ this.interval = {
239
+ timeoutsFired: 0,
240
+ timeoutSweeps: 0,
241
+ timeoutSweepMsSum: 0,
242
+ timeoutSweepMsMax: 0,
243
+ notifyWakeups: 0,
244
+ notifyFlushes: 0,
245
+ notifyWakeMsSum: 0,
246
+ notifyWakeMsMax: 0,
247
+ heapSize: 0,
248
+ };
249
+ return out;
250
+ }
251
+
252
+ getTotalStats(): TotalStats {
253
+ return { ...this.totals };
254
+ }
255
+
256
+ getMeta(): TouchJournalMeta {
257
+ const nowMs = Date.now();
258
+ this.pruneFlushIntervals(nowMs);
259
+ const intervals = this.flushIntervalsLast10s.map((x) => x.intervalMs);
260
+ return {
261
+ mode: "memory",
262
+ cursor: this.getCursor(),
263
+ epoch: this.epoch,
264
+ generation: this.getGeneration(),
265
+ bucketMs: this.bucketMs,
266
+ coalesceMs: this.coalesceMs,
267
+ filterSize: this.lastSet.length,
268
+ k: this.k,
269
+ pendingKeys: this.pending.size,
270
+ overflowBuckets: this.overflowBuckets,
271
+ activeWaiters: this.activeWaiters,
272
+ bucketMaxSourceOffsetSeq: this.lastFlushedSourceOffsetSeq.toString(),
273
+ lastFlushAtMs: this.lastFlushAtMs,
274
+ flushIntervalMsMaxLast10s: intervals.length > 0 ? Math.max(...intervals) : 0,
275
+ flushIntervalMsP95Last10s: percentile(intervals, 0.95),
276
+ };
277
+ }
278
+
279
+ touch(keyId: number, sourceOffsetSeq?: bigint): void {
280
+ if (this.pending.size === 0 && !this.overflow && this.pendingBucketStartMs <= 0) {
281
+ this.pendingBucketStartMs = Date.now();
282
+ }
283
+ if (this.pending.size >= this.pendingMaxKeys) {
284
+ // We may drop fine touches once we overflow, but we must treat the bucket
285
+ // as a broadcast invalidation to avoid false negatives.
286
+ this.overflow = true;
287
+ } else {
288
+ this.pending.add(u32(keyId));
289
+ }
290
+ if (typeof sourceOffsetSeq === "bigint" && sourceOffsetSeq > this.pendingMaxSourceOffsetSeq) {
291
+ this.pendingMaxSourceOffsetSeq = sourceOffsetSeq;
292
+ }
293
+ this.ensureFlushScheduled();
294
+ }
295
+
296
+ setCoalesceMs(ms: number): void {
297
+ const next = Math.max(1, Math.min(this.bucketMs, Math.floor(ms)));
298
+ this.coalesceMs = next;
299
+ }
300
+
301
+ /**
302
+ * Best-effort membership query: "maybe touched since sinceGeneration".
303
+ * False positives are allowed; false negatives within epoch are not (except overflow / generation wrap).
304
+ */
305
+ maybeTouchedSince(keyId: number, sinceGeneration: number): boolean {
306
+ const since = u32(sinceGeneration);
307
+ if (since < u32(this.lastOverflowGeneration)) return true;
308
+ const h1 = u32(keyId);
309
+ let h2 = mix32(h1);
310
+ if (h2 === 0) h2 = 0x9e3779b9; // avoid zero stride
311
+ let min = 0xffffffff;
312
+ for (let i = 0; i < this.k; i++) {
313
+ const pos = u32(h1 + Math.imul(i, h2)) & this.mask;
314
+ const g = this.lastSet[pos]!;
315
+ if (g < min) min = g;
316
+ }
317
+ return u32(min) > since;
318
+ }
319
+
320
+ maybeTouchedSinceAny(keyIds: number[], sinceGeneration: number): boolean {
321
+ const since = u32(sinceGeneration);
322
+ if (since < u32(this.lastOverflowGeneration)) return true;
323
+ for (let i = 0; i < keyIds.length; i++) {
324
+ if (this.maybeTouchedSince(keyIds[i]!, since)) return true;
325
+ }
326
+ return false;
327
+ }
328
+
329
+ /**
330
+ * Wait for any of `keys` to be touched in a bucket generation strictly greater than `afterGeneration`.
331
+ *
332
+ * Returns:
333
+ * - `{generation, keyId}` when touched
334
+ * - `null` on timeout or abort
335
+ */
336
+ waitForAny(args: { keys: number[]; afterGeneration: number; timeoutMs: number; signal?: AbortSignal }): Promise<TouchHit> {
337
+ if (args.keys.length === 0) return Promise.resolve(null);
338
+ if (args.signal?.aborted) return Promise.resolve(null);
339
+ const timeoutMs = Math.max(0, Math.floor(args.timeoutMs));
340
+ if (timeoutMs <= 0) return Promise.resolve(null);
341
+
342
+ const keys = Array.from(new Set(args.keys.map(u32)));
343
+ const broad = keys.length > this.keyIndexMaxKeys;
344
+
345
+ return new Promise((resolve) => {
346
+ const waiter: Waiter = {
347
+ afterGeneration: u32(args.afterGeneration),
348
+ keys,
349
+ broad,
350
+ deadlineMs: Date.now() + timeoutMs,
351
+ heapIndex: -1,
352
+ done: false,
353
+ cleanup: (hit) => {
354
+ if (waiter.done) return;
355
+ waiter.done = true;
356
+
357
+ if (waiter.broad) {
358
+ this.broad.delete(waiter);
359
+ } else {
360
+ for (const k of waiter.keys) {
361
+ const s = this.byKey.get(k);
362
+ if (!s) continue;
363
+ s.delete(waiter);
364
+ if (s.size === 0) this.byKey.delete(k);
365
+ }
366
+ }
367
+
368
+ this.activeWaiters = Math.max(0, this.activeWaiters - 1);
369
+
370
+ const removedRoot = this.heapRemove(waiter);
371
+ if (args.signal) args.signal.removeEventListener("abort", onAbort);
372
+ if (removedRoot) this.rescheduleTimeoutTimer();
373
+ resolve(hit);
374
+ },
375
+ };
376
+
377
+ if (waiter.broad) {
378
+ this.broad.add(waiter);
379
+ } else {
380
+ for (const k of waiter.keys) {
381
+ const set = this.byKey.get(k) ?? new Set<Waiter>();
382
+ set.add(waiter);
383
+ this.byKey.set(k, set);
384
+ }
385
+ }
386
+ this.activeWaiters += 1;
387
+
388
+ const onAbort = () => waiter.cleanup(null);
389
+ if (args.signal) args.signal.addEventListener("abort", onAbort, { once: true });
390
+
391
+ this.heapPush(waiter);
392
+ this.rescheduleTimeoutTimer();
393
+ });
394
+ }
395
+
396
+ private ensureFlushScheduled(): void {
397
+ if (this.flushTimer) return;
398
+ this.flushTimer = setTimeout(() => this.flushBucket(), this.coalesceMs);
399
+ }
400
+
401
+ private flushBucket(): void {
402
+ this.flushTimer = null;
403
+
404
+ const hasTouches = this.pending.size > 0 || this.overflow;
405
+ if (!hasTouches) return;
406
+
407
+ // Advance generation only at bucket boundaries so cursors are safe.
408
+ this.generation = u32(this.generation + 1);
409
+ const gen = this.getGeneration();
410
+ const bucketMaxSourceOffsetSeq = this.pendingMaxSourceOffsetSeq;
411
+ if (bucketMaxSourceOffsetSeq > this.lastFlushedSourceOffsetSeq) this.lastFlushedSourceOffsetSeq = bucketMaxSourceOffsetSeq;
412
+ const flushAtMs = Date.now();
413
+ const bucketStartMs = this.pendingBucketStartMs > 0 ? this.pendingBucketStartMs : flushAtMs;
414
+ if (this.lastFlushAtMs > 0 && flushAtMs >= this.lastFlushAtMs) {
415
+ this.flushIntervalsLast10s.push({ atMs: flushAtMs, intervalMs: flushAtMs - this.lastFlushAtMs });
416
+ this.pruneFlushIntervals(flushAtMs);
417
+ }
418
+ this.lastFlushAtMs = flushAtMs;
419
+ this.lastBucketStartMs = bucketStartMs;
420
+ this.totals.flushes += 1;
421
+
422
+ if (this.overflow) {
423
+ this.overflowBuckets += 1;
424
+ this.lastOverflowGeneration = gen;
425
+ }
426
+
427
+ // Update bloom filter for touched keys. We still update for the keys we captured even on overflow;
428
+ // the overflow marker is what preserves correctness for dropped keys.
429
+ for (const keyId of this.pending) {
430
+ const h1 = u32(keyId);
431
+ let h2 = mix32(h1);
432
+ if (h2 === 0) h2 = 0x9e3779b9;
433
+ for (let i = 0; i < this.k; i++) {
434
+ const pos = u32(h1 + Math.imul(i, h2)) & this.mask;
435
+ this.lastSet[pos] = gen;
436
+ }
437
+ }
438
+
439
+ if (this.overflow) {
440
+ // Broadcast wakeup: resolve all waiters (safe, lossy).
441
+ const wakeStartMs = Date.now();
442
+ let wakeups = 0;
443
+ const all: Waiter[] = [];
444
+ for (const s of this.byKey.values()) for (const w of s) all.push(w);
445
+ for (const w of this.broad) all.push(w);
446
+ for (const w of all) {
447
+ if (w.done) continue;
448
+ if (gen > w.afterGeneration) {
449
+ wakeups += 1;
450
+ w.cleanup({ generation: gen, keyId: 0, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
451
+ }
452
+ }
453
+ if (wakeups > 0) {
454
+ const wakeMs = Date.now() - wakeStartMs;
455
+ this.interval.notifyWakeups += wakeups;
456
+ this.interval.notifyFlushes += 1;
457
+ this.interval.notifyWakeMsSum += wakeMs;
458
+ this.interval.notifyWakeMsMax = Math.max(this.interval.notifyWakeMsMax, wakeMs);
459
+ this.totals.notifyWakeups += wakeups;
460
+ this.totals.notifyFlushes += 1;
461
+ this.totals.notifyWakeMsSum += wakeMs;
462
+ this.totals.notifyWakeMsMax = Math.max(this.totals.notifyWakeMsMax, wakeMs);
463
+ }
464
+ } else {
465
+ // Wake keyed waiters by touched key id.
466
+ const wakeStartMs = Date.now();
467
+ let wakeups = 0;
468
+ for (const keyId of this.pending) {
469
+ const set = this.byKey.get(keyId);
470
+ if (!set || set.size === 0) continue;
471
+ for (const w of set) {
472
+ if (w.done) continue;
473
+ if (gen > w.afterGeneration) {
474
+ wakeups += 1;
475
+ w.cleanup({ generation: gen, keyId, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
476
+ }
477
+ }
478
+ }
479
+
480
+ // Wake broad waiters by scanning bloom membership for their keysets.
481
+ if (this.broad.size > 0) {
482
+ for (const w of this.broad) {
483
+ if (w.done) continue;
484
+ if (gen <= w.afterGeneration) continue;
485
+ let hit = false;
486
+ for (let i = 0; i < w.keys.length; i++) {
487
+ if (this.maybeTouchedSince(w.keys[i]!, w.afterGeneration)) {
488
+ hit = true;
489
+ break;
490
+ }
491
+ }
492
+ if (hit) {
493
+ wakeups += 1;
494
+ w.cleanup({ generation: gen, keyId: 0, bucketMaxSourceOffsetSeq, flushAtMs, bucketStartMs });
495
+ }
496
+ }
497
+ }
498
+ if (wakeups > 0) {
499
+ const wakeMs = Date.now() - wakeStartMs;
500
+ this.interval.notifyWakeups += wakeups;
501
+ this.interval.notifyFlushes += 1;
502
+ this.interval.notifyWakeMsSum += wakeMs;
503
+ this.interval.notifyWakeMsMax = Math.max(this.interval.notifyWakeMsMax, wakeMs);
504
+ this.totals.notifyWakeups += wakeups;
505
+ this.totals.notifyFlushes += 1;
506
+ this.totals.notifyWakeMsSum += wakeMs;
507
+ this.totals.notifyWakeMsMax = Math.max(this.totals.notifyWakeMsMax, wakeMs);
508
+ }
509
+ }
510
+
511
+ this.pending.clear();
512
+ this.pendingBucketStartMs = 0;
513
+ this.pendingMaxSourceOffsetSeq = -1n;
514
+ this.overflow = false;
515
+ }
516
+
517
+ getLastFlushAtMs(): number {
518
+ return this.lastFlushAtMs;
519
+ }
520
+
521
+ getLastBucketStartMs(): number {
522
+ return this.lastBucketStartMs;
523
+ }
524
+
525
+ private pruneFlushIntervals(nowMs: number): void {
526
+ const cutoff = nowMs - 10_000;
527
+ while (this.flushIntervalsLast10s.length > 0 && this.flushIntervalsLast10s[0]!.atMs < cutoff) {
528
+ this.flushIntervalsLast10s.shift();
529
+ }
530
+ }
531
+
532
+ private rescheduleTimeoutTimer(): void {
533
+ const next = this.deadlineHeap[0];
534
+ if (!next) {
535
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
536
+ this.timeoutTimer = null;
537
+ this.scheduledDeadlineMs = null;
538
+ return;
539
+ }
540
+
541
+ if (this.timeoutTimer && this.scheduledDeadlineMs != null && this.scheduledDeadlineMs === next.deadlineMs) return;
542
+
543
+ if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
544
+ this.scheduledDeadlineMs = next.deadlineMs;
545
+ const delayMs = Math.max(0, next.deadlineMs - Date.now());
546
+ this.timeoutTimer = setTimeout(() => this.expireDueWaiters(), delayMs);
547
+ }
548
+
549
+ private expireDueWaiters(): void {
550
+ this.timeoutTimer = null;
551
+ this.scheduledDeadlineMs = null;
552
+
553
+ const start = Date.now();
554
+ const now = start;
555
+ let expired = 0;
556
+
557
+ for (;;) {
558
+ const head = this.deadlineHeap[0];
559
+ if (!head) break;
560
+ if (head.deadlineMs > now) break;
561
+
562
+ const w = this.heapPopMin();
563
+ if (!w) break;
564
+ if (w.done) continue;
565
+
566
+ expired += 1;
567
+ w.cleanup(null);
568
+ }
569
+
570
+ if (expired > 0) {
571
+ const sweepMs = Date.now() - start;
572
+ this.interval.timeoutsFired += expired;
573
+ this.interval.timeoutSweeps += 1;
574
+ this.interval.timeoutSweepMsSum += sweepMs;
575
+ this.interval.timeoutSweepMsMax = Math.max(this.interval.timeoutSweepMsMax, sweepMs);
576
+ this.totals.timeoutsFired += expired;
577
+ this.totals.timeoutSweeps += 1;
578
+ this.totals.timeoutSweepMsSum += sweepMs;
579
+ this.totals.timeoutSweepMsMax = Math.max(this.totals.timeoutSweepMsMax, sweepMs);
580
+ }
581
+
582
+ this.rescheduleTimeoutTimer();
583
+ }
584
+
585
+ private heapSwap(i: number, j: number): void {
586
+ const a = this.deadlineHeap[i]!;
587
+ const b = this.deadlineHeap[j]!;
588
+ this.deadlineHeap[i] = b;
589
+ this.deadlineHeap[j] = a;
590
+ a.heapIndex = j;
591
+ b.heapIndex = i;
592
+ }
593
+
594
+ private heapLess(i: number, j: number): boolean {
595
+ const a = this.deadlineHeap[i]!;
596
+ const b = this.deadlineHeap[j]!;
597
+ return a.deadlineMs < b.deadlineMs;
598
+ }
599
+
600
+ private heapSiftUp(i: number): void {
601
+ let idx = i;
602
+ while (idx > 0) {
603
+ const parent = (idx - 1) >> 1;
604
+ if (!this.heapLess(idx, parent)) break;
605
+ this.heapSwap(idx, parent);
606
+ idx = parent;
607
+ }
608
+ }
609
+
610
+ private heapSiftDown(i: number): void {
611
+ let idx = i;
612
+ for (;;) {
613
+ const left = idx * 2 + 1;
614
+ const right = left + 1;
615
+ if (left >= this.deadlineHeap.length) break;
616
+ let smallest = left;
617
+ if (right < this.deadlineHeap.length && this.heapLess(right, left)) smallest = right;
618
+ if (!this.heapLess(smallest, idx)) break;
619
+ this.heapSwap(idx, smallest);
620
+ idx = smallest;
621
+ }
622
+ }
623
+
624
+ private heapPush(w: Waiter): void {
625
+ if (w.heapIndex >= 0) return;
626
+ w.heapIndex = this.deadlineHeap.length;
627
+ this.deadlineHeap.push(w);
628
+ this.heapSiftUp(w.heapIndex);
629
+ }
630
+
631
+ // Returns true if the root was removed.
632
+ private heapRemove(w: Waiter): boolean {
633
+ const idx = w.heapIndex;
634
+ if (idx < 0) return false;
635
+
636
+ const lastIdx = this.deadlineHeap.length - 1;
637
+ const removedRoot = idx === 0;
638
+ if (idx !== lastIdx) this.heapSwap(idx, lastIdx);
639
+ this.deadlineHeap.pop();
640
+ w.heapIndex = -1;
641
+
642
+ if (idx < this.deadlineHeap.length) {
643
+ this.heapSiftDown(idx);
644
+ this.heapSiftUp(idx);
645
+ }
646
+ return removedRoot;
647
+ }
648
+
649
+ private heapPopMin(): Waiter | null {
650
+ if (this.deadlineHeap.length === 0) return null;
651
+ const w = this.deadlineHeap[0]!;
652
+ const last = this.deadlineHeap.length - 1;
653
+ if (last === 0) {
654
+ this.deadlineHeap.pop();
655
+ w.heapIndex = -1;
656
+ return w;
657
+ }
658
+ this.heapSwap(0, last);
659
+ this.deadlineHeap.pop();
660
+ w.heapIndex = -1;
661
+ this.heapSiftDown(0);
662
+ return w;
663
+ }
664
+ }
665
+
666
+ function percentile(values: number[], p: number): number {
667
+ if (values.length === 0) return 0;
668
+ const sorted = [...values].sort((a, b) => a - b);
669
+ const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p)));
670
+ return sorted[idx] ?? 0;
671
+ }
@@ -0,0 +1,20 @@
1
+ import { Result } from "better-result";
2
+ import { xxh32Result, type HashError } from "../runtime/hash";
3
+ import { dsError } from "../util/ds_error.ts";
4
+
5
+ export type TouchKeyIdError = HashError;
6
+
7
+ export function touchKeyIdFromRoutingKeyResult(key: string): Result<number, TouchKeyIdError> {
8
+ const s = key.trim().toLowerCase();
9
+ if (/^[0-9a-f]{16}$/.test(s)) {
10
+ // low32 of the canonical 64-bit routing key.
11
+ return Result.ok(Number.parseInt(s.slice(8), 16) >>> 0);
12
+ }
13
+ return xxh32Result(s);
14
+ }
15
+
16
+ export function touchKeyIdFromRoutingKey(key: string): number {
17
+ const res = touchKeyIdFromRoutingKeyResult(key);
18
+ if (Result.isError(res)) throw dsError(res.error.message);
19
+ return res.value;
20
+ }