@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.
- 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 +1983 -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 +1440 -0
- package/src/db/schema.ts +619 -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 +459 -0
- package/src/touch/live_keys.ts +118 -0
- package/src/touch/live_metrics.ts +858 -0
- package/src/touch/live_templates.ts +619 -0
- package/src/touch/manager.ts +1341 -0
- package/src/touch/naming.ts +13 -0
- package/src/touch/routing_key_notifier.ts +275 -0
- package/src/touch/spec.ts +526 -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 +58 -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
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
|
+
}
|