@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.
Files changed (83) 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 +1706 -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 +1386 -0
  15. package/src/db/schema.ts +625 -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 +442 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +827 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1199 -0
  58. package/src/touch/spec.ts +456 -0
  59. package/src/touch/touch_journal.ts +671 -0
  60. package/src/touch/touch_key_id.ts +20 -0
  61. package/src/touch/worker_pool.ts +189 -0
  62. package/src/touch/worker_protocol.ts +56 -0
  63. package/src/types/proper-lockfile.d.ts +1 -0
  64. package/src/uploader.ts +317 -0
  65. package/src/util/base32_crockford.ts +81 -0
  66. package/src/util/bloom256.ts +67 -0
  67. package/src/util/cleanup.ts +22 -0
  68. package/src/util/crc32c.ts +29 -0
  69. package/src/util/ds_error.ts +15 -0
  70. package/src/util/duration.ts +17 -0
  71. package/src/util/endian.ts +53 -0
  72. package/src/util/json_pointer.ts +148 -0
  73. package/src/util/log.ts +25 -0
  74. package/src/util/lru.ts +45 -0
  75. package/src/util/retry.ts +35 -0
  76. package/src/util/siphash.ts +71 -0
  77. package/src/util/stream_paths.ts +31 -0
  78. package/src/util/time.ts +14 -0
  79. package/src/util/yield.ts +3 -0
  80. package/build/index.d.mts +0 -1
  81. package/build/index.d.ts +0 -1
  82. package/build/index.js +0 -0
  83. package/build/index.mjs +0 -1
package/src/ingest.ts ADDED
@@ -0,0 +1,655 @@
1
+ import type { Config } from "./config";
2
+ import type { SqliteDurableStore } from "./db/db";
3
+ import { STREAM_FLAG_DELETED } from "./db/db";
4
+ import type { StatsCollector } from "./stats";
5
+ import type { BackpressureGate } from "./backpressure";
6
+ import type { MemoryGuard } from "./memory";
7
+ import type { Metrics } from "./metrics";
8
+ import { Result } from "better-result";
9
+
10
+ export type AppendRow = {
11
+ routingKey: Uint8Array | null;
12
+ contentType: string | null;
13
+ payload: Uint8Array;
14
+ };
15
+
16
+ export type ProducerInfo = {
17
+ id: string;
18
+ epoch: number;
19
+ seq: number;
20
+ };
21
+
22
+ export type AppendSuccess = {
23
+ lastOffset: bigint;
24
+ appendedRows: number;
25
+ closed: boolean;
26
+ duplicate: boolean;
27
+ producer?: { epoch: number; seq: number };
28
+ };
29
+
30
+ export type AppendError =
31
+ | { kind: "not_found" | "gone" | "content_type_mismatch" | "overloaded" | "internal" }
32
+ | { kind: "stream_seq"; expected: string; received: string }
33
+ | { kind: "closed"; lastOffset: bigint }
34
+ | { kind: "producer_stale_epoch"; producerEpoch: number }
35
+ | { kind: "producer_gap"; expected: number; received: number }
36
+ | { kind: "producer_epoch_seq" };
37
+
38
+ export type AppendResult = Result<AppendSuccess, AppendError>;
39
+
40
+ type AppendTask = {
41
+ stream: string;
42
+ baseAppendMs: bigint;
43
+ rows: AppendRow[];
44
+ contentType: string | null;
45
+ streamSeq: string | null;
46
+ producer: ProducerInfo | null;
47
+ close: boolean;
48
+ reservedBytes: number;
49
+ enqueuedAtMs?: number;
50
+ resolve: (r: AppendResult) => void;
51
+ };
52
+
53
+ export class IngestQueue {
54
+ private readonly cfg: Config;
55
+ private readonly db: SqliteDurableStore;
56
+ private readonly stats?: StatsCollector;
57
+ private readonly gate?: BackpressureGate;
58
+ private readonly memory?: MemoryGuard;
59
+ private readonly metrics?: Metrics;
60
+ private readonly q: AppendTask[] = [];
61
+ private timer: any | null = null;
62
+ private scheduled = false;
63
+ private queuedBytes = 0;
64
+ private lastBacklogWarnMs = 0;
65
+
66
+ // Prepared statements local to the ingestor.
67
+ private readonly stmts: {
68
+ getStream: any;
69
+ insertWal: any;
70
+ updateStreamAppend: any;
71
+ updateStreamCloseOnly: any;
72
+ getProducerState: any;
73
+ upsertProducerState: any;
74
+ };
75
+
76
+ constructor(cfg: Config, db: SqliteDurableStore, stats?: StatsCollector, gate?: BackpressureGate, memory?: MemoryGuard, metrics?: Metrics) {
77
+ this.cfg = cfg;
78
+ this.db = db;
79
+ this.stats = stats;
80
+ this.gate = gate;
81
+ this.memory = memory;
82
+ this.metrics = metrics;
83
+
84
+ this.stmts = {
85
+ getStream: this.db.db.query(
86
+ `SELECT stream, epoch, next_offset, last_append_ms, expires_at_ms, stream_flags,
87
+ content_type, stream_seq, closed, closed_producer_id, closed_producer_epoch, closed_producer_seq
88
+ FROM streams WHERE stream=? LIMIT 1;`
89
+ ),
90
+ insertWal: this.db.db.query(
91
+ `INSERT INTO wal(stream, offset, ts_ms, payload, payload_len, routing_key, content_type, flags)
92
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?);`
93
+ ),
94
+ updateStreamAppend: this.db.db.query(
95
+ `UPDATE streams
96
+ SET next_offset=?, updated_at_ms=?, last_append_ms=?,
97
+ pending_rows=pending_rows+?, pending_bytes=pending_bytes+?,
98
+ wal_rows=wal_rows+?, wal_bytes=wal_bytes+?,
99
+ stream_seq=?,
100
+ closed=CASE WHEN ? THEN 1 ELSE closed END,
101
+ closed_producer_id=CASE WHEN ? THEN ? ELSE closed_producer_id END,
102
+ closed_producer_epoch=CASE WHEN ? THEN ? ELSE closed_producer_epoch END,
103
+ closed_producer_seq=CASE WHEN ? THEN ? ELSE closed_producer_seq END
104
+ WHERE stream=? AND (stream_flags & ?) = 0;`
105
+ ),
106
+ updateStreamCloseOnly: this.db.db.query(
107
+ `UPDATE streams
108
+ SET closed=1,
109
+ closed_producer_id=?,
110
+ closed_producer_epoch=?,
111
+ closed_producer_seq=?,
112
+ updated_at_ms=?,
113
+ stream_seq=?
114
+ WHERE stream=? AND (stream_flags & ?) = 0;`
115
+ ),
116
+ getProducerState: this.db.db.query(
117
+ `SELECT epoch, last_seq FROM producer_state WHERE stream=? AND producer_id=? LIMIT 1;`
118
+ ),
119
+ upsertProducerState: this.db.db.query(
120
+ `INSERT INTO producer_state(stream, producer_id, epoch, last_seq, updated_at_ms)
121
+ VALUES(?, ?, ?, ?, ?)
122
+ ON CONFLICT(stream, producer_id) DO UPDATE SET
123
+ epoch=excluded.epoch,
124
+ last_seq=excluded.last_seq,
125
+ updated_at_ms=excluded.updated_at_ms;`
126
+ ),
127
+ };
128
+
129
+ this.timer = setInterval(() => {
130
+ void this.flush();
131
+ }, this.cfg.ingestFlushIntervalMs);
132
+ }
133
+
134
+ stop(): void {
135
+ if (this.timer) clearInterval(this.timer);
136
+ this.timer = null;
137
+ }
138
+
139
+ /**
140
+ * Enqueue an append. This returns after the batch containing it has committed.
141
+ */
142
+ append(args: {
143
+ stream: string;
144
+ baseAppendMs: bigint;
145
+ rows: AppendRow[];
146
+ contentType: string | null;
147
+ streamSeq?: string | null;
148
+ producer?: ProducerInfo | null;
149
+ close?: boolean;
150
+ }, opts?: { bypassBackpressure?: boolean; priority?: "high" | "normal" }): Promise<AppendResult> {
151
+ const bytes = args.rows.reduce((acc, r) => acc + r.payload.byteLength, 0);
152
+ if (this.memory && !this.memory.shouldAllow()) {
153
+ this.memory.maybeGc("memory limit");
154
+ if (!opts?.bypassBackpressure) {
155
+ this.memory.maybeHeapSnapshot("memory limit");
156
+ if (this.metrics) this.metrics.record("tieredstore.backpressure.over_limit", 1, "count", { reason: "memory" });
157
+ return Promise.resolve(Result.err({ kind: "overloaded" }));
158
+ }
159
+ }
160
+ if (!opts?.bypassBackpressure) {
161
+ if (this.q.length >= this.cfg.ingestMaxQueueRequests || this.queuedBytes + bytes > this.cfg.ingestMaxQueueBytes) {
162
+ if (this.metrics) this.metrics.record("tieredstore.backpressure.over_limit", 1, "count", { reason: "queue" });
163
+ return Promise.resolve(Result.err({ kind: "overloaded" }));
164
+ }
165
+ if (this.gate && !this.gate.reserve(bytes)) {
166
+ if (this.metrics) this.metrics.record("tieredstore.backpressure.over_limit", 1, "count", { reason: "backlog" });
167
+ this.warnBacklog();
168
+ return Promise.resolve(Result.err({ kind: "overloaded" }));
169
+ }
170
+ }
171
+ this.queuedBytes += bytes;
172
+ return new Promise((resolve) => {
173
+ const task: AppendTask = {
174
+ stream: args.stream,
175
+ baseAppendMs: args.baseAppendMs,
176
+ rows: args.rows,
177
+ contentType: args.contentType ?? null,
178
+ streamSeq: args.streamSeq ?? null,
179
+ producer: args.producer ?? null,
180
+ close: args.close ?? false,
181
+ reservedBytes: opts?.bypassBackpressure ? 0 : bytes,
182
+ enqueuedAtMs: this.stats ? Date.now() : undefined,
183
+ resolve,
184
+ };
185
+ if (opts?.priority === "high") this.q.unshift(task);
186
+ else this.q.push(task);
187
+ // Opportunistic flush if the queue gets large.
188
+ if (!this.scheduled && this.q.length >= this.cfg.ingestMaxBatchRequests) {
189
+ this.scheduled = true;
190
+ setTimeout(() => {
191
+ this.scheduled = false;
192
+ void this.flush();
193
+ }, 0);
194
+ }
195
+ });
196
+ }
197
+
198
+ appendInternal(args: {
199
+ stream: string;
200
+ baseAppendMs: bigint;
201
+ rows: AppendRow[];
202
+ contentType: string | null;
203
+ }): Promise<AppendResult> {
204
+ return this.append(args, { bypassBackpressure: true, priority: "high" });
205
+ }
206
+
207
+ getQueueStats(): { requests: number; bytes: number } {
208
+ return { requests: this.q.length, bytes: this.queuedBytes };
209
+ }
210
+
211
+ isQueueFull(): boolean {
212
+ return this.q.length >= this.cfg.ingestMaxQueueRequests || this.queuedBytes >= this.cfg.ingestMaxQueueBytes;
213
+ }
214
+
215
+ private warnBacklog(): void {
216
+ if (!this.gate) return;
217
+ const now = Date.now();
218
+ if (now - this.lastBacklogWarnMs < 10_000) return;
219
+ this.lastBacklogWarnMs = now;
220
+ const current = this.gate.getCurrentBytes();
221
+ const max = this.gate.getMaxBytes();
222
+ const msg =
223
+ `[backpressure] local backlog ${formatBytes(current)} exceeds limit ${formatBytes(max)}; rejecting appends (DS_LOCAL_BACKLOG_MAX_BYTES)`;
224
+ // eslint-disable-next-line no-console
225
+ console.warn(msg);
226
+ }
227
+
228
+ async flush(): Promise<void> {
229
+ if (this.q.length === 0) return;
230
+ const flushStartMs = Date.now();
231
+ let busyWaitMs = 0;
232
+
233
+ // Drain up to limits.
234
+ const batch: AppendTask[] = [];
235
+ let batchBytes = 0;
236
+ let batchReservedBytes = 0;
237
+ let drainCount = 0;
238
+ while (drainCount < this.q.length && batch.length < this.cfg.ingestMaxBatchRequests && batchBytes < this.cfg.ingestMaxBatchBytes) {
239
+ const t = this.q[drainCount]!;
240
+ batch.push(t);
241
+ drainCount += 1;
242
+ for (const r of t.rows) batchBytes += r.payload.byteLength;
243
+ batchReservedBytes += t.reservedBytes;
244
+ }
245
+ if (drainCount > 0) {
246
+ this.q.splice(0, drainCount);
247
+ }
248
+ this.queuedBytes = Math.max(0, this.queuedBytes - batchBytes);
249
+
250
+ // Compute queue wait/backpressure stats before executing the batch.
251
+ let bpOverMs = 0;
252
+ if (this.stats) {
253
+ const budgetMs = this.stats.getBackpressureBudgetMs();
254
+ const nowMs = Date.now();
255
+ for (const t of batch) {
256
+ if (t.enqueuedAtMs == null) continue;
257
+ const waitMs = Math.max(0, nowMs - t.enqueuedAtMs);
258
+ if (waitMs > budgetMs) {
259
+ bpOverMs += waitMs - budgetMs;
260
+ }
261
+ }
262
+ }
263
+
264
+ // Execute batch in a single SQLite transaction.
265
+ const nowMs = this.db.nowMs();
266
+ type StreamState = {
267
+ epoch: number;
268
+ nextOffset: bigint;
269
+ lastAppendMs: bigint;
270
+ expiresAtMs: bigint | null;
271
+ streamFlags: number;
272
+ contentType: string;
273
+ streamSeq: string | null;
274
+ closed: boolean;
275
+ closedProducerId: string | null;
276
+ closedProducerEpoch: number | null;
277
+ closedProducerSeq: number | null;
278
+ };
279
+ type ProducerState = { epoch: number; lastSeq: number };
280
+
281
+ let perStream = new Map<string, StreamState>();
282
+ let perProducer = new Map<string, ProducerState | null>();
283
+
284
+ let walBytesCommitted = 0;
285
+
286
+ // Compute results inside the transaction, but only resolve after commit.
287
+ let results: AppendResult[] = [];
288
+
289
+ const resetAttempt = () => {
290
+ perStream = new Map<string, StreamState>();
291
+ perProducer = new Map<string, ProducerState | null>();
292
+ walBytesCommitted = 0;
293
+ results = new Array(batch.length);
294
+ };
295
+
296
+ const tx = this.db.db.transaction(() => {
297
+ const loadStream = (stream: string): StreamState | null => {
298
+ const cached = perStream.get(stream);
299
+ if (cached) return cached;
300
+ const row = this.stmts.getStream.get(stream) as any;
301
+ if (!row || (Number(row.stream_flags) & STREAM_FLAG_DELETED) !== 0) return null;
302
+ const st: StreamState = {
303
+ epoch: Number(row.epoch),
304
+ nextOffset: BigInt(row.next_offset),
305
+ lastAppendMs: BigInt(row.last_append_ms),
306
+ expiresAtMs: row.expires_at_ms == null ? null : BigInt(row.expires_at_ms),
307
+ streamFlags: Number(row.stream_flags),
308
+ contentType: String(row.content_type),
309
+ streamSeq: row.stream_seq == null ? null : String(row.stream_seq),
310
+ closed: Number(row.closed) !== 0,
311
+ closedProducerId: row.closed_producer_id == null ? null : String(row.closed_producer_id),
312
+ closedProducerEpoch: row.closed_producer_epoch == null ? null : Number(row.closed_producer_epoch),
313
+ closedProducerSeq: row.closed_producer_seq == null ? null : Number(row.closed_producer_seq),
314
+ };
315
+ perStream.set(stream, st);
316
+ return st;
317
+ };
318
+
319
+ const loadProducerState = (stream: string, producerId: string): ProducerState | null => {
320
+ const key = `${stream}\u0000${producerId}`;
321
+ if (perProducer.has(key)) return perProducer.get(key)!;
322
+ const row = this.stmts.getProducerState.get(stream, producerId) as any;
323
+ const state = row ? { epoch: Number(row.epoch), lastSeq: Number(row.last_seq) } : null;
324
+ perProducer.set(key, state);
325
+ return state;
326
+ };
327
+
328
+ const checkProducer = (
329
+ task: AppendTask
330
+ ): Result<{ duplicate: boolean; update: boolean; epoch: number; seq: number }, AppendError> => {
331
+ const producer = task.producer!;
332
+ const key = `${task.stream}\u0000${producer.id}`;
333
+ const state = loadProducerState(task.stream, producer.id);
334
+ if (!state) {
335
+ if (producer.seq !== 0) {
336
+ return Result.err({ kind: "producer_epoch_seq" });
337
+ }
338
+ const next = { epoch: producer.epoch, lastSeq: producer.seq };
339
+ perProducer.set(key, next);
340
+ return Result.ok({ duplicate: false, update: true, epoch: producer.epoch, seq: producer.seq });
341
+ }
342
+ if (producer.epoch < state.epoch) {
343
+ return Result.err({ kind: "producer_stale_epoch", producerEpoch: state.epoch });
344
+ }
345
+ if (producer.epoch > state.epoch) {
346
+ if (producer.seq !== 0) {
347
+ return Result.err({ kind: "producer_epoch_seq" });
348
+ }
349
+ const next = { epoch: producer.epoch, lastSeq: producer.seq };
350
+ perProducer.set(key, next);
351
+ return Result.ok({ duplicate: false, update: true, epoch: producer.epoch, seq: producer.seq });
352
+ }
353
+ if (producer.seq <= state.lastSeq) {
354
+ return Result.ok({ duplicate: true, update: false, epoch: state.epoch, seq: state.lastSeq });
355
+ }
356
+ if (producer.seq === state.lastSeq + 1) {
357
+ const next = { epoch: state.epoch, lastSeq: producer.seq };
358
+ perProducer.set(key, next);
359
+ return Result.ok({ duplicate: false, update: true, epoch: state.epoch, seq: producer.seq });
360
+ }
361
+ return Result.err({ kind: "producer_gap", expected: state.lastSeq + 1, received: producer.seq });
362
+ };
363
+
364
+ const checkStreamSeq = (
365
+ task: AppendTask,
366
+ st: StreamState
367
+ ): Result<{ nextSeq: string | null }, AppendError> => {
368
+ if (task.streamSeq == null) return Result.ok({ nextSeq: st.streamSeq });
369
+ if (st.streamSeq != null && task.streamSeq <= st.streamSeq) {
370
+ return Result.err({
371
+ kind: "stream_seq",
372
+ expected: st.streamSeq,
373
+ received: task.streamSeq,
374
+ });
375
+ }
376
+ return Result.ok({ nextSeq: task.streamSeq });
377
+ };
378
+
379
+ for (let idx = 0; idx < batch.length; idx++) {
380
+ const task = batch[idx];
381
+ const st = loadStream(task.stream);
382
+ if (!st) {
383
+ results[idx] = Result.err({ kind: "not_found" });
384
+ continue;
385
+ }
386
+ if (st.expiresAtMs != null && nowMs > st.expiresAtMs) {
387
+ results[idx] = Result.err({ kind: "gone" });
388
+ continue;
389
+ }
390
+
391
+ const tailOffset = st.nextOffset - 1n;
392
+ const isCloseOnly = task.close && task.rows.length === 0;
393
+
394
+ if (st.closed) {
395
+ if (isCloseOnly) {
396
+ results[idx] = Result.ok({
397
+ lastOffset: tailOffset,
398
+ appendedRows: 0,
399
+ closed: true,
400
+ duplicate: true,
401
+ });
402
+ continue;
403
+ }
404
+ if (
405
+ task.producer &&
406
+ task.close &&
407
+ st.closedProducerId != null &&
408
+ st.closedProducerEpoch != null &&
409
+ st.closedProducerSeq != null &&
410
+ st.closedProducerId === task.producer.id &&
411
+ st.closedProducerEpoch === task.producer.epoch &&
412
+ st.closedProducerSeq === task.producer.seq
413
+ ) {
414
+ results[idx] = Result.ok({
415
+ lastOffset: tailOffset,
416
+ appendedRows: 0,
417
+ closed: true,
418
+ duplicate: true,
419
+ producer: { epoch: st.closedProducerEpoch, seq: st.closedProducerSeq },
420
+ });
421
+ continue;
422
+ }
423
+ results[idx] = Result.err({ kind: "closed", lastOffset: tailOffset });
424
+ continue;
425
+ }
426
+
427
+ if (isCloseOnly) {
428
+ let producerInfo: { epoch: number; seq: number } | undefined;
429
+ let duplicate = false;
430
+ if (task.producer) {
431
+ const prodCheck = checkProducer(task);
432
+ if (Result.isError(prodCheck)) {
433
+ results[idx] = Result.err(prodCheck.error);
434
+ continue;
435
+ }
436
+ duplicate = prodCheck.value.duplicate;
437
+ producerInfo = { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq };
438
+ if (prodCheck.value.update) {
439
+ this.stmts.upsertProducerState.run(
440
+ task.stream,
441
+ task.producer.id,
442
+ prodCheck.value.epoch,
443
+ prodCheck.value.seq,
444
+ nowMs
445
+ );
446
+ }
447
+ }
448
+ if (!duplicate) {
449
+ const seqCheck = checkStreamSeq(task, st);
450
+ if (Result.isError(seqCheck)) {
451
+ results[idx] = Result.err(seqCheck.error);
452
+ continue;
453
+ }
454
+ st.streamSeq = seqCheck.value.nextSeq;
455
+ const closedProducer = task.producer ?? null;
456
+ this.stmts.updateStreamCloseOnly.run(
457
+ closedProducer ? closedProducer.id : null,
458
+ closedProducer ? closedProducer.epoch : null,
459
+ closedProducer ? closedProducer.seq : null,
460
+ nowMs,
461
+ st.streamSeq,
462
+ task.stream,
463
+ STREAM_FLAG_DELETED
464
+ );
465
+ st.closed = true;
466
+ st.closedProducerId = closedProducer ? closedProducer.id : null;
467
+ st.closedProducerEpoch = closedProducer ? closedProducer.epoch : null;
468
+ st.closedProducerSeq = closedProducer ? closedProducer.seq : null;
469
+ }
470
+ results[idx] = Result.ok({
471
+ lastOffset: tailOffset,
472
+ appendedRows: 0,
473
+ closed: st.closed,
474
+ duplicate,
475
+ producer: producerInfo,
476
+ });
477
+ continue;
478
+ }
479
+
480
+ if (!task.contentType || task.contentType !== st.contentType) {
481
+ results[idx] = Result.err({ kind: "content_type_mismatch" });
482
+ continue;
483
+ }
484
+
485
+ let producerInfo: { epoch: number; seq: number } | undefined;
486
+ if (task.producer) {
487
+ const prodCheck = checkProducer(task);
488
+ if (Result.isError(prodCheck)) {
489
+ results[idx] = Result.err(prodCheck.error);
490
+ continue;
491
+ }
492
+ if (prodCheck.value.duplicate) {
493
+ results[idx] = Result.ok({
494
+ lastOffset: tailOffset,
495
+ appendedRows: 0,
496
+ closed: false,
497
+ duplicate: true,
498
+ producer: { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq },
499
+ });
500
+ continue;
501
+ }
502
+ producerInfo = { epoch: prodCheck.value.epoch, seq: prodCheck.value.seq };
503
+ if (prodCheck.value.update) {
504
+ this.stmts.upsertProducerState.run(
505
+ task.stream,
506
+ task.producer.id,
507
+ prodCheck.value.epoch,
508
+ prodCheck.value.seq,
509
+ nowMs
510
+ );
511
+ }
512
+ }
513
+
514
+ const seqCheck = checkStreamSeq(task, st);
515
+ if (Result.isError(seqCheck)) {
516
+ results[idx] = Result.err(seqCheck.error);
517
+ continue;
518
+ }
519
+ st.streamSeq = seqCheck.value.nextSeq;
520
+
521
+ // Clamp timestamps to be monotonic per stream (ms resolution).
522
+ let appendMs = task.baseAppendMs;
523
+ if (appendMs <= st.lastAppendMs) appendMs = st.lastAppendMs + 1n;
524
+
525
+ let offset = st.nextOffset;
526
+ let totalBytes = 0n;
527
+ for (let i = 0; i < task.rows.length; i++) {
528
+ const r = task.rows[i];
529
+ // Use a constant per-request timestamp for all rows. Offsets already
530
+ // provide ordering, and per-row +1ms stamping breaks age-based WAL
531
+ // retention under high fanout (e.g. touch streams) by pushing ts_ms
532
+ // far into the future.
533
+ const rowAppendMs = appendMs;
534
+ const payloadLen = r.payload.byteLength;
535
+ totalBytes += BigInt(payloadLen);
536
+ this.stmts.insertWal.run(task.stream, offset, rowAppendMs, r.payload, payloadLen, r.routingKey, r.contentType, 0);
537
+ offset += 1n;
538
+ }
539
+ const lastOffset = offset - 1n;
540
+ st.nextOffset = offset;
541
+ st.lastAppendMs = appendMs;
542
+ if (task.close) {
543
+ st.closed = true;
544
+ if (task.producer) {
545
+ st.closedProducerId = task.producer.id;
546
+ st.closedProducerEpoch = task.producer.epoch;
547
+ st.closedProducerSeq = task.producer.seq;
548
+ } else {
549
+ st.closedProducerId = null;
550
+ st.closedProducerEpoch = null;
551
+ st.closedProducerSeq = null;
552
+ }
553
+ }
554
+
555
+ const closedProducer = task.close && task.producer ? task.producer : null;
556
+ const closeFlag = task.close ? 1 : 0;
557
+ this.stmts.updateStreamAppend.run(
558
+ st.nextOffset,
559
+ nowMs,
560
+ st.lastAppendMs,
561
+ BigInt(task.rows.length),
562
+ totalBytes,
563
+ BigInt(task.rows.length),
564
+ totalBytes,
565
+ st.streamSeq,
566
+ closeFlag,
567
+ closeFlag,
568
+ closedProducer ? closedProducer.id : null,
569
+ closeFlag,
570
+ closedProducer ? closedProducer.epoch : null,
571
+ closeFlag,
572
+ closedProducer ? closedProducer.seq : null,
573
+ task.stream,
574
+ STREAM_FLAG_DELETED
575
+ );
576
+ walBytesCommitted += Number(totalBytes);
577
+
578
+ results[idx] = Result.ok({
579
+ lastOffset,
580
+ appendedRows: task.rows.length,
581
+ closed: task.close,
582
+ duplicate: false,
583
+ producer: producerInfo,
584
+ });
585
+ }
586
+ });
587
+
588
+ const isSqliteBusy = (e: any): boolean => {
589
+ const code = String(e?.code ?? "");
590
+ const errno = Number(e?.errno ?? -1);
591
+ return code === "SQLITE_BUSY" || code === "SQLITE_BUSY_SNAPSHOT" || errno === 5 || errno === 517;
592
+ };
593
+
594
+ const sleep = (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms));
595
+
596
+ try {
597
+ const maxBusyMs = Math.max(0, this.cfg.ingestBusyTimeoutMs);
598
+ const startMs = Date.now();
599
+ let attempt = 0;
600
+ while (true) {
601
+ resetAttempt();
602
+ try {
603
+ tx();
604
+ break;
605
+ } catch (e) {
606
+ if (!isSqliteBusy(e)) throw e;
607
+ if (maxBusyMs <= 0) throw e;
608
+ const elapsed = Date.now() - startMs;
609
+ if (elapsed >= maxBusyMs) throw e;
610
+ const delay = Math.min(200, 5 * 2 ** attempt);
611
+ attempt += 1;
612
+ busyWaitMs += delay;
613
+ await sleep(delay);
614
+ }
615
+ }
616
+ if (this.gate) {
617
+ const reservedCommitted = Math.min(batchReservedBytes, walBytesCommitted);
618
+ this.gate.commit(walBytesCommitted, reservedCommitted);
619
+ const extra = batchReservedBytes - walBytesCommitted;
620
+ if (extra > 0) this.gate.release(extra);
621
+ }
622
+ if (this.stats && walBytesCommitted > 0) this.stats.recordWalCommitBytes(walBytesCommitted);
623
+ if (this.stats && bpOverMs > 0) this.stats.recordBackpressureOverMs(bpOverMs);
624
+ for (let i = 0; i < batch.length; i++) batch[i].resolve(results[i] ?? Result.err({ kind: "internal" }));
625
+ const elapsedNs = (Date.now() - flushStartMs) * 1_000_000;
626
+ if (this.metrics) {
627
+ this.metrics.record("tieredstore.ingest.flush.latency", elapsedNs, "ns");
628
+ if (busyWaitMs > 0) this.metrics.record("tieredstore.ingest.sqlite_busy.wait", busyWaitMs * 1_000_000, "ns");
629
+ }
630
+ } catch (e) {
631
+ // If the whole transaction failed, all tasks are treated as internal errors.
632
+ // eslint-disable-next-line no-console
633
+ console.error("ingest tx failed", e);
634
+ if (this.gate && batchReservedBytes > 0) this.gate.release(batchReservedBytes);
635
+ for (const t of batch) t.resolve(Result.err({ kind: "internal" }));
636
+ const elapsedNs = (Date.now() - flushStartMs) * 1_000_000;
637
+ if (this.metrics) {
638
+ this.metrics.record("tieredstore.ingest.flush.latency", elapsedNs, "ns");
639
+ if (busyWaitMs > 0) this.metrics.record("tieredstore.ingest.sqlite_busy.wait", busyWaitMs * 1_000_000, "ns");
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ function formatBytes(bytes: number): string {
646
+ const units = ["b", "kb", "mb", "gb"];
647
+ let value = Math.max(0, bytes);
648
+ let idx = 0;
649
+ while (value >= 1024 && idx < units.length - 1) {
650
+ value /= 1024;
651
+ idx += 1;
652
+ }
653
+ const digits = idx === 0 ? 0 : 1;
654
+ return `${value.toFixed(digits)}${units[idx]}`;
655
+ }