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