@rindle/api-server 0.7.12 → 0.9.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/README.md +54 -0
- package/dist/index.d.ts +71 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +130 -2
- package/dist/index.js.map +1 -1
- package/dist/streams.d.ts +373 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +1047 -0
- package/dist/streams.js.map +1 -0
- package/package.json +8 -6
- package/src/index.ts +248 -11
- package/src/streams.ts +1341 -0
package/dist/streams.js
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
// LM stream checkpointing — the two-plane response path (designs/LM-STREAM-CHECKPOINT-DESIGN.md).
|
|
2
|
+
//
|
|
3
|
+
// A model response arrives as hundreds of tiny deltas per second. Every one wants to be on a screen
|
|
4
|
+
// immediately; none wants to be a durable write. So the response runs on TWO planes sharing ONE
|
|
5
|
+
// monotone coordinate (`seq`, in UTF-16 code units of the response text):
|
|
6
|
+
//
|
|
7
|
+
// - the LIVE plane (this module): process-local, volatile, every delta, straight to subscribers;
|
|
8
|
+
// - the DURABLE plane: the app's own tables, advanced at coarse boundaries — N chars, T ms, an
|
|
9
|
+
// explicit `flush()`, and always at close.
|
|
10
|
+
//
|
|
11
|
+
// The contract both planes stand on (§0):
|
|
12
|
+
//
|
|
13
|
+
// S (splice) durableText(f) ++ concat(frames delivered from f) == producedText, for every join
|
|
14
|
+
// offset f and every join time.
|
|
15
|
+
// P (prefix) durableText is always a PREFIX of producedText, and durableSeq is monotone.
|
|
16
|
+
//
|
|
17
|
+
// P is what makes S mechanical — two prefixes of one string merge by taking the longer (see
|
|
18
|
+
// {@link spliceStreamText}). Everything else here is bookkeeping in service of those two.
|
|
19
|
+
//
|
|
20
|
+
// TWO THINGS THIS DELIBERATELY IS NOT (both were wrong in the first cut of this module):
|
|
21
|
+
//
|
|
22
|
+
// 1. Checkpoints are SYSTEM WRITES, not mutation envelopes. `lmid` is client-authored and exists
|
|
23
|
+
// to release a client's optimistic rebase point; a server-authored checkpoint has no
|
|
24
|
+
// prediction to release, and CDC → IVM fanout needs no envelope at all. Checkpoints therefore
|
|
25
|
+
// go through `outsideSql` with no clientID/mid — the same rule the realtime lifecycle writes
|
|
26
|
+
// follow ("a system write must never advance an lmid").
|
|
27
|
+
// 2. A checkpoint INSERTS A CHUNK ROW; it never appends to a growing column. A normalized `edit`
|
|
28
|
+
// carries BOTH the old and the new full row, so advancing a `body` column would re-stream the
|
|
29
|
+
// whole message twice per checkpoint — O(n²) wire to deliver n characters. One row per chunk
|
|
30
|
+
// makes each checkpoint a single `add` carrying only its own slice.
|
|
31
|
+
//
|
|
32
|
+
// Durability is never overclaimed (§0, the RINDLE-REALTIME §8.1 "ack honesty" rule): a `chunk` frame
|
|
33
|
+
// says only "produced", a `durable` frame says "committed", an `end` frame says "sealed". Losing
|
|
34
|
+
// this plane entirely costs latency and the un-checkpointed tail — never consistency: a client with
|
|
35
|
+
// no live stream still sees the message grow through IVM at checkpoint granularity, which is why
|
|
36
|
+
// `absent`/`stale` are ordinary answers rather than errors.
|
|
37
|
+
// Type-only (erased at runtime) — no import cycle with ./index.ts.
|
|
38
|
+
import { STREAM_STATUS_STREAMING, frameResumePoint } from "@rindle/client";
|
|
39
|
+
// ------------------------------------------------------------------------------- the wire
|
|
40
|
+
//
|
|
41
|
+
// The frame shapes and the two pure reassembly functions live in `@rindle/client` — BOTH tiers need
|
|
42
|
+
// them, and a browser must never pull this package to read a stream. Re-exported here so the
|
|
43
|
+
// server-side import path is unchanged.
|
|
44
|
+
export { STREAM_STATUS_STREAMING, assembleDurableText, frameResumePoint, spliceStreamText, } from "@rindle/client";
|
|
45
|
+
// ------------------------------------------------------------------------------- defaults
|
|
46
|
+
const DEFAULT_CHECKPOINT_CHARS = 512;
|
|
47
|
+
const DEFAULT_CHECKPOINT_INTERVAL_MS = 750;
|
|
48
|
+
const DEFAULT_CHECKPOINT_RETRIES = 3;
|
|
49
|
+
const DEFAULT_RETAIN_CHARS = 64 * 1024;
|
|
50
|
+
const DEFAULT_LINGER_MS = 30_000;
|
|
51
|
+
const DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
52
|
+
/** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
|
|
53
|
+
* authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
|
|
54
|
+
const RETRY_BACKOFF_MS = 50;
|
|
55
|
+
/** A SCHEDULING timer — the checkpoint cadence, the linger window, the SSE keep-alive. It never
|
|
56
|
+
* holds the process open: none of them is work in flight, and a stream plane must not keep a CLI
|
|
57
|
+
* alive. `unref` is Node-only — Workers' `setTimeout` has no such method, hence the guard. */
|
|
58
|
+
function timer(fn, ms) {
|
|
59
|
+
const t = setTimeout(fn, ms);
|
|
60
|
+
t.unref?.();
|
|
61
|
+
return t;
|
|
62
|
+
}
|
|
63
|
+
/** The retry backoff, and deliberately NOT unref'd: a checkpoint mid-retry IS work in flight, and a
|
|
64
|
+
* process that exits during it drops text the caller is still awaiting. */
|
|
65
|
+
function delay(ms) {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
function errText(err) {
|
|
69
|
+
return String(err?.message ?? err);
|
|
70
|
+
}
|
|
71
|
+
/** A per-plane stand-in for {@link RindleStreamOptions.hostId} when only the open CAS — not
|
|
72
|
+
* subscribe routing — needs a token. */
|
|
73
|
+
function randomOpenToken() {
|
|
74
|
+
const c = globalThis.crypto;
|
|
75
|
+
return c?.randomUUID?.() ?? `open-${Math.random().toString(36).slice(2)}`;
|
|
76
|
+
}
|
|
77
|
+
export function resolveStreamColumns(columns) {
|
|
78
|
+
return {
|
|
79
|
+
key: columns?.key ?? "id",
|
|
80
|
+
body: columns?.body ?? "body",
|
|
81
|
+
status: columns?.status ?? "status",
|
|
82
|
+
seq: columns?.seq ?? "seq",
|
|
83
|
+
...(columns?.cancel !== undefined ? { cancel: columns.cancel } : {}),
|
|
84
|
+
...(columns?.error !== undefined ? { error: columns.error } : {}),
|
|
85
|
+
...(columns?.host !== undefined ? { host: columns.host } : {}),
|
|
86
|
+
chunkKey: columns?.chunkKey ?? "id",
|
|
87
|
+
chunkStream: columns?.chunkStream ?? "streamId",
|
|
88
|
+
chunkSeq: columns?.chunkSeq ?? "seq",
|
|
89
|
+
chunkText: columns?.chunkText ?? "text",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Quote an identifier for both dialects (`"x"` is standard in SQLite and Postgres alike). An
|
|
93
|
+
* embedded quote is refused rather than escaped: a mapping is app configuration, and a name that
|
|
94
|
+
* needs escaping is a mistake worth failing loudly on. */
|
|
95
|
+
function q(identifier) {
|
|
96
|
+
if (identifier.includes('"'))
|
|
97
|
+
throw new TypeError(`invalid stream column/table name: ${JSON.stringify(identifier)}`);
|
|
98
|
+
return `"${identifier}"`;
|
|
99
|
+
}
|
|
100
|
+
/** The chunk row's deterministic id: a replayed checkpoint collides with itself and is absorbed by
|
|
101
|
+
* `ON CONFLICT DO NOTHING` — idempotency without an envelope or a dedup ledger (§3.3). */
|
|
102
|
+
export function streamChunkId(streamId, seq) {
|
|
103
|
+
return `${streamId}:${seq}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The chunk table's DDL, for the app's migration. The app owns the message table (this only states
|
|
107
|
+
* the three columns the plane needs on it); the chunk table is entirely protocol-shaped, so it is
|
|
108
|
+
* generated rather than hand-written.
|
|
109
|
+
*/
|
|
110
|
+
export function streamChunkTableDdl(tables, dialect) {
|
|
111
|
+
const c = resolveStreamColumns(tables.columns);
|
|
112
|
+
const text = dialect.name === "postgres" ? "text" : "TEXT";
|
|
113
|
+
const int = dialect.name === "postgres" ? "bigint" : "INTEGER";
|
|
114
|
+
return [
|
|
115
|
+
`CREATE TABLE IF NOT EXISTS ${q(tables.chunks)} (` +
|
|
116
|
+
`${q(c.chunkKey)} ${text} PRIMARY KEY, ` +
|
|
117
|
+
`${q(c.chunkStream)} ${text} NOT NULL, ` +
|
|
118
|
+
`${q(c.chunkSeq)} ${int} NOT NULL, ` +
|
|
119
|
+
`${q(c.chunkText)} ${text} NOT NULL)`,
|
|
120
|
+
// The read path is always "this message's chunks, in order" — and compaction deletes by stream.
|
|
121
|
+
`CREATE INDEX IF NOT EXISTS ${q(`${tables.chunks}_stream_seq`)} ` +
|
|
122
|
+
`ON ${q(tables.chunks)} (${q(c.chunkStream)}, ${q(c.chunkSeq)})`,
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
/** The plane's SQL, one place, both dialects. */
|
|
126
|
+
class MappedTableSql {
|
|
127
|
+
tables;
|
|
128
|
+
cols;
|
|
129
|
+
dialect;
|
|
130
|
+
constructor(tables, dialect) {
|
|
131
|
+
this.tables = tables;
|
|
132
|
+
this.cols = resolveStreamColumns(tables.columns);
|
|
133
|
+
this.dialect = dialect;
|
|
134
|
+
}
|
|
135
|
+
p(i) {
|
|
136
|
+
return this.dialect.placeholder(i);
|
|
137
|
+
}
|
|
138
|
+
/** The open probe: the row must exist and be empty. A generation is never a resume — regenerating
|
|
139
|
+
* is a NEW message id — so an already-advanced row is a bug worth refusing loudly. */
|
|
140
|
+
probeOpen() {
|
|
141
|
+
const { key, seq, status } = this.cols;
|
|
142
|
+
return `SELECT ${q(seq)} AS seq, ${q(status)} AS status FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
143
|
+
}
|
|
144
|
+
/** The open write. With a `host` column mapped this is the CAS half of the single-flight guard:
|
|
145
|
+
* set the status AND this producer's token only if no producer holds the row, verified by the
|
|
146
|
+
* read-back in {@link probeHost}. Without one it is the plain flip the probe already vetted. */
|
|
147
|
+
markStreaming(streamId, hostToken) {
|
|
148
|
+
const { key, status, host } = this.cols;
|
|
149
|
+
if (host !== undefined) {
|
|
150
|
+
return {
|
|
151
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)}, ${q(host)} = ${this.p(2)} ` +
|
|
152
|
+
`WHERE ${q(key)} = ${this.p(3)} AND ${q(status)} <> ${this.p(4)}`,
|
|
153
|
+
params: [STREAM_STATUS_STREAMING, hostToken, streamId, STREAM_STATUS_STREAMING],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)} WHERE ${q(key)} = ${this.p(2)}`,
|
|
158
|
+
params: [STREAM_STATUS_STREAMING, streamId],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** The read-back half of the open CAS, or `undefined` when no `host` column is mapped. */
|
|
162
|
+
probeHost() {
|
|
163
|
+
const { host, key } = this.cols;
|
|
164
|
+
if (host === undefined)
|
|
165
|
+
return undefined;
|
|
166
|
+
return `SELECT ${q(host)} AS host FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
167
|
+
}
|
|
168
|
+
/** ONE transaction: insert the slice, then CAS the message's durable length. BOTH statements are
|
|
169
|
+
* gated on the same `seq = :from` predicate: the CAS refuses to apply twice or out of order, and
|
|
170
|
+
* the guarded insert makes a STALE RE-COVER a no-op — after a committed-but-ack-lost append, the
|
|
171
|
+
* next slice's `from` lags the row, and an unguarded insert would land an OVERLAPPING chunk row
|
|
172
|
+
* (a different deterministic id, so `ON CONFLICT` alone cannot absorb it) and corrupt the
|
|
173
|
+
* assembled text until compaction. The `ON CONFLICT` clause stays as the belt under that
|
|
174
|
+
* guard's braces. Which case actually happened is decided by the read-back ({@link probeState}),
|
|
175
|
+
* never assumed (§3.2/§3.3). */
|
|
176
|
+
append(streamId, from, seq, text) {
|
|
177
|
+
const c = this.cols;
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
sql: `INSERT INTO ${q(this.tables.chunks)} ` +
|
|
181
|
+
`(${q(c.chunkKey)}, ${q(c.chunkStream)}, ${q(c.chunkSeq)}, ${q(c.chunkText)}) ` +
|
|
182
|
+
`SELECT ${this.p(1)}, ${this.p(2)}, ${this.p(3)}, ${this.p(4)} ` +
|
|
183
|
+
`WHERE EXISTS (SELECT 1 FROM ${q(this.tables.message)} ` +
|
|
184
|
+
`WHERE ${q(c.key)} = ${this.p(5)} AND ${q(c.seq)} = ${this.p(6)}) ` +
|
|
185
|
+
`ON CONFLICT DO NOTHING`,
|
|
186
|
+
params: [streamChunkId(streamId, seq), streamId, seq, text, streamId, from],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${q(c.seq)} = ${this.p(1)} ` +
|
|
190
|
+
`WHERE ${q(c.key)} = ${this.p(2)} AND ${q(c.seq)} = ${this.p(3)}`,
|
|
191
|
+
params: [seq, streamId, from],
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
/** Compaction (§3.4): write the whole body, seal the status, drop every chunk — atomically. Also
|
|
196
|
+
* the repair path: whatever the chunks did or did not capture, the body ends up correct. */
|
|
197
|
+
compact(streamId, body, status, error) {
|
|
198
|
+
const c = this.cols;
|
|
199
|
+
const sets = [`${q(c.body)} = ${this.p(1)}`, `${q(c.seq)} = ${this.p(2)}`, `${q(c.status)} = ${this.p(3)}`];
|
|
200
|
+
const params = [body, body.length, status];
|
|
201
|
+
if (c.error !== undefined) {
|
|
202
|
+
sets.push(`${q(c.error)} = ${this.p(params.length + 1)}`);
|
|
203
|
+
params.push(error ?? null);
|
|
204
|
+
}
|
|
205
|
+
params.push(streamId);
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${sets.join(", ")} WHERE ${q(c.key)} = ${this.p(params.length)}`,
|
|
209
|
+
params,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
sql: `DELETE FROM ${q(this.tables.chunks)} WHERE ${q(c.chunkStream)} = ${this.p(1)}`,
|
|
213
|
+
params: [streamId],
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
get hasCancel() {
|
|
218
|
+
return this.cols.cancel !== undefined;
|
|
219
|
+
}
|
|
220
|
+
/** The post-append read-back: the row's authoritative length — what CONFIRMS an append applied
|
|
221
|
+
* (and absorbs a committed-but-ack-lost one) — plus the cancel flag when mapped (§6). */
|
|
222
|
+
probeState() {
|
|
223
|
+
const { cancel, key, seq } = this.cols;
|
|
224
|
+
const picked = cancel === undefined ? `${q(seq)} AS seq` : `${q(seq)} AS seq, ${q(cancel)} AS cancel`;
|
|
225
|
+
return `SELECT ${picked} FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// ------------------------------------------------------------------------------- subscribers
|
|
229
|
+
/** One attached reader. Frames queue; a reader that stops draining is bounded and then DROPPED with
|
|
230
|
+
* `stale` rather than allowed to pin the producer's memory — it resolves that by rejoining. */
|
|
231
|
+
class Subscriber {
|
|
232
|
+
queue = [];
|
|
233
|
+
waiter;
|
|
234
|
+
done = false;
|
|
235
|
+
cap;
|
|
236
|
+
onDetach;
|
|
237
|
+
constructor(cap, onDetach) {
|
|
238
|
+
this.cap = cap;
|
|
239
|
+
this.onDetach = onDetach;
|
|
240
|
+
}
|
|
241
|
+
/** @returns false when the queue overflowed (caller drops this subscriber). */
|
|
242
|
+
offer(frame) {
|
|
243
|
+
if (this.done)
|
|
244
|
+
return true;
|
|
245
|
+
if (this.waiter) {
|
|
246
|
+
const w = this.waiter;
|
|
247
|
+
this.waiter = undefined;
|
|
248
|
+
w({ value: frame, done: false });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (this.queue.length >= this.cap)
|
|
252
|
+
return false;
|
|
253
|
+
this.queue.push(frame);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
/** Deliver a terminal frame (bypassing the cap — it is the last one) and end the iterator. */
|
|
257
|
+
finish(frame) {
|
|
258
|
+
if (this.done)
|
|
259
|
+
return;
|
|
260
|
+
if (this.waiter) {
|
|
261
|
+
const w = this.waiter;
|
|
262
|
+
this.waiter = undefined;
|
|
263
|
+
this.done = true;
|
|
264
|
+
w({ value: frame, done: false });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.queue.push(frame);
|
|
268
|
+
this.done = true;
|
|
269
|
+
}
|
|
270
|
+
close() {
|
|
271
|
+
this.done = true;
|
|
272
|
+
this.queue.length = 0;
|
|
273
|
+
if (this.waiter) {
|
|
274
|
+
const w = this.waiter;
|
|
275
|
+
this.waiter = undefined;
|
|
276
|
+
w({ value: undefined, done: true });
|
|
277
|
+
}
|
|
278
|
+
this.onDetach(this);
|
|
279
|
+
}
|
|
280
|
+
frames() {
|
|
281
|
+
const self = this;
|
|
282
|
+
return {
|
|
283
|
+
[Symbol.asyncIterator]() {
|
|
284
|
+
return {
|
|
285
|
+
next() {
|
|
286
|
+
const queued = self.queue.shift();
|
|
287
|
+
if (queued !== undefined)
|
|
288
|
+
return Promise.resolve({ value: queued, done: false });
|
|
289
|
+
if (self.done) {
|
|
290
|
+
self.onDetach(self);
|
|
291
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
292
|
+
}
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
self.waiter = resolve;
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
return() {
|
|
298
|
+
self.close();
|
|
299
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
class LiveStream {
|
|
307
|
+
/** Retained text; `buf[0]` sits at offset {@link bufFrom}. */
|
|
308
|
+
buf = "";
|
|
309
|
+
bufFrom = 0;
|
|
310
|
+
seq = 0;
|
|
311
|
+
durableSeq = 0;
|
|
312
|
+
ended = false;
|
|
313
|
+
cancelled = false;
|
|
314
|
+
streamId;
|
|
315
|
+
user;
|
|
316
|
+
meta;
|
|
317
|
+
plane;
|
|
318
|
+
subs = new Set();
|
|
319
|
+
sealing;
|
|
320
|
+
draining = false;
|
|
321
|
+
forced = false;
|
|
322
|
+
/** The last drain round left a slice uncommitted: wait out the interval instead of re-attempting
|
|
323
|
+
* immediately, so a down store costs a retry cadence and not a hot loop. */
|
|
324
|
+
stalled = false;
|
|
325
|
+
tick = null;
|
|
326
|
+
waiters = [];
|
|
327
|
+
sealed;
|
|
328
|
+
sealError;
|
|
329
|
+
constructor(streamId, user, meta, plane) {
|
|
330
|
+
this.streamId = streamId;
|
|
331
|
+
this.user = user;
|
|
332
|
+
this.meta = meta;
|
|
333
|
+
this.plane = plane;
|
|
334
|
+
}
|
|
335
|
+
// ---- producer side
|
|
336
|
+
push(text) {
|
|
337
|
+
if (this.sealing || this.ended)
|
|
338
|
+
throw new Error(`stream ${this.streamId} is closed`);
|
|
339
|
+
if (text.length === 0)
|
|
340
|
+
return;
|
|
341
|
+
const from = this.seq;
|
|
342
|
+
this.buf += text;
|
|
343
|
+
this.seq += text.length;
|
|
344
|
+
this.fanout({ type: "chunk", from, seq: this.seq, text });
|
|
345
|
+
if (this.seq - this.durableSeq >= this.plane.chars)
|
|
346
|
+
this.kick(true);
|
|
347
|
+
else
|
|
348
|
+
this.arm();
|
|
349
|
+
}
|
|
350
|
+
flush() {
|
|
351
|
+
const at = this.seq;
|
|
352
|
+
if (this.durableSeq >= at)
|
|
353
|
+
return Promise.resolve(this.durableSeq);
|
|
354
|
+
// After a failed seal the shortfall is PERMANENT (the stream ended; nothing will retry it), so a
|
|
355
|
+
// late flush rejects honestly instead of quietly re-running the seal.
|
|
356
|
+
if (this.ended) {
|
|
357
|
+
return Promise.reject(new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`));
|
|
358
|
+
}
|
|
359
|
+
const p = new Promise((resolve, reject) => {
|
|
360
|
+
this.waiters.push({ at, resolve, reject });
|
|
361
|
+
});
|
|
362
|
+
this.kick(true);
|
|
363
|
+
return p;
|
|
364
|
+
}
|
|
365
|
+
/** Seal the stream. Resolves once the terminal checkpoint has committed; rejects if it could not
|
|
366
|
+
* (the stream still ENDS — subscribers always get their `end` frame — the caller just learns the
|
|
367
|
+
* store is short of what was produced). */
|
|
368
|
+
seal(status, error) {
|
|
369
|
+
// Already sealed: hand back the SAME settled promise (already marked handled below), so a second
|
|
370
|
+
// `close()`/`fail()` can neither re-seal nor mint a stray rejection.
|
|
371
|
+
if (this.ended)
|
|
372
|
+
return this.sealed?.promise ?? Promise.resolve();
|
|
373
|
+
if (!this.sealing) {
|
|
374
|
+
this.sealing = { status, error };
|
|
375
|
+
let resolve;
|
|
376
|
+
let reject;
|
|
377
|
+
const promise = new Promise((res, rej) => {
|
|
378
|
+
resolve = res;
|
|
379
|
+
reject = rej;
|
|
380
|
+
});
|
|
381
|
+
// A `fail()` nobody awaited must not become an unhandled rejection at process level. This
|
|
382
|
+
// marks the promise handled WITHOUT consuming it — a caller that does await still sees the
|
|
383
|
+
// rejection, because `await` attaches its own handler to the same promise.
|
|
384
|
+
promise.catch(() => { });
|
|
385
|
+
this.sealed = { resolve, reject, promise };
|
|
386
|
+
this.disarm();
|
|
387
|
+
this.kick(true);
|
|
388
|
+
}
|
|
389
|
+
return this.sealed.promise;
|
|
390
|
+
}
|
|
391
|
+
// ---- checkpoint driving
|
|
392
|
+
arm() {
|
|
393
|
+
if (this.tick || this.seq === this.durableSeq)
|
|
394
|
+
return;
|
|
395
|
+
this.tick = timer(() => {
|
|
396
|
+
this.tick = null;
|
|
397
|
+
this.kick(true);
|
|
398
|
+
}, this.plane.intervalMs);
|
|
399
|
+
}
|
|
400
|
+
disarm() {
|
|
401
|
+
if (this.tick)
|
|
402
|
+
clearTimeout(this.tick);
|
|
403
|
+
this.tick = null;
|
|
404
|
+
}
|
|
405
|
+
kick(force) {
|
|
406
|
+
if (force)
|
|
407
|
+
this.forced = true;
|
|
408
|
+
if (this.draining)
|
|
409
|
+
return;
|
|
410
|
+
this.draining = true;
|
|
411
|
+
void this.drain();
|
|
412
|
+
}
|
|
413
|
+
async drain() {
|
|
414
|
+
this.stalled = false;
|
|
415
|
+
try {
|
|
416
|
+
for (;;) {
|
|
417
|
+
// Sealing goes STRAIGHT to the seal: it writes the whole body (§3.4), so committing the
|
|
418
|
+
// outstanding tail as one more chunk first would be pure waste — and the seal repairs any
|
|
419
|
+
// earlier checkpoint that failed, which a tail commit could not.
|
|
420
|
+
if (this.sealing) {
|
|
421
|
+
await this.commitSeal();
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
const behind = this.seq - this.durableSeq;
|
|
425
|
+
if (behind > 0 && (this.forced || behind >= this.plane.chars)) {
|
|
426
|
+
// Consume the force BEFORE the round, not after: a `flush()` that lands while this round
|
|
427
|
+
// is already in flight re-sets it and gets a round of its OWN — clearing it afterwards
|
|
428
|
+
// would swallow that request and leave the flush to the interval cadence.
|
|
429
|
+
this.forced = false;
|
|
430
|
+
this.disarm();
|
|
431
|
+
const failure = await this.commitTail();
|
|
432
|
+
if (failure) {
|
|
433
|
+
this.stalled = true;
|
|
434
|
+
// A seal requested WHILE this checkpoint was in flight outranks the retry cadence: the
|
|
435
|
+
// seal writes the whole body anyway, so it both supersedes and repairs the failed slice.
|
|
436
|
+
// Backing off here instead would make `close()` wait out `intervalMs` for nothing. An
|
|
437
|
+
// explicit re-force during the failed round likewise earns one immediate re-attempt —
|
|
438
|
+
// someone is waiting on it — while an unforced failure stalls to the cadence.
|
|
439
|
+
if (!this.sealing && !this.forced)
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
this.forced = false;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
this.draining = false;
|
|
450
|
+
if (!this.ended) {
|
|
451
|
+
// A pending seal ALWAYS proceeds at once — including out of a stall (see the loop above);
|
|
452
|
+
// otherwise a stall waits out the interval, and deltas that landed mid-commit may already
|
|
453
|
+
// meet the threshold and deserve an immediate round.
|
|
454
|
+
if (this.sealing)
|
|
455
|
+
this.kick(false);
|
|
456
|
+
else if (this.stalled)
|
|
457
|
+
this.arm();
|
|
458
|
+
else if (this.seq - this.durableSeq >= this.plane.chars)
|
|
459
|
+
this.kick(false);
|
|
460
|
+
else
|
|
461
|
+
this.arm();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** @returns the failure, or `undefined` when the slice committed. */
|
|
466
|
+
async commitTail() {
|
|
467
|
+
const from = this.durableSeq;
|
|
468
|
+
const to = this.seq;
|
|
469
|
+
const text = this.buf.slice(from - this.bufFrom, to - this.bufFrom);
|
|
470
|
+
let out;
|
|
471
|
+
try {
|
|
472
|
+
out = await this.plane.commit({ kind: "append", streamId: this.streamId, from, seq: to, text });
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
// The store may hold MORE than we believe — an append that committed while its ack was lost,
|
|
476
|
+
// somewhere in the retry chain. The read-back is the truth, and adopting it is what keeps the
|
|
477
|
+
// NEXT slice contiguous instead of overlapping (§3.3).
|
|
478
|
+
const truth = await this.plane.probeDurableSeq(this.streamId);
|
|
479
|
+
if (truth !== undefined && truth > this.durableSeq)
|
|
480
|
+
this.advanceDurable(truth);
|
|
481
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
|
|
482
|
+
// Waiters that asked for THIS prefix learn it did not land; later waiters keep waiting for a
|
|
483
|
+
// later attempt. A durability failure is never silent.
|
|
484
|
+
this.rejectWaiters(to, err);
|
|
485
|
+
return { err };
|
|
486
|
+
}
|
|
487
|
+
if (out?.cancelRequested)
|
|
488
|
+
this.cancelled = true;
|
|
489
|
+
// `durableSeq` advances to what the store CONFIRMED — the mapped path reads it back, a `commit`
|
|
490
|
+
// callback may report it — never to a plane-side assumption. A confirmation short of `to` means
|
|
491
|
+
// this slice's `from` was stale (a prior ack loss the read-back could not see at the time): the
|
|
492
|
+
// guarded statements made it a no-op, and the next round re-covers from the confirmed offset.
|
|
493
|
+
const confirmed = out?.seq ?? to;
|
|
494
|
+
if (confirmed > this.durableSeq)
|
|
495
|
+
this.advanceDurable(confirmed);
|
|
496
|
+
if (confirmed < to) {
|
|
497
|
+
const err = new Error(`stream ${this.streamId}: the store confirmed ${confirmed} of ${to} — re-covering from there`);
|
|
498
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
|
|
499
|
+
this.rejectWaiters(to, err);
|
|
500
|
+
return { err };
|
|
501
|
+
}
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
/** The one place durable progress is recorded: position, `durable` frame, buffer trim, waiters. */
|
|
505
|
+
advanceDurable(seq) {
|
|
506
|
+
this.durableSeq = seq;
|
|
507
|
+
this.fanout({ type: "durable", seq });
|
|
508
|
+
this.trim();
|
|
509
|
+
this.resolveWaiters();
|
|
510
|
+
}
|
|
511
|
+
async commitSeal() {
|
|
512
|
+
const seal = this.sealing;
|
|
513
|
+
const from = this.durableSeq;
|
|
514
|
+
try {
|
|
515
|
+
await this.plane.commit({
|
|
516
|
+
kind: "close",
|
|
517
|
+
streamId: this.streamId,
|
|
518
|
+
from,
|
|
519
|
+
seq: this.seq,
|
|
520
|
+
// A trimmed buffer (only reachable in `commit`-callback mode, where the app opted into
|
|
521
|
+
// trimming) cannot supply the whole body: `bodyFrom` says where the retained text starts,
|
|
522
|
+
// so the app can still append exactly the outstanding tail (`body.slice(from - bodyFrom)`).
|
|
523
|
+
body: this.buf,
|
|
524
|
+
bodyFrom: this.bufFrom,
|
|
525
|
+
status: seal.status,
|
|
526
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
527
|
+
});
|
|
528
|
+
this.durableSeq = this.seq;
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
this.sealError = err;
|
|
532
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: this.seq });
|
|
533
|
+
}
|
|
534
|
+
this.ended = true;
|
|
535
|
+
this.disarm();
|
|
536
|
+
this.rejectWaiters(Number.POSITIVE_INFINITY, new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`));
|
|
537
|
+
this.resolveWaiters();
|
|
538
|
+
const frame = {
|
|
539
|
+
type: "end",
|
|
540
|
+
seq: this.durableSeq,
|
|
541
|
+
status: seal.status,
|
|
542
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
543
|
+
};
|
|
544
|
+
for (const sub of [...this.subs])
|
|
545
|
+
sub.finish(frame);
|
|
546
|
+
this.plane.retire(this.streamId);
|
|
547
|
+
if (this.sealError)
|
|
548
|
+
this.sealed?.reject(this.sealError);
|
|
549
|
+
else
|
|
550
|
+
this.sealed?.resolve();
|
|
551
|
+
}
|
|
552
|
+
/** Keep everything at or above `durableSeq`, plus a slack window below it so a client whose IVM
|
|
553
|
+
* view lags one checkpoint can still join without a `stale` round trip (§4). A `retainChars` of
|
|
554
|
+
* `Infinity` (the mapped-table default — compaction needs the whole text) never trims. */
|
|
555
|
+
trim() {
|
|
556
|
+
if (!Number.isFinite(this.plane.retainChars))
|
|
557
|
+
return;
|
|
558
|
+
const floor = Math.max(0, this.durableSeq - this.plane.retainChars);
|
|
559
|
+
if (floor <= this.bufFrom)
|
|
560
|
+
return;
|
|
561
|
+
this.buf = this.buf.slice(floor - this.bufFrom);
|
|
562
|
+
this.bufFrom = floor;
|
|
563
|
+
}
|
|
564
|
+
resolveWaiters() {
|
|
565
|
+
if (this.waiters.length === 0)
|
|
566
|
+
return;
|
|
567
|
+
const still = [];
|
|
568
|
+
for (const w of this.waiters) {
|
|
569
|
+
if (w.at <= this.durableSeq)
|
|
570
|
+
w.resolve(this.durableSeq);
|
|
571
|
+
else
|
|
572
|
+
still.push(w);
|
|
573
|
+
}
|
|
574
|
+
this.waiters = still;
|
|
575
|
+
}
|
|
576
|
+
rejectWaiters(upTo, err) {
|
|
577
|
+
if (this.waiters.length === 0)
|
|
578
|
+
return;
|
|
579
|
+
const still = [];
|
|
580
|
+
for (const w of this.waiters) {
|
|
581
|
+
if (w.at <= upTo && w.at > this.durableSeq)
|
|
582
|
+
w.reject(err);
|
|
583
|
+
else
|
|
584
|
+
still.push(w);
|
|
585
|
+
}
|
|
586
|
+
this.waiters = still;
|
|
587
|
+
}
|
|
588
|
+
// ---- subscriber side
|
|
589
|
+
fanout(frame) {
|
|
590
|
+
for (const sub of [...this.subs]) {
|
|
591
|
+
if (!sub.offer(frame)) {
|
|
592
|
+
// Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
|
|
593
|
+
// producer's memory.
|
|
594
|
+
sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
|
|
595
|
+
this.subs.delete(sub);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** Attach a reader at `from`. Runs in ONE synchronous block — snapshot, replay, register — so no
|
|
600
|
+
* delta can slip between the replay and the live tail (the gap-free half of contract S). */
|
|
601
|
+
subscribe(from) {
|
|
602
|
+
const sub = new Subscriber(this.plane.maxQueuedFrames, (s) => this.subs.delete(s));
|
|
603
|
+
// Floored as well as clamped: a fractional `from` (a hand-built subscribe input) would otherwise
|
|
604
|
+
// produce a replay chunk whose `text.length !== seq - from`, breaking the frame invariant.
|
|
605
|
+
const at = Math.min(Math.max(Math.floor(from), 0), this.seq);
|
|
606
|
+
if (at < this.bufFrom) {
|
|
607
|
+
sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
|
|
608
|
+
return this.wrap(sub);
|
|
609
|
+
}
|
|
610
|
+
sub.offer({ type: "open", streamId: this.streamId, from: at, seq: this.seq, durableSeq: this.durableSeq, ended: this.ended });
|
|
611
|
+
if (this.seq > at) {
|
|
612
|
+
sub.offer({ type: "chunk", from: at, seq: this.seq, text: this.buf.slice(at - this.bufFrom) });
|
|
613
|
+
}
|
|
614
|
+
if (this.durableSeq > at)
|
|
615
|
+
sub.offer({ type: "durable", seq: this.durableSeq });
|
|
616
|
+
if (this.ended) {
|
|
617
|
+
const seal = this.sealing ?? { status: "interrupted" };
|
|
618
|
+
sub.finish({
|
|
619
|
+
type: "end",
|
|
620
|
+
seq: this.durableSeq,
|
|
621
|
+
status: seal.status,
|
|
622
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
623
|
+
});
|
|
624
|
+
return this.wrap(sub);
|
|
625
|
+
}
|
|
626
|
+
this.subs.add(sub);
|
|
627
|
+
return this.wrap(sub);
|
|
628
|
+
}
|
|
629
|
+
wrap(sub) {
|
|
630
|
+
return { streamId: this.streamId, frames: sub.frames(), close: () => sub.close() };
|
|
631
|
+
}
|
|
632
|
+
/** Drop every reader without sealing (process teardown — `drainStreams` seals first). */
|
|
633
|
+
detachAll() {
|
|
634
|
+
for (const sub of [...this.subs])
|
|
635
|
+
sub.close();
|
|
636
|
+
this.subs.clear();
|
|
637
|
+
this.disarm();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
export class StreamPlane {
|
|
641
|
+
chars;
|
|
642
|
+
intervalMs;
|
|
643
|
+
retries;
|
|
644
|
+
retainChars;
|
|
645
|
+
lingerMs;
|
|
646
|
+
maxQueuedFrames;
|
|
647
|
+
live = new Map();
|
|
648
|
+
opts;
|
|
649
|
+
sink;
|
|
650
|
+
mapped;
|
|
651
|
+
tables;
|
|
652
|
+
/** The open CAS token (§5.1): `hostId`, or a random per-plane stand-in when only the CAS — not
|
|
653
|
+
* routing — needs it. */
|
|
654
|
+
openToken;
|
|
655
|
+
constructor(opts, sink) {
|
|
656
|
+
this.opts = opts;
|
|
657
|
+
this.sink = sink;
|
|
658
|
+
this.chars = opts.policy?.chars ?? DEFAULT_CHECKPOINT_CHARS;
|
|
659
|
+
this.intervalMs = opts.policy?.intervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
660
|
+
this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
|
|
661
|
+
this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
|
|
662
|
+
this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
|
|
663
|
+
this.openToken = opts.hostId ?? randomOpenToken();
|
|
664
|
+
if ("tables" in opts.checkpoint) {
|
|
665
|
+
if (!sink)
|
|
666
|
+
throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
|
|
667
|
+
this.tables = opts.checkpoint.tables;
|
|
668
|
+
this.mapped = new MappedTableSql(opts.checkpoint.tables, sink.dialect);
|
|
669
|
+
// Options this mode cannot honour are refused loudly (the room-profile rule), never ignored.
|
|
670
|
+
if (opts.retainChars !== undefined) {
|
|
671
|
+
throw new TypeError("streams.retainChars is `commit`-mode only — mapped tables retain the whole text for compaction (§3.4)");
|
|
672
|
+
}
|
|
673
|
+
if (opts.hostId !== undefined && resolveStreamColumns(opts.checkpoint.tables.columns).host === undefined) {
|
|
674
|
+
throw new TypeError("streams.hostId does nothing in tables mode until columns.host names where to persist it");
|
|
675
|
+
}
|
|
676
|
+
// Compaction writes the WHOLE body at close, so the buffer is retained in full. `retainChars`
|
|
677
|
+
// is honoured only where the app owns persistence and may not need the tail.
|
|
678
|
+
this.retainChars = Number.POSITIVE_INFINITY;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
this.retainChars = opts.retainChars ?? DEFAULT_RETAIN_CHARS;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Open a stream on an EXISTING message row (§5): the app's own mutator wrote it, alongside the
|
|
685
|
+
* user's prompt, so the pointer is already durable and every client's query already shows the
|
|
686
|
+
* message. This verifies it and flips it to `streaming`. */
|
|
687
|
+
async open(input) {
|
|
688
|
+
if (this.live.has(input.streamId)) {
|
|
689
|
+
throw new Error(`stream ${input.streamId} is already open on this host`);
|
|
690
|
+
}
|
|
691
|
+
const stream = new LiveStream(input.streamId, input.user, input.meta, this);
|
|
692
|
+
this.live.set(input.streamId, stream);
|
|
693
|
+
try {
|
|
694
|
+
if (this.mapped)
|
|
695
|
+
await this.assertOpenable(input.streamId);
|
|
696
|
+
await this.commit({
|
|
697
|
+
kind: "open",
|
|
698
|
+
streamId: input.streamId,
|
|
699
|
+
meta: input.meta ?? null,
|
|
700
|
+
...(this.opts.hostId !== undefined ? { hostId: this.opts.hostId } : {}),
|
|
701
|
+
startedAt: Date.now(),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
this.live.delete(input.streamId);
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
streamId: stream.streamId,
|
|
710
|
+
get seq() {
|
|
711
|
+
return stream.seq;
|
|
712
|
+
},
|
|
713
|
+
get durableSeq() {
|
|
714
|
+
return stream.durableSeq;
|
|
715
|
+
},
|
|
716
|
+
get cancelled() {
|
|
717
|
+
return stream.cancelled;
|
|
718
|
+
},
|
|
719
|
+
push: (text) => stream.push(text),
|
|
720
|
+
flush: () => stream.flush(),
|
|
721
|
+
pump: async (deltas) => {
|
|
722
|
+
// `for await` closes the iterator on `break`, which is what aborts the SDK's underlying
|
|
723
|
+
// request — the whole point of honouring cancellation here rather than in the caller.
|
|
724
|
+
for await (const d of deltas) {
|
|
725
|
+
stream.push(d);
|
|
726
|
+
if (stream.cancelled)
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
close: () => stream.seal(stream.cancelled ? "cancelled" : "complete"),
|
|
731
|
+
fail: (error) => stream.seal("error", errText(error)),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* The read-only precondition on the app's message row, checked ONCE per `open` (§5.1). Read-only on
|
|
736
|
+
* purpose: it is a decision about the app's data, so re-deciding it per write attempt would let a
|
|
737
|
+
* lost ack turn a success into a refusal.
|
|
738
|
+
*
|
|
739
|
+
* The `streaming` check is the **single-flight guard**, and it lives at the ROW rather than in this
|
|
740
|
+
* process's map because the thing it defends against is distributed: the kick that starts a
|
|
741
|
+
* generation is an at-least-once effect (a retried mutation envelope re-runs its post-commit code,
|
|
742
|
+
* §10.5), so a second kick can land on another instance, where the in-memory map is empty. Two
|
|
743
|
+
* producers on one `streamId` would interleave: both CAS the same length, one stalls, and whichever
|
|
744
|
+
* closes last overwrites the body with ITS buffer. Cheap to refuse; expensive to debug.
|
|
745
|
+
*
|
|
746
|
+
* This probe alone is check-then-act — it and the open write are separate round trips, so two
|
|
747
|
+
* SIMULTANEOUS kicks can both pass it. Mapping a `host` column closes that window: the open write
|
|
748
|
+
* turns conditional and {@link verifyOpenWinner}'s read-back names the winner.
|
|
749
|
+
*/
|
|
750
|
+
async assertOpenable(streamId) {
|
|
751
|
+
const cols = resolveStreamColumns(this.tables.columns);
|
|
752
|
+
const rows = await this.sink.sql.query(this.mapped.probeOpen(), [streamId]);
|
|
753
|
+
const row = rows[0];
|
|
754
|
+
if (!row) {
|
|
755
|
+
throw new StreamOpenRefused(`stream ${streamId}: no row in "${this.tables.message}" — the app's own mutator must write the message ` +
|
|
756
|
+
`(with its chatId/role/…) BEFORE opening the stream on it`);
|
|
757
|
+
}
|
|
758
|
+
// A NULL length is refused as loudly as an advanced one, and for a subtler reason: it is the CAS
|
|
759
|
+
// column, and `WHERE seq = 0` can never match NULL. A nullable column would let every chunk
|
|
760
|
+
// insert land while its length CAS silently matched nothing — the one failure mode this plane
|
|
761
|
+
// cannot detect (`batch` reports no row count) and one compaction would paper over (§5.1).
|
|
762
|
+
if (typeof row.seq !== "number") {
|
|
763
|
+
throw new StreamOpenRefused(`stream ${streamId}: "${this.tables.message}"."${cols.seq}" is ${row.seq === null ? "NULL" : typeof row.seq} — ` +
|
|
764
|
+
`the length column must be NOT NULL DEFAULT 0 (it is compare-and-swapped on every checkpoint, and no ` +
|
|
765
|
+
`comparison matches NULL)`);
|
|
766
|
+
}
|
|
767
|
+
if (row.seq !== 0) {
|
|
768
|
+
throw new StreamOpenRefused(`stream ${streamId}: the message row already holds ${row.seq} characters — a regeneration is a NEW message ` +
|
|
769
|
+
`id, never a resume of an advanced row`);
|
|
770
|
+
}
|
|
771
|
+
if (row.status === STREAM_STATUS_STREAMING) {
|
|
772
|
+
throw new StreamOpenRefused(`stream ${streamId}: "${cols.status}" is already "${STREAM_STATUS_STREAMING}" — another producer holds this ` +
|
|
773
|
+
`stream. Two producers on one stream interleave their checkpoints; if the first one's host died, the ` +
|
|
774
|
+
`sweeper marking it "interrupted" is what releases the row (§7)`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async subscribe(input) {
|
|
778
|
+
const stream = this.live.get(input.streamId);
|
|
779
|
+
const from = input.from ?? 0;
|
|
780
|
+
// Authorize BEFORE existence is consulted: a denial must not double as an existence oracle.
|
|
781
|
+
const verdict = await this.opts.authorize({
|
|
782
|
+
user: input.user,
|
|
783
|
+
streamId: input.streamId,
|
|
784
|
+
from,
|
|
785
|
+
meta: stream === undefined ? undefined : stream.meta,
|
|
786
|
+
request: input.request,
|
|
787
|
+
});
|
|
788
|
+
if (verdict === false)
|
|
789
|
+
throw new StreamForbidden(input.streamId);
|
|
790
|
+
if (!stream)
|
|
791
|
+
return oneFrame(input.streamId, { type: "absent" });
|
|
792
|
+
return stream.subscribe(from);
|
|
793
|
+
}
|
|
794
|
+
/** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
|
|
795
|
+
* graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
|
|
796
|
+
* than claiming completion (§5). Wire it to SIGTERM. */
|
|
797
|
+
async drainStreams() {
|
|
798
|
+
await Promise.allSettled([...this.live.values()].map((s) => s.seal("interrupted")));
|
|
799
|
+
}
|
|
800
|
+
/** Teardown: drop readers and timers WITHOUT a durable write (that is `drainStreams`). */
|
|
801
|
+
closeSync() {
|
|
802
|
+
for (const s of this.live.values())
|
|
803
|
+
s.detachAll();
|
|
804
|
+
this.live.clear();
|
|
805
|
+
}
|
|
806
|
+
/** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
|
|
807
|
+
* still gets `end` (and the tail it missed) rather than a bare `absent`. */
|
|
808
|
+
retire(streamId) {
|
|
809
|
+
timer(() => {
|
|
810
|
+
const entry = this.live.get(streamId);
|
|
811
|
+
if (entry?.ended) {
|
|
812
|
+
this.live.delete(streamId);
|
|
813
|
+
entry.detachAll();
|
|
814
|
+
}
|
|
815
|
+
}, this.lingerMs);
|
|
816
|
+
}
|
|
817
|
+
reportCheckpointError(err, info) {
|
|
818
|
+
// Reporting runs INSIDE the drain loop, so a throwing hook would take the loop down with it and
|
|
819
|
+
// wedge the stream — the one failure mode a diagnostic must never cause.
|
|
820
|
+
try {
|
|
821
|
+
if (this.opts.onCheckpointError)
|
|
822
|
+
this.opts.onCheckpointError(err, info);
|
|
823
|
+
else
|
|
824
|
+
console.error(`[rindle api-server] stream ${info.streamId}: checkpoint ${info.from}→${info.seq} failed:`, err);
|
|
825
|
+
}
|
|
826
|
+
catch (hookErr) {
|
|
827
|
+
console.error(`[rindle api-server] stream ${info.streamId}: onCheckpointError itself threw:`, hookErr);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/** Drive ONE checkpoint, retrying on failure. Every statement it emits is idempotent under replay
|
|
831
|
+
* — the chunk insert dedups on its deterministic id, the length CAS refuses to apply twice, the
|
|
832
|
+
* compaction is a whole-row overwrite — so "retry until it sticks" needs no dedup ledger and no
|
|
833
|
+
* `lmid` (§3.3). */
|
|
834
|
+
async commit(input) {
|
|
835
|
+
let lastErr;
|
|
836
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
837
|
+
if (attempt > 0)
|
|
838
|
+
await delay(RETRY_BACKOFF_MS * 2 ** (attempt - 1));
|
|
839
|
+
try {
|
|
840
|
+
return await this.commitOnce(input);
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
if (err instanceof StreamOpenRefused)
|
|
844
|
+
throw err; // a settled verdict, not a blip
|
|
845
|
+
lastErr = err;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
throw lastErr;
|
|
849
|
+
}
|
|
850
|
+
async commitOnce(input) {
|
|
851
|
+
const target = this.opts.checkpoint;
|
|
852
|
+
if ("commit" in target)
|
|
853
|
+
return (await target.commit(input)) ?? undefined;
|
|
854
|
+
const sql = this.sink.sql;
|
|
855
|
+
const mapped = this.mapped;
|
|
856
|
+
switch (input.kind) {
|
|
857
|
+
case "open": {
|
|
858
|
+
// The PRECONDITION is checked once, in `assertOpenable`, before this write — never here.
|
|
859
|
+
// Re-reading it per attempt would make a lost ack on the write below refuse its own success
|
|
860
|
+
// (the row would already say `streaming`).
|
|
861
|
+
await sql.batch([mapped.markStreaming(input.streamId, this.openToken)]);
|
|
862
|
+
await this.verifyOpenWinner(input.streamId);
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
case "append": {
|
|
866
|
+
try {
|
|
867
|
+
await sql.batch(mapped.append(input.streamId, input.from, input.seq, input.text));
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
// The batch may have COMMITTED with its ack lost. The read-back is the truth: a row at
|
|
871
|
+
// (or past) this slice's end means it landed, and the throw was only the reply.
|
|
872
|
+
const truth = await this.readAppendState(input.streamId).catch(() => undefined);
|
|
873
|
+
if (truth === undefined || truth.seq < input.seq)
|
|
874
|
+
throw err;
|
|
875
|
+
return truth;
|
|
876
|
+
}
|
|
877
|
+
// The read-back CONFIRMS the append (the guarded statements report no row count). If it
|
|
878
|
+
// throws, the retry is safe: a replay of an applied slice no-ops on the guard and confirms
|
|
879
|
+
// on its own read-back.
|
|
880
|
+
return await this.readAppendState(input.streamId);
|
|
881
|
+
}
|
|
882
|
+
case "close": {
|
|
883
|
+
await sql.batch(mapped.compact(input.streamId, input.body, input.status, input.error));
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/** The read-back half of the open CAS, run only when a `host` column is mapped. The probe in
|
|
889
|
+
* `assertOpenable` and the open write are separate round trips, so bare check-then-act leaves a
|
|
890
|
+
* window where two simultaneous kicks both pass the probe; the conditional `markStreaming`
|
|
891
|
+
* matches nothing when a rival got there first, and whichever token the row now holds names the
|
|
892
|
+
* winner — a true CAS with no row counts needed. A replayed open (lost ack) reads back its OWN
|
|
893
|
+
* token and proceeds. */
|
|
894
|
+
async verifyOpenWinner(streamId) {
|
|
895
|
+
const probe = this.mapped.probeHost();
|
|
896
|
+
if (probe === undefined)
|
|
897
|
+
return;
|
|
898
|
+
const rows = await this.sink.sql.query(probe, [streamId]);
|
|
899
|
+
if (rows[0]?.host !== this.openToken) {
|
|
900
|
+
throw new StreamOpenRefused(`stream ${streamId}: another producer won the open race (the row's host is ` +
|
|
901
|
+
`${JSON.stringify(rows[0]?.host ?? null)}) — two producers on one stream interleave their checkpoints, ` +
|
|
902
|
+
`so the loser stands down`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/** The post-append read-back (§3.2): the row's authoritative length — which both CONFIRMS an
|
|
906
|
+
* append (the guarded batch reports no row count) and absorbs a committed-but-ack-lost one —
|
|
907
|
+
* plus the reader's cancel flag when mapped (§6), riding the same indexed point read. This read
|
|
908
|
+
* is LOAD-BEARING: a failure here fails the append attempt (retried, then stalled), because
|
|
909
|
+
* claiming durability the store did not confirm is the one dishonesty this plane refuses. */
|
|
910
|
+
async readAppendState(streamId) {
|
|
911
|
+
const rows = await this.sink.sql.query(this.mapped.probeState(), [
|
|
912
|
+
streamId,
|
|
913
|
+
]);
|
|
914
|
+
const row = rows[0];
|
|
915
|
+
if (row === undefined || typeof row.seq !== "number") {
|
|
916
|
+
throw new Error(`stream ${streamId}: the read-back found no usable message row`);
|
|
917
|
+
}
|
|
918
|
+
return { seq: row.seq, ...(this.mapped.hasCancel ? { cancelRequested: Boolean(row.cancel) } : {}) };
|
|
919
|
+
}
|
|
920
|
+
/** The store's word on how much is durable — for resynchronizing after a FAILED append, where a
|
|
921
|
+
* lost ack may have left the store ahead of the plane. `undefined` in `commit` mode (no readable
|
|
922
|
+
* authority) or when the read itself fails (the next attempt retries the resync too). */
|
|
923
|
+
async probeDurableSeq(streamId) {
|
|
924
|
+
if (!this.mapped)
|
|
925
|
+
return undefined;
|
|
926
|
+
try {
|
|
927
|
+
return (await this.readAppendState(streamId)).seq;
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/** The open probe's verdict: the message row is missing or already advanced. Not retried — it is a
|
|
935
|
+
* settled statement about the app's data, and retrying re-asks a question already answered. */
|
|
936
|
+
export class StreamOpenRefused extends Error {
|
|
937
|
+
constructor(message) {
|
|
938
|
+
super(message);
|
|
939
|
+
this.name = "StreamOpenRefused";
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Refused by {@link RindleStreamOptions.authorize}. Distinct from the api-server's own
|
|
943
|
+
* `RindleApiError` so this module stays importable without the server (no cycle); the server
|
|
944
|
+
* translates it to a 403 at the handler seam. */
|
|
945
|
+
export class StreamForbidden extends Error {
|
|
946
|
+
streamId;
|
|
947
|
+
constructor(streamId) {
|
|
948
|
+
super(`stream ${streamId}: forbidden`);
|
|
949
|
+
this.name = "StreamForbidden";
|
|
950
|
+
this.streamId = streamId;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
function oneFrame(streamId, frame) {
|
|
954
|
+
let taken = false;
|
|
955
|
+
return {
|
|
956
|
+
streamId,
|
|
957
|
+
frames: {
|
|
958
|
+
[Symbol.asyncIterator]() {
|
|
959
|
+
return {
|
|
960
|
+
next() {
|
|
961
|
+
if (taken)
|
|
962
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
963
|
+
taken = true;
|
|
964
|
+
return Promise.resolve({ value: frame, done: false });
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
close: () => {
|
|
970
|
+
taken = true;
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
// ------------------------------------------------------------------------------- SSE transport
|
|
975
|
+
/** Headers for the SSE response. `x-accel-buffering` is the nginx-family opt-out — without it a
|
|
976
|
+
* buffering proxy holds the tokens and hands the user a paragraph at a time. */
|
|
977
|
+
export const STREAM_SSE_HEADERS = {
|
|
978
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
979
|
+
"cache-control": "no-cache, no-transform",
|
|
980
|
+
connection: "keep-alive",
|
|
981
|
+
"x-accel-buffering": "no",
|
|
982
|
+
};
|
|
983
|
+
/** Pull a subscribe request out of a fetch-style GET: `?streamId=…&from=…`, with `Last-Event-ID`
|
|
984
|
+
* winning over an explicit `from` (a reconnecting `EventSource` knows better than its own URL —
|
|
985
|
+
* the URL is the ORIGINAL join point, the header is where it actually got to). */
|
|
986
|
+
export function streamRequestFromHttp(req) {
|
|
987
|
+
const url = new URL(req.url);
|
|
988
|
+
const streamId = url.searchParams.get("streamId") ?? "";
|
|
989
|
+
const lastEventId = req.headers.get("last-event-id");
|
|
990
|
+
const raw = lastEventId ?? url.searchParams.get("from") ?? "0";
|
|
991
|
+
const from = Number.parseInt(raw, 10);
|
|
992
|
+
return { streamId, from: Number.isFinite(from) && from > 0 ? from : 0 };
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Encode a subscription as an SSE body. Each positional frame carries `id: <seq>`, so a browser
|
|
996
|
+
* `EventSource` that drops the connection resumes at exactly the right offset with no application
|
|
997
|
+
* code — its own `Last-Event-ID` header is the `from` of the next subscribe ({@link
|
|
998
|
+
* streamRequestFromHttp}).
|
|
999
|
+
*
|
|
1000
|
+
* The reader must close the `EventSource` on the `end` frame: `EventSource` reconnects on ANY close,
|
|
1001
|
+
* including a clean one.
|
|
1002
|
+
*/
|
|
1003
|
+
export function streamFramesToSse(sub, opts) {
|
|
1004
|
+
const encoder = new TextEncoder();
|
|
1005
|
+
const keepAliveMs = opts?.keepAliveMs ?? 15_000;
|
|
1006
|
+
let ping = null;
|
|
1007
|
+
let cancelled = false;
|
|
1008
|
+
let iter;
|
|
1009
|
+
// PULL-based on purpose: a frame is taken from the subscription only when the consumer has
|
|
1010
|
+
// demand, so a slow HTTP client backs frames up in the SUBSCRIBER's bounded queue — where
|
|
1011
|
+
// `maxQueuedFrames` drops it with `stale` (§4) — instead of unboundedly in this stream's own.
|
|
1012
|
+
return new ReadableStream({
|
|
1013
|
+
start(controller) {
|
|
1014
|
+
iter = sub.frames[Symbol.asyncIterator]();
|
|
1015
|
+
const beat = () => {
|
|
1016
|
+
if (cancelled)
|
|
1017
|
+
return;
|
|
1018
|
+
// A comment line: keeps intermediaries from reaping an idle connection, costs 8 bytes.
|
|
1019
|
+
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
1020
|
+
ping = timer(beat, keepAliveMs);
|
|
1021
|
+
};
|
|
1022
|
+
ping = timer(beat, keepAliveMs);
|
|
1023
|
+
},
|
|
1024
|
+
async pull(controller) {
|
|
1025
|
+
const next = await iter.next();
|
|
1026
|
+
if (cancelled)
|
|
1027
|
+
return;
|
|
1028
|
+
if (next.done) {
|
|
1029
|
+
if (ping)
|
|
1030
|
+
clearTimeout(ping);
|
|
1031
|
+
sub.close();
|
|
1032
|
+
controller.close();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const id = frameResumePoint(next.value);
|
|
1036
|
+
const head = id === undefined ? "" : `id: ${id}\n`;
|
|
1037
|
+
controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(next.value)}\n\n`));
|
|
1038
|
+
},
|
|
1039
|
+
cancel() {
|
|
1040
|
+
cancelled = true;
|
|
1041
|
+
if (ping)
|
|
1042
|
+
clearTimeout(ping);
|
|
1043
|
+
sub.close();
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
//# sourceMappingURL=streams.js.map
|