@rindle/api-server 0.7.11 → 0.8.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/src/streams.ts
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
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
|
+
|
|
38
|
+
// Type-only (erased at runtime) — no import cycle with ./index.ts.
|
|
39
|
+
import { STREAM_STATUS_STREAMING, frameResumePoint } from "@rindle/client";
|
|
40
|
+
import type { StreamFrame, StreamStatus } from "@rindle/client";
|
|
41
|
+
import type { SqlStatement } from "@rindle/daemon-client";
|
|
42
|
+
|
|
43
|
+
import type { Authorizer, ServerSql, SqlDialect } from "./index.ts";
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------------------- the wire
|
|
46
|
+
//
|
|
47
|
+
// The frame shapes and the two pure reassembly functions live in `@rindle/client` — BOTH tiers need
|
|
48
|
+
// them, and a browser must never pull this package to read a stream. Re-exported here so the
|
|
49
|
+
// server-side import path is unchanged.
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
STREAM_STATUS_STREAMING,
|
|
53
|
+
assembleDurableText,
|
|
54
|
+
frameResumePoint,
|
|
55
|
+
spliceStreamText,
|
|
56
|
+
} from "@rindle/client";
|
|
57
|
+
export type { StreamFrame, StreamStatus } from "@rindle/client";
|
|
58
|
+
|
|
59
|
+
// ------------------------------------------------------------------------------- the durable seam
|
|
60
|
+
|
|
61
|
+
/** What a checkpoint hands the durable plane when the app supplies its own {@link StreamCommit}. */
|
|
62
|
+
export type StreamCommitInput =
|
|
63
|
+
/** The pointer is marked live. The app's own mutator created the row (it owns `chatId`, `role`,
|
|
64
|
+
* the model name…); this only flips it to `streaming`. */
|
|
65
|
+
| { kind: "open"; streamId: string; meta: unknown; hostId?: string; startedAt: number }
|
|
66
|
+
/** A prefix advance carrying ONLY its own slice. `text.length === seq - from`, and `from` is the
|
|
67
|
+
* last append the PLANE saw confirmed — so appends are contiguous in the fault-free run, but an
|
|
68
|
+
* append that committed while its ack was lost makes the next one RE-COVER (its `from` lags what
|
|
69
|
+
* the app already applied). See {@link StreamCommit} for the two ways to stay idempotent. */
|
|
70
|
+
| { kind: "append"; streamId: string; from: number; seq: number; text: string }
|
|
71
|
+
/** The seal. `body` is the producer's retained text and `bodyFrom` its absolute start offset:
|
|
72
|
+
* `bodyFrom === 0` — and `body` is the WHOLE response — unless the app opted into trimming via
|
|
73
|
+
* `retainChars` (`commit` mode only). A compacting app requires `bodyFrom === 0` and writes
|
|
74
|
+
* `body` wholesale; a non-compacting app appends the outstanding tail —
|
|
75
|
+
* `body.slice(from - bodyFrom)` — as a final chunk. `seq === bodyFrom + body.length`, always. */
|
|
76
|
+
| { kind: "close"; streamId: string; from: number; seq: number; body: string; bodyFrom: number; status: StreamStatus; error?: string };
|
|
77
|
+
|
|
78
|
+
/** The escape hatch: persist the checkpoint however the app likes. Retried on throw (§3.3), so it
|
|
79
|
+
* must be idempotent under a repeated `(from, seq)` — AND under a RE-COVER: an append whose write
|
|
80
|
+
* committed but whose ack was lost leaves the plane believing less than the store holds, so the
|
|
81
|
+
* next append's `(from, seq)` can OVERLAP text already applied. Apply only the unseen suffix
|
|
82
|
+
* (`text.slice(applied - from)` when `applied > from`), or better, return `{seq: applied}` — the
|
|
83
|
+
* authoritative applied length — and the plane resynchronizes instead of re-covering at all.
|
|
84
|
+
* Return `{cancelRequested: true}` to tell the producer the reader asked it to stop (§6). */
|
|
85
|
+
export type StreamCommit = (
|
|
86
|
+
input: StreamCommitInput,
|
|
87
|
+
) => Promise<void | { cancelRequested?: boolean; seq?: number }>;
|
|
88
|
+
|
|
89
|
+
/** Which columns of the app's own tables the plane reads and writes. Every entry has a default
|
|
90
|
+
* except `cancel`, `error`, and `host`, which are opt-in BY NAMING: the plane never emits SQL
|
|
91
|
+
* against a column the app did not ask it to use. */
|
|
92
|
+
export interface StreamColumns {
|
|
93
|
+
/** The message row's primary key, matched against `streamId`. Default `id`. */
|
|
94
|
+
key?: string;
|
|
95
|
+
/** The compacted response text. Default `body`. */
|
|
96
|
+
body?: string;
|
|
97
|
+
/** {@link STREAM_STATUS_STREAMING} then a {@link StreamStatus}. Default `status`. */
|
|
98
|
+
status?: string;
|
|
99
|
+
/** Total durable length — `length(body) + Σ chunk lengths`. The CAS column (§3.2). Default `seq`. */
|
|
100
|
+
seq?: string;
|
|
101
|
+
/** Opt-in (§6): a truthy value here stops the generation at the next checkpoint. No default —
|
|
102
|
+
* naming it is what turns cancellation on. */
|
|
103
|
+
cancel?: string;
|
|
104
|
+
/** Opt-in: where a failed generation's message is recorded. No default. */
|
|
105
|
+
error?: string;
|
|
106
|
+
/** Opt-in: where the open write records this producer's identity ({@link RindleStreamOptions.hostId}).
|
|
107
|
+
* Naming it upgrades the row-level single-flight guard from check-then-act to a true
|
|
108
|
+
* compare-and-swap — the open write turns conditional and a read-back names the winner (§5.1) —
|
|
109
|
+
* and gives multi-instance subscribe routing a column to read (§4). No default. */
|
|
110
|
+
host?: string;
|
|
111
|
+
/** Chunk primary key; the plane writes the deterministic `"<streamId>:<seq>"`. Default `id`. */
|
|
112
|
+
chunkKey?: string;
|
|
113
|
+
/** Chunk → message reference. Default `streamId`. */
|
|
114
|
+
chunkStream?: string;
|
|
115
|
+
/** The chunk's END offset — the ordering key. Default `seq`. */
|
|
116
|
+
chunkSeq?: string;
|
|
117
|
+
/** The chunk's slice of the response. Default `text`. */
|
|
118
|
+
chunkText?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The app's tables (§5). The app authors and migrates BOTH — the message row is unambiguously
|
|
123
|
+
* app-owned (it has `chatId`, `role`, token counts) and the chunk row must be reachable from the
|
|
124
|
+
* app's own query as a `related` subquery, which a Rindle system table would make awkward. The plane
|
|
125
|
+
* only needs to be told where things live. Use {@link streamChunkTableDdl} for the chunk table's
|
|
126
|
+
* migration.
|
|
127
|
+
*
|
|
128
|
+
* The message table carries WHATEVER ELSE the app wants; the plane's hard requirements are only:
|
|
129
|
+
*
|
|
130
|
+
* | mapped column | requirement | why |
|
|
131
|
+
* | --- | --- | --- |
|
|
132
|
+
* | `key` | UNIQUE (normally the pk) | every checkpoint targets one row by it |
|
|
133
|
+
* | `seq` | integer, **NOT NULL DEFAULT 0** | the CAS column — nothing matches NULL (§3.2) |
|
|
134
|
+
* | `body` | text, empty at open | compaction overwrites it with the whole response |
|
|
135
|
+
* | `status` | text accepting `streaming`/`complete`/`cancelled`/`error`/`interrupted` | a CHECK
|
|
136
|
+
* constraint that omits one of these turns a seal into an infra failure |
|
|
137
|
+
* | `cancel` | truthy-readable, if mapped | read on the checkpoint round-trip (§6) |
|
|
138
|
+
* | `error` | nullable text, if mapped | compaction writes `NULL` when there is no error |
|
|
139
|
+
* | `host` | text, if mapped | written at open with the producer's token; the read-back decides the open race (§5.1) |
|
|
140
|
+
*
|
|
141
|
+
* `body` is PLANE-OWNED and always a bare string. Rich content (an array of content blocks, tool
|
|
142
|
+
* calls, attachments) belongs in SIBLING columns the app's own mutators write — `flush()` orders the
|
|
143
|
+
* text before them. For genuinely multi-block streaming, point `message` at a per-BLOCK table
|
|
144
|
+
* instead: `streamId` is just an app key, so one stream per block needs nothing from this plane.
|
|
145
|
+
*/
|
|
146
|
+
export interface StreamTables {
|
|
147
|
+
/** The app's message table. Must already contain the row when {@link StreamPlane.open} runs. */
|
|
148
|
+
message: string;
|
|
149
|
+
/** The append-only chunk table. */
|
|
150
|
+
chunks: string;
|
|
151
|
+
columns?: StreamColumns;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type StreamCheckpointTarget = { tables: StreamTables } | { commit: StreamCommit };
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------------------- configuration
|
|
157
|
+
|
|
158
|
+
/** When a checkpoint fires — the FIRST of these wins (§3.1). Checkpoints are serialized, so a slow
|
|
159
|
+
* store degrades to fewer, larger checkpoints, never to a queue of them. */
|
|
160
|
+
export interface StreamCheckpointPolicy {
|
|
161
|
+
/** Produced-but-uncommitted characters that force a checkpoint. Default 512. */
|
|
162
|
+
chars?: number;
|
|
163
|
+
/** Milliseconds since the last checkpoint that force one. Default 750. */
|
|
164
|
+
intervalMs?: number;
|
|
165
|
+
/** Retries for a failing commit before the slice is left for the next trigger. Default 3. */
|
|
166
|
+
retries?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface AuthorizeStreamInput<User> {
|
|
170
|
+
user: User;
|
|
171
|
+
streamId: string;
|
|
172
|
+
/** Where the subscriber claims to be. */
|
|
173
|
+
from: number;
|
|
174
|
+
/** The `meta` this stream was opened with — `undefined` when the stream is not hosted here, which
|
|
175
|
+
* is precisely when the app must decide from `streamId` and its own durable state. */
|
|
176
|
+
meta: unknown;
|
|
177
|
+
request?: unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface RindleStreamOptions<User> {
|
|
181
|
+
/** Where checkpoints land: the app's tables (the default path) or a raw `commit` callback. */
|
|
182
|
+
checkpoint: StreamCheckpointTarget;
|
|
183
|
+
/** REQUIRED. Subscribing to a stream is reading someone's chat, so there is no default-allow.
|
|
184
|
+
* Runs BEFORE existence is checked, so a denial cannot be used to probe for stream ids. */
|
|
185
|
+
authorize: Authorizer<AuthorizeStreamInput<User>>;
|
|
186
|
+
policy?: StreamCheckpointPolicy;
|
|
187
|
+
/** This process's identity — it must be UNIQUE per producer process, because the open CAS trusts
|
|
188
|
+
* it to distinguish rivals (§5.1). In `tables` mode, map {@link StreamColumns.host} and the open
|
|
189
|
+
* write persists it on the message row (so the app can route later subscribers to the hosting
|
|
190
|
+
* instance, §4) and uses it as the single-flight token; setting it WITHOUT a mapped `host` column
|
|
191
|
+
* is refused at construction. In `commit` mode it rides the `open` input. When a `host` column is
|
|
192
|
+
* mapped and no hostId is given, a random per-plane token is used — the CAS still holds, routing
|
|
193
|
+
* just has no stable name to read. */
|
|
194
|
+
hostId?: string;
|
|
195
|
+
/** Slack retained BELOW `durableSeq` so a client whose IVM view lags a checkpoint can still join
|
|
196
|
+
* without a `stale` round trip. Text at or above `durableSeq` is never trimmed. Default 64 KiB.
|
|
197
|
+
* `commit`-mode only — REFUSED (a construction-time `TypeError`) in `tables` mode, where
|
|
198
|
+
* compaction needs the whole produced text at close (§3.4), so the buffer is retained in full. */
|
|
199
|
+
retainChars?: number;
|
|
200
|
+
/** How long a sealed stream stays joinable before eviction. Default 30s. */
|
|
201
|
+
lingerMs?: number;
|
|
202
|
+
/** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024. */
|
|
203
|
+
maxQueuedFrames?: number;
|
|
204
|
+
/** A checkpoint that exhausted its retries. The stream keeps streaming — this is a durability
|
|
205
|
+
* stall, not a stream stall — so the error must not vanish. Absent ⇒ `console.error`. */
|
|
206
|
+
onCheckpointError?: (err: unknown, info: { streamId: string; from: number; seq: number }) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ------------------------------------------------------------------------------- producer handle
|
|
210
|
+
|
|
211
|
+
export interface OpenStreamInput<User> {
|
|
212
|
+
user: User;
|
|
213
|
+
/** The app's message-row id: the durable pointer AND the live plane's key. The row must already
|
|
214
|
+
* exist (the app's own mutator wrote it, alongside the user's prompt) with `seq = 0`. */
|
|
215
|
+
streamId: string;
|
|
216
|
+
/** Opaque app payload, passed through to a `commit` callback. Unused in `tables` mode. */
|
|
217
|
+
meta?: unknown;
|
|
218
|
+
request?: unknown;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The producer's handle (§2). One writer per stream, by construction. */
|
|
222
|
+
export interface StreamHandle {
|
|
223
|
+
readonly streamId: string;
|
|
224
|
+
/** Total produced code units. */
|
|
225
|
+
readonly seq: number;
|
|
226
|
+
/** Total code units the store has committed. Never exceeds {@link seq} (contract P). */
|
|
227
|
+
readonly durableSeq: number;
|
|
228
|
+
/** True once a checkpoint round-trip has seen the reader's cancel flag (§6). `pump` stops on it;
|
|
229
|
+
* a hand-rolled generation loop should check it. */
|
|
230
|
+
readonly cancelled: boolean;
|
|
231
|
+
/** Append a delta: fanned to subscribers synchronously, checkpointed on policy. */
|
|
232
|
+
push(text: string): void;
|
|
233
|
+
/** Force a checkpoint and resolve once the store holds every character produced so far. This is
|
|
234
|
+
* the ORDERING primitive: `await flush()` before writing a discrete row (a tool call, a stop
|
|
235
|
+
* reason) so the text precedes it in the store (§1). Rejects if the checkpoint cannot commit. */
|
|
236
|
+
flush(): Promise<number>;
|
|
237
|
+
/** Drain a delta iterable into the stream (the shape every LLM SDK's text stream already has),
|
|
238
|
+
* stopping early — and closing the iterator, which aborts the underlying request — once the
|
|
239
|
+
* reader has cancelled. */
|
|
240
|
+
pump(deltas: AsyncIterable<string>): Promise<void>;
|
|
241
|
+
/** Seal the stream: `cancelled` if the reader asked it to stop, else `complete`. In `tables` mode
|
|
242
|
+
* this is the compaction (§3.4) — it writes the whole body and drops the chunk rows in one
|
|
243
|
+
* transaction, so it also REPAIRS any checkpoint that failed along the way. */
|
|
244
|
+
close(): Promise<void>;
|
|
245
|
+
/** Seal `error` at whatever was produced. Never throws for the reason it is sealing. */
|
|
246
|
+
fail(error: unknown): Promise<void>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface SubscribeStreamInput<User> {
|
|
250
|
+
user: User;
|
|
251
|
+
streamId: string;
|
|
252
|
+
/** "I already have this many characters" — from the client's IVM view, or a `Last-Event-ID`.
|
|
253
|
+
* A non-negative integer; default 0. */
|
|
254
|
+
from?: number;
|
|
255
|
+
request?: unknown;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface StreamSubscription {
|
|
259
|
+
readonly streamId: string;
|
|
260
|
+
/** Terminates after exactly one of `end` / `stale` / `absent`. */
|
|
261
|
+
readonly frames: AsyncIterable<StreamFrame>;
|
|
262
|
+
/** Detach early (a disconnected client). Idempotent. */
|
|
263
|
+
close(): void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ------------------------------------------------------------------------------- defaults
|
|
267
|
+
|
|
268
|
+
const DEFAULT_CHECKPOINT_CHARS = 512;
|
|
269
|
+
const DEFAULT_CHECKPOINT_INTERVAL_MS = 750;
|
|
270
|
+
const DEFAULT_CHECKPOINT_RETRIES = 3;
|
|
271
|
+
const DEFAULT_RETAIN_CHARS = 64 * 1024;
|
|
272
|
+
const DEFAULT_LINGER_MS = 30_000;
|
|
273
|
+
const DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
274
|
+
/** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
|
|
275
|
+
* authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
|
|
276
|
+
const RETRY_BACKOFF_MS = 50;
|
|
277
|
+
|
|
278
|
+
/** A SCHEDULING timer — the checkpoint cadence, the linger window, the SSE keep-alive. It never
|
|
279
|
+
* holds the process open: none of them is work in flight, and a stream plane must not keep a CLI
|
|
280
|
+
* alive. `unref` is Node-only — Workers' `setTimeout` has no such method, hence the guard. */
|
|
281
|
+
function timer(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
282
|
+
const t = setTimeout(fn, ms);
|
|
283
|
+
(t as { unref?: () => void }).unref?.();
|
|
284
|
+
return t;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The retry backoff, and deliberately NOT unref'd: a checkpoint mid-retry IS work in flight, and a
|
|
288
|
+
* process that exits during it drops text the caller is still awaiting. */
|
|
289
|
+
function delay(ms: number): Promise<void> {
|
|
290
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function errText(err: unknown): string {
|
|
294
|
+
return String((err as Error)?.message ?? err);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A per-plane stand-in for {@link RindleStreamOptions.hostId} when only the open CAS — not
|
|
298
|
+
* subscribe routing — needs a token. */
|
|
299
|
+
function randomOpenToken(): string {
|
|
300
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
301
|
+
return c?.randomUUID?.() ?? `open-${Math.random().toString(36).slice(2)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------------------------- mapped-table SQL
|
|
305
|
+
|
|
306
|
+
/** Every mapped column, defaults applied. `cancel`/`error` stay `undefined` unless named. */
|
|
307
|
+
export interface ResolvedStreamColumns {
|
|
308
|
+
key: string;
|
|
309
|
+
body: string;
|
|
310
|
+
status: string;
|
|
311
|
+
seq: string;
|
|
312
|
+
cancel?: string;
|
|
313
|
+
error?: string;
|
|
314
|
+
host?: string;
|
|
315
|
+
chunkKey: string;
|
|
316
|
+
chunkStream: string;
|
|
317
|
+
chunkSeq: string;
|
|
318
|
+
chunkText: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function resolveStreamColumns(columns: StreamColumns | undefined): ResolvedStreamColumns {
|
|
322
|
+
return {
|
|
323
|
+
key: columns?.key ?? "id",
|
|
324
|
+
body: columns?.body ?? "body",
|
|
325
|
+
status: columns?.status ?? "status",
|
|
326
|
+
seq: columns?.seq ?? "seq",
|
|
327
|
+
...(columns?.cancel !== undefined ? { cancel: columns.cancel } : {}),
|
|
328
|
+
...(columns?.error !== undefined ? { error: columns.error } : {}),
|
|
329
|
+
...(columns?.host !== undefined ? { host: columns.host } : {}),
|
|
330
|
+
chunkKey: columns?.chunkKey ?? "id",
|
|
331
|
+
chunkStream: columns?.chunkStream ?? "streamId",
|
|
332
|
+
chunkSeq: columns?.chunkSeq ?? "seq",
|
|
333
|
+
chunkText: columns?.chunkText ?? "text",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Quote an identifier for both dialects (`"x"` is standard in SQLite and Postgres alike). An
|
|
338
|
+
* embedded quote is refused rather than escaped: a mapping is app configuration, and a name that
|
|
339
|
+
* needs escaping is a mistake worth failing loudly on. */
|
|
340
|
+
function q(identifier: string): string {
|
|
341
|
+
if (identifier.includes('"')) throw new TypeError(`invalid stream column/table name: ${JSON.stringify(identifier)}`);
|
|
342
|
+
return `"${identifier}"`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The chunk row's deterministic id: a replayed checkpoint collides with itself and is absorbed by
|
|
346
|
+
* `ON CONFLICT DO NOTHING` — idempotency without an envelope or a dedup ledger (§3.3). */
|
|
347
|
+
export function streamChunkId(streamId: string, seq: number): string {
|
|
348
|
+
return `${streamId}:${seq}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The chunk table's DDL, for the app's migration. The app owns the message table (this only states
|
|
353
|
+
* the three columns the plane needs on it); the chunk table is entirely protocol-shaped, so it is
|
|
354
|
+
* generated rather than hand-written.
|
|
355
|
+
*/
|
|
356
|
+
export function streamChunkTableDdl(tables: StreamTables, dialect: SqlDialect): string[] {
|
|
357
|
+
const c = resolveStreamColumns(tables.columns);
|
|
358
|
+
const text = dialect.name === "postgres" ? "text" : "TEXT";
|
|
359
|
+
const int = dialect.name === "postgres" ? "bigint" : "INTEGER";
|
|
360
|
+
return [
|
|
361
|
+
`CREATE TABLE IF NOT EXISTS ${q(tables.chunks)} (` +
|
|
362
|
+
`${q(c.chunkKey)} ${text} PRIMARY KEY, ` +
|
|
363
|
+
`${q(c.chunkStream)} ${text} NOT NULL, ` +
|
|
364
|
+
`${q(c.chunkSeq)} ${int} NOT NULL, ` +
|
|
365
|
+
`${q(c.chunkText)} ${text} NOT NULL)`,
|
|
366
|
+
// The read path is always "this message's chunks, in order" — and compaction deletes by stream.
|
|
367
|
+
`CREATE INDEX IF NOT EXISTS ${q(`${tables.chunks}_stream_seq`)} ` +
|
|
368
|
+
`ON ${q(tables.chunks)} (${q(c.chunkStream)}, ${q(c.chunkSeq)})`,
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The plane's SQL, one place, both dialects. */
|
|
373
|
+
class MappedTableSql {
|
|
374
|
+
private readonly tables: StreamTables;
|
|
375
|
+
private readonly cols: ResolvedStreamColumns;
|
|
376
|
+
private readonly dialect: SqlDialect;
|
|
377
|
+
|
|
378
|
+
constructor(tables: StreamTables, dialect: SqlDialect) {
|
|
379
|
+
this.tables = tables;
|
|
380
|
+
this.cols = resolveStreamColumns(tables.columns);
|
|
381
|
+
this.dialect = dialect;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private p(i: number): string {
|
|
385
|
+
return this.dialect.placeholder(i);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The open probe: the row must exist and be empty. A generation is never a resume — regenerating
|
|
389
|
+
* is a NEW message id — so an already-advanced row is a bug worth refusing loudly. */
|
|
390
|
+
probeOpen(): string {
|
|
391
|
+
const { key, seq, status } = this.cols;
|
|
392
|
+
return `SELECT ${q(seq)} AS seq, ${q(status)} AS status FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The open write. With a `host` column mapped this is the CAS half of the single-flight guard:
|
|
396
|
+
* set the status AND this producer's token only if no producer holds the row, verified by the
|
|
397
|
+
* read-back in {@link probeHost}. Without one it is the plain flip the probe already vetted. */
|
|
398
|
+
markStreaming(streamId: string, hostToken: string): SqlStatement {
|
|
399
|
+
const { key, status, host } = this.cols;
|
|
400
|
+
if (host !== undefined) {
|
|
401
|
+
return {
|
|
402
|
+
sql:
|
|
403
|
+
`UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)}, ${q(host)} = ${this.p(2)} ` +
|
|
404
|
+
`WHERE ${q(key)} = ${this.p(3)} AND ${q(status)} <> ${this.p(4)}`,
|
|
405
|
+
params: [STREAM_STATUS_STREAMING, hostToken, streamId, STREAM_STATUS_STREAMING],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)} WHERE ${q(key)} = ${this.p(2)}`,
|
|
410
|
+
params: [STREAM_STATUS_STREAMING, streamId],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The read-back half of the open CAS, or `undefined` when no `host` column is mapped. */
|
|
415
|
+
probeHost(): string | undefined {
|
|
416
|
+
const { host, key } = this.cols;
|
|
417
|
+
if (host === undefined) return undefined;
|
|
418
|
+
return `SELECT ${q(host)} AS host FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** ONE transaction: insert the slice, then CAS the message's durable length. BOTH statements are
|
|
422
|
+
* gated on the same `seq = :from` predicate: the CAS refuses to apply twice or out of order, and
|
|
423
|
+
* the guarded insert makes a STALE RE-COVER a no-op — after a committed-but-ack-lost append, the
|
|
424
|
+
* next slice's `from` lags the row, and an unguarded insert would land an OVERLAPPING chunk row
|
|
425
|
+
* (a different deterministic id, so `ON CONFLICT` alone cannot absorb it) and corrupt the
|
|
426
|
+
* assembled text until compaction. The `ON CONFLICT` clause stays as the belt under that
|
|
427
|
+
* guard's braces. Which case actually happened is decided by the read-back ({@link probeState}),
|
|
428
|
+
* never assumed (§3.2/§3.3). */
|
|
429
|
+
append(streamId: string, from: number, seq: number, text: string): SqlStatement[] {
|
|
430
|
+
const c = this.cols;
|
|
431
|
+
return [
|
|
432
|
+
{
|
|
433
|
+
sql:
|
|
434
|
+
`INSERT INTO ${q(this.tables.chunks)} ` +
|
|
435
|
+
`(${q(c.chunkKey)}, ${q(c.chunkStream)}, ${q(c.chunkSeq)}, ${q(c.chunkText)}) ` +
|
|
436
|
+
`SELECT ${this.p(1)}, ${this.p(2)}, ${this.p(3)}, ${this.p(4)} ` +
|
|
437
|
+
`WHERE EXISTS (SELECT 1 FROM ${q(this.tables.message)} ` +
|
|
438
|
+
`WHERE ${q(c.key)} = ${this.p(5)} AND ${q(c.seq)} = ${this.p(6)}) ` +
|
|
439
|
+
`ON CONFLICT DO NOTHING`,
|
|
440
|
+
params: [streamChunkId(streamId, seq), streamId, seq, text, streamId, from],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
sql:
|
|
444
|
+
`UPDATE ${q(this.tables.message)} SET ${q(c.seq)} = ${this.p(1)} ` +
|
|
445
|
+
`WHERE ${q(c.key)} = ${this.p(2)} AND ${q(c.seq)} = ${this.p(3)}`,
|
|
446
|
+
params: [seq, streamId, from],
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Compaction (§3.4): write the whole body, seal the status, drop every chunk — atomically. Also
|
|
452
|
+
* the repair path: whatever the chunks did or did not capture, the body ends up correct. */
|
|
453
|
+
compact(streamId: string, body: string, status: StreamStatus, error: string | undefined): SqlStatement[] {
|
|
454
|
+
const c = this.cols;
|
|
455
|
+
const sets = [`${q(c.body)} = ${this.p(1)}`, `${q(c.seq)} = ${this.p(2)}`, `${q(c.status)} = ${this.p(3)}`];
|
|
456
|
+
const params: Array<string | number | null> = [body, body.length, status];
|
|
457
|
+
if (c.error !== undefined) {
|
|
458
|
+
sets.push(`${q(c.error)} = ${this.p(params.length + 1)}`);
|
|
459
|
+
params.push(error ?? null);
|
|
460
|
+
}
|
|
461
|
+
params.push(streamId);
|
|
462
|
+
return [
|
|
463
|
+
{
|
|
464
|
+
sql: `UPDATE ${q(this.tables.message)} SET ${sets.join(", ")} WHERE ${q(c.key)} = ${this.p(params.length)}`,
|
|
465
|
+
params,
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
sql: `DELETE FROM ${q(this.tables.chunks)} WHERE ${q(c.chunkStream)} = ${this.p(1)}`,
|
|
469
|
+
params: [streamId],
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
get hasCancel(): boolean {
|
|
475
|
+
return this.cols.cancel !== undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The post-append read-back: the row's authoritative length — what CONFIRMS an append applied
|
|
479
|
+
* (and absorbs a committed-but-ack-lost one) — plus the cancel flag when mapped (§6). */
|
|
480
|
+
probeState(): string {
|
|
481
|
+
const { cancel, key, seq } = this.cols;
|
|
482
|
+
const picked = cancel === undefined ? `${q(seq)} AS seq` : `${q(seq)} AS seq, ${q(cancel)} AS cancel`;
|
|
483
|
+
return `SELECT ${picked} FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ------------------------------------------------------------------------------- subscribers
|
|
488
|
+
|
|
489
|
+
/** One attached reader. Frames queue; a reader that stops draining is bounded and then DROPPED with
|
|
490
|
+
* `stale` rather than allowed to pin the producer's memory — it resolves that by rejoining. */
|
|
491
|
+
class Subscriber {
|
|
492
|
+
private queue: StreamFrame[] = [];
|
|
493
|
+
private waiter?: (r: IteratorResult<StreamFrame>) => void;
|
|
494
|
+
private done = false;
|
|
495
|
+
private readonly cap: number;
|
|
496
|
+
private readonly onDetach: (s: Subscriber) => void;
|
|
497
|
+
|
|
498
|
+
constructor(cap: number, onDetach: (s: Subscriber) => void) {
|
|
499
|
+
this.cap = cap;
|
|
500
|
+
this.onDetach = onDetach;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** @returns false when the queue overflowed (caller drops this subscriber). */
|
|
504
|
+
offer(frame: StreamFrame): boolean {
|
|
505
|
+
if (this.done) return true;
|
|
506
|
+
if (this.waiter) {
|
|
507
|
+
const w = this.waiter;
|
|
508
|
+
this.waiter = undefined;
|
|
509
|
+
w({ value: frame, done: false });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (this.queue.length >= this.cap) return false;
|
|
513
|
+
this.queue.push(frame);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Deliver a terminal frame (bypassing the cap — it is the last one) and end the iterator. */
|
|
518
|
+
finish(frame: StreamFrame): void {
|
|
519
|
+
if (this.done) return;
|
|
520
|
+
if (this.waiter) {
|
|
521
|
+
const w = this.waiter;
|
|
522
|
+
this.waiter = undefined;
|
|
523
|
+
this.done = true;
|
|
524
|
+
w({ value: frame, done: false });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.queue.push(frame);
|
|
528
|
+
this.done = true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
close(): void {
|
|
532
|
+
this.done = true;
|
|
533
|
+
this.queue.length = 0;
|
|
534
|
+
if (this.waiter) {
|
|
535
|
+
const w = this.waiter;
|
|
536
|
+
this.waiter = undefined;
|
|
537
|
+
w({ value: undefined as never, done: true });
|
|
538
|
+
}
|
|
539
|
+
this.onDetach(this);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
frames(): AsyncIterable<StreamFrame> {
|
|
543
|
+
const self = this;
|
|
544
|
+
return {
|
|
545
|
+
[Symbol.asyncIterator](): AsyncIterator<StreamFrame> {
|
|
546
|
+
return {
|
|
547
|
+
next(): Promise<IteratorResult<StreamFrame>> {
|
|
548
|
+
const queued = self.queue.shift();
|
|
549
|
+
if (queued !== undefined) return Promise.resolve({ value: queued, done: false });
|
|
550
|
+
if (self.done) {
|
|
551
|
+
self.onDetach(self);
|
|
552
|
+
return Promise.resolve({ value: undefined as never, done: true });
|
|
553
|
+
}
|
|
554
|
+
return new Promise((resolve) => {
|
|
555
|
+
self.waiter = resolve;
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
return(): Promise<IteratorResult<StreamFrame>> {
|
|
559
|
+
self.close();
|
|
560
|
+
return Promise.resolve({ value: undefined as never, done: true });
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------------------------- the live stream
|
|
569
|
+
|
|
570
|
+
interface Waiter {
|
|
571
|
+
at: number;
|
|
572
|
+
resolve: (seq: number) => void;
|
|
573
|
+
reject: (err: unknown) => void;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
class LiveStream<User> {
|
|
577
|
+
/** Retained text; `buf[0]` sits at offset {@link bufFrom}. */
|
|
578
|
+
private buf = "";
|
|
579
|
+
private bufFrom = 0;
|
|
580
|
+
seq = 0;
|
|
581
|
+
durableSeq = 0;
|
|
582
|
+
ended = false;
|
|
583
|
+
cancelled = false;
|
|
584
|
+
|
|
585
|
+
readonly streamId: string;
|
|
586
|
+
readonly user: User;
|
|
587
|
+
readonly meta: unknown;
|
|
588
|
+
private readonly plane: StreamPlane<User>;
|
|
589
|
+
|
|
590
|
+
private readonly subs = new Set<Subscriber>();
|
|
591
|
+
private sealing?: { status: StreamStatus; error?: string };
|
|
592
|
+
private draining = false;
|
|
593
|
+
private forced = false;
|
|
594
|
+
/** The last drain round left a slice uncommitted: wait out the interval instead of re-attempting
|
|
595
|
+
* immediately, so a down store costs a retry cadence and not a hot loop. */
|
|
596
|
+
private stalled = false;
|
|
597
|
+
private tick: ReturnType<typeof setTimeout> | null = null;
|
|
598
|
+
private waiters: Waiter[] = [];
|
|
599
|
+
private sealed?: { resolve: () => void; reject: (e: unknown) => void; promise: Promise<void> };
|
|
600
|
+
private sealError: unknown;
|
|
601
|
+
|
|
602
|
+
constructor(streamId: string, user: User, meta: unknown, plane: StreamPlane<User>) {
|
|
603
|
+
this.streamId = streamId;
|
|
604
|
+
this.user = user;
|
|
605
|
+
this.meta = meta;
|
|
606
|
+
this.plane = plane;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---- producer side
|
|
610
|
+
|
|
611
|
+
push(text: string): void {
|
|
612
|
+
if (this.sealing || this.ended) throw new Error(`stream ${this.streamId} is closed`);
|
|
613
|
+
if (text.length === 0) return;
|
|
614
|
+
const from = this.seq;
|
|
615
|
+
this.buf += text;
|
|
616
|
+
this.seq += text.length;
|
|
617
|
+
this.fanout({ type: "chunk", from, seq: this.seq, text });
|
|
618
|
+
if (this.seq - this.durableSeq >= this.plane.chars) this.kick(true);
|
|
619
|
+
else this.arm();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
flush(): Promise<number> {
|
|
623
|
+
const at = this.seq;
|
|
624
|
+
if (this.durableSeq >= at) return Promise.resolve(this.durableSeq);
|
|
625
|
+
// After a failed seal the shortfall is PERMANENT (the stream ended; nothing will retry it), so a
|
|
626
|
+
// late flush rejects honestly instead of quietly re-running the seal.
|
|
627
|
+
if (this.ended) {
|
|
628
|
+
return Promise.reject(
|
|
629
|
+
new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`),
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const p = new Promise<number>((resolve, reject) => {
|
|
633
|
+
this.waiters.push({ at, resolve, reject });
|
|
634
|
+
});
|
|
635
|
+
this.kick(true);
|
|
636
|
+
return p;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Seal the stream. Resolves once the terminal checkpoint has committed; rejects if it could not
|
|
640
|
+
* (the stream still ENDS — subscribers always get their `end` frame — the caller just learns the
|
|
641
|
+
* store is short of what was produced). */
|
|
642
|
+
seal(status: StreamStatus, error?: string): Promise<void> {
|
|
643
|
+
// Already sealed: hand back the SAME settled promise (already marked handled below), so a second
|
|
644
|
+
// `close()`/`fail()` can neither re-seal nor mint a stray rejection.
|
|
645
|
+
if (this.ended) return this.sealed?.promise ?? Promise.resolve();
|
|
646
|
+
if (!this.sealing) {
|
|
647
|
+
this.sealing = { status, error };
|
|
648
|
+
let resolve!: () => void;
|
|
649
|
+
let reject!: (e: unknown) => void;
|
|
650
|
+
const promise = new Promise<void>((res, rej) => {
|
|
651
|
+
resolve = res;
|
|
652
|
+
reject = rej;
|
|
653
|
+
});
|
|
654
|
+
// A `fail()` nobody awaited must not become an unhandled rejection at process level. This
|
|
655
|
+
// marks the promise handled WITHOUT consuming it — a caller that does await still sees the
|
|
656
|
+
// rejection, because `await` attaches its own handler to the same promise.
|
|
657
|
+
promise.catch(() => {});
|
|
658
|
+
this.sealed = { resolve, reject, promise };
|
|
659
|
+
this.disarm();
|
|
660
|
+
this.kick(true);
|
|
661
|
+
}
|
|
662
|
+
return this.sealed!.promise;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---- checkpoint driving
|
|
666
|
+
|
|
667
|
+
private arm(): void {
|
|
668
|
+
if (this.tick || this.seq === this.durableSeq) return;
|
|
669
|
+
this.tick = timer(() => {
|
|
670
|
+
this.tick = null;
|
|
671
|
+
this.kick(true);
|
|
672
|
+
}, this.plane.intervalMs);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private disarm(): void {
|
|
676
|
+
if (this.tick) clearTimeout(this.tick);
|
|
677
|
+
this.tick = null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private kick(force: boolean): void {
|
|
681
|
+
if (force) this.forced = true;
|
|
682
|
+
if (this.draining) return;
|
|
683
|
+
this.draining = true;
|
|
684
|
+
void this.drain();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async drain(): Promise<void> {
|
|
688
|
+
this.stalled = false;
|
|
689
|
+
try {
|
|
690
|
+
for (;;) {
|
|
691
|
+
// Sealing goes STRAIGHT to the seal: it writes the whole body (§3.4), so committing the
|
|
692
|
+
// outstanding tail as one more chunk first would be pure waste — and the seal repairs any
|
|
693
|
+
// earlier checkpoint that failed, which a tail commit could not.
|
|
694
|
+
if (this.sealing) {
|
|
695
|
+
await this.commitSeal();
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
const behind = this.seq - this.durableSeq;
|
|
699
|
+
if (behind > 0 && (this.forced || behind >= this.plane.chars)) {
|
|
700
|
+
// Consume the force BEFORE the round, not after: a `flush()` that lands while this round
|
|
701
|
+
// is already in flight re-sets it and gets a round of its OWN — clearing it afterwards
|
|
702
|
+
// would swallow that request and leave the flush to the interval cadence.
|
|
703
|
+
this.forced = false;
|
|
704
|
+
this.disarm();
|
|
705
|
+
const failure = await this.commitTail();
|
|
706
|
+
if (failure) {
|
|
707
|
+
this.stalled = true;
|
|
708
|
+
// A seal requested WHILE this checkpoint was in flight outranks the retry cadence: the
|
|
709
|
+
// seal writes the whole body anyway, so it both supersedes and repairs the failed slice.
|
|
710
|
+
// Backing off here instead would make `close()` wait out `intervalMs` for nothing. An
|
|
711
|
+
// explicit re-force during the failed round likewise earns one immediate re-attempt —
|
|
712
|
+
// someone is waiting on it — while an unforced failure stalls to the cadence.
|
|
713
|
+
if (!this.sealing && !this.forced) break;
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
this.forced = false;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
} finally {
|
|
721
|
+
this.draining = false;
|
|
722
|
+
if (!this.ended) {
|
|
723
|
+
// A pending seal ALWAYS proceeds at once — including out of a stall (see the loop above);
|
|
724
|
+
// otherwise a stall waits out the interval, and deltas that landed mid-commit may already
|
|
725
|
+
// meet the threshold and deserve an immediate round.
|
|
726
|
+
if (this.sealing) this.kick(false);
|
|
727
|
+
else if (this.stalled) this.arm();
|
|
728
|
+
else if (this.seq - this.durableSeq >= this.plane.chars) this.kick(false);
|
|
729
|
+
else this.arm();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @returns the failure, or `undefined` when the slice committed. */
|
|
735
|
+
private async commitTail(): Promise<{ err: unknown } | undefined> {
|
|
736
|
+
const from = this.durableSeq;
|
|
737
|
+
const to = this.seq;
|
|
738
|
+
const text = this.buf.slice(from - this.bufFrom, to - this.bufFrom);
|
|
739
|
+
let out: { cancelRequested?: boolean; seq?: number } | void;
|
|
740
|
+
try {
|
|
741
|
+
out = await this.plane.commit({ kind: "append", streamId: this.streamId, from, seq: to, text });
|
|
742
|
+
} catch (err) {
|
|
743
|
+
// The store may hold MORE than we believe — an append that committed while its ack was lost,
|
|
744
|
+
// somewhere in the retry chain. The read-back is the truth, and adopting it is what keeps the
|
|
745
|
+
// NEXT slice contiguous instead of overlapping (§3.3).
|
|
746
|
+
const truth = await this.plane.probeDurableSeq(this.streamId);
|
|
747
|
+
if (truth !== undefined && truth > this.durableSeq) this.advanceDurable(truth);
|
|
748
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
|
|
749
|
+
// Waiters that asked for THIS prefix learn it did not land; later waiters keep waiting for a
|
|
750
|
+
// later attempt. A durability failure is never silent.
|
|
751
|
+
this.rejectWaiters(to, err);
|
|
752
|
+
return { err };
|
|
753
|
+
}
|
|
754
|
+
if (out?.cancelRequested) this.cancelled = true;
|
|
755
|
+
// `durableSeq` advances to what the store CONFIRMED — the mapped path reads it back, a `commit`
|
|
756
|
+
// callback may report it — never to a plane-side assumption. A confirmation short of `to` means
|
|
757
|
+
// this slice's `from` was stale (a prior ack loss the read-back could not see at the time): the
|
|
758
|
+
// guarded statements made it a no-op, and the next round re-covers from the confirmed offset.
|
|
759
|
+
const confirmed = out?.seq ?? to;
|
|
760
|
+
if (confirmed > this.durableSeq) this.advanceDurable(confirmed);
|
|
761
|
+
if (confirmed < to) {
|
|
762
|
+
const err = new Error(
|
|
763
|
+
`stream ${this.streamId}: the store confirmed ${confirmed} of ${to} — re-covering from there`,
|
|
764
|
+
);
|
|
765
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
|
|
766
|
+
this.rejectWaiters(to, err);
|
|
767
|
+
return { err };
|
|
768
|
+
}
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** The one place durable progress is recorded: position, `durable` frame, buffer trim, waiters. */
|
|
773
|
+
private advanceDurable(seq: number): void {
|
|
774
|
+
this.durableSeq = seq;
|
|
775
|
+
this.fanout({ type: "durable", seq });
|
|
776
|
+
this.trim();
|
|
777
|
+
this.resolveWaiters();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private async commitSeal(): Promise<void> {
|
|
781
|
+
const seal = this.sealing!;
|
|
782
|
+
const from = this.durableSeq;
|
|
783
|
+
try {
|
|
784
|
+
await this.plane.commit({
|
|
785
|
+
kind: "close",
|
|
786
|
+
streamId: this.streamId,
|
|
787
|
+
from,
|
|
788
|
+
seq: this.seq,
|
|
789
|
+
// A trimmed buffer (only reachable in `commit`-callback mode, where the app opted into
|
|
790
|
+
// trimming) cannot supply the whole body: `bodyFrom` says where the retained text starts,
|
|
791
|
+
// so the app can still append exactly the outstanding tail (`body.slice(from - bodyFrom)`).
|
|
792
|
+
body: this.buf,
|
|
793
|
+
bodyFrom: this.bufFrom,
|
|
794
|
+
status: seal.status,
|
|
795
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
796
|
+
});
|
|
797
|
+
this.durableSeq = this.seq;
|
|
798
|
+
} catch (err) {
|
|
799
|
+
this.sealError = err;
|
|
800
|
+
this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: this.seq });
|
|
801
|
+
}
|
|
802
|
+
this.ended = true;
|
|
803
|
+
this.disarm();
|
|
804
|
+
this.rejectWaiters(
|
|
805
|
+
Number.POSITIVE_INFINITY,
|
|
806
|
+
new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`),
|
|
807
|
+
);
|
|
808
|
+
this.resolveWaiters();
|
|
809
|
+
const frame: StreamFrame = {
|
|
810
|
+
type: "end",
|
|
811
|
+
seq: this.durableSeq,
|
|
812
|
+
status: seal.status,
|
|
813
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
814
|
+
};
|
|
815
|
+
for (const sub of [...this.subs]) sub.finish(frame);
|
|
816
|
+
this.plane.retire(this.streamId);
|
|
817
|
+
if (this.sealError) this.sealed?.reject(this.sealError);
|
|
818
|
+
else this.sealed?.resolve();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Keep everything at or above `durableSeq`, plus a slack window below it so a client whose IVM
|
|
822
|
+
* view lags one checkpoint can still join without a `stale` round trip (§4). A `retainChars` of
|
|
823
|
+
* `Infinity` (the mapped-table default — compaction needs the whole text) never trims. */
|
|
824
|
+
private trim(): void {
|
|
825
|
+
if (!Number.isFinite(this.plane.retainChars)) return;
|
|
826
|
+
const floor = Math.max(0, this.durableSeq - this.plane.retainChars);
|
|
827
|
+
if (floor <= this.bufFrom) return;
|
|
828
|
+
this.buf = this.buf.slice(floor - this.bufFrom);
|
|
829
|
+
this.bufFrom = floor;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private resolveWaiters(): void {
|
|
833
|
+
if (this.waiters.length === 0) return;
|
|
834
|
+
const still: Waiter[] = [];
|
|
835
|
+
for (const w of this.waiters) {
|
|
836
|
+
if (w.at <= this.durableSeq) w.resolve(this.durableSeq);
|
|
837
|
+
else still.push(w);
|
|
838
|
+
}
|
|
839
|
+
this.waiters = still;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private rejectWaiters(upTo: number, err: unknown): void {
|
|
843
|
+
if (this.waiters.length === 0) return;
|
|
844
|
+
const still: Waiter[] = [];
|
|
845
|
+
for (const w of this.waiters) {
|
|
846
|
+
if (w.at <= upTo && w.at > this.durableSeq) w.reject(err);
|
|
847
|
+
else still.push(w);
|
|
848
|
+
}
|
|
849
|
+
this.waiters = still;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---- subscriber side
|
|
853
|
+
|
|
854
|
+
private fanout(frame: StreamFrame): void {
|
|
855
|
+
for (const sub of [...this.subs]) {
|
|
856
|
+
if (!sub.offer(frame)) {
|
|
857
|
+
// Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
|
|
858
|
+
// producer's memory.
|
|
859
|
+
sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
|
|
860
|
+
this.subs.delete(sub);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Attach a reader at `from`. Runs in ONE synchronous block — snapshot, replay, register — so no
|
|
866
|
+
* delta can slip between the replay and the live tail (the gap-free half of contract S). */
|
|
867
|
+
subscribe(from: number): StreamSubscription {
|
|
868
|
+
const sub = new Subscriber(this.plane.maxQueuedFrames, (s) => this.subs.delete(s));
|
|
869
|
+
// Floored as well as clamped: a fractional `from` (a hand-built subscribe input) would otherwise
|
|
870
|
+
// produce a replay chunk whose `text.length !== seq - from`, breaking the frame invariant.
|
|
871
|
+
const at = Math.min(Math.max(Math.floor(from), 0), this.seq);
|
|
872
|
+
if (at < this.bufFrom) {
|
|
873
|
+
sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
|
|
874
|
+
return this.wrap(sub);
|
|
875
|
+
}
|
|
876
|
+
sub.offer({ type: "open", streamId: this.streamId, from: at, seq: this.seq, durableSeq: this.durableSeq, ended: this.ended });
|
|
877
|
+
if (this.seq > at) {
|
|
878
|
+
sub.offer({ type: "chunk", from: at, seq: this.seq, text: this.buf.slice(at - this.bufFrom) });
|
|
879
|
+
}
|
|
880
|
+
if (this.durableSeq > at) sub.offer({ type: "durable", seq: this.durableSeq });
|
|
881
|
+
if (this.ended) {
|
|
882
|
+
const seal = this.sealing ?? { status: "interrupted" as StreamStatus };
|
|
883
|
+
sub.finish({
|
|
884
|
+
type: "end",
|
|
885
|
+
seq: this.durableSeq,
|
|
886
|
+
status: seal.status,
|
|
887
|
+
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
888
|
+
});
|
|
889
|
+
return this.wrap(sub);
|
|
890
|
+
}
|
|
891
|
+
this.subs.add(sub);
|
|
892
|
+
return this.wrap(sub);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private wrap(sub: Subscriber): StreamSubscription {
|
|
896
|
+
return { streamId: this.streamId, frames: sub.frames(), close: () => sub.close() };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Drop every reader without sealing (process teardown — `drainStreams` seals first). */
|
|
900
|
+
detachAll(): void {
|
|
901
|
+
for (const sub of [...this.subs]) sub.close();
|
|
902
|
+
this.subs.clear();
|
|
903
|
+
this.disarm();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ------------------------------------------------------------------------------- the plane
|
|
908
|
+
|
|
909
|
+
/** What the plane needs from the api-server to write a checkpoint: the backend's OUTSIDE-transaction
|
|
910
|
+
* SQL surface (`batch` is one transaction on every backend) and its dialect. Deliberately narrow so
|
|
911
|
+
* `streams.ts` never imports the server (no cycle). */
|
|
912
|
+
export interface StreamSqlSink {
|
|
913
|
+
readonly dialect: SqlDialect;
|
|
914
|
+
readonly sql: ServerSql;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export class StreamPlane<User> {
|
|
918
|
+
readonly chars: number;
|
|
919
|
+
readonly intervalMs: number;
|
|
920
|
+
readonly retries: number;
|
|
921
|
+
readonly retainChars: number;
|
|
922
|
+
readonly lingerMs: number;
|
|
923
|
+
readonly maxQueuedFrames: number;
|
|
924
|
+
|
|
925
|
+
private readonly live = new Map<string, LiveStream<User>>();
|
|
926
|
+
private readonly opts: RindleStreamOptions<User>;
|
|
927
|
+
private readonly sink: StreamSqlSink | undefined;
|
|
928
|
+
private readonly mapped: MappedTableSql | undefined;
|
|
929
|
+
private readonly tables: StreamTables | undefined;
|
|
930
|
+
/** The open CAS token (§5.1): `hostId`, or a random per-plane stand-in when only the CAS — not
|
|
931
|
+
* routing — needs it. */
|
|
932
|
+
private readonly openToken: string;
|
|
933
|
+
|
|
934
|
+
constructor(opts: RindleStreamOptions<User>, sink?: StreamSqlSink) {
|
|
935
|
+
this.opts = opts;
|
|
936
|
+
this.sink = sink;
|
|
937
|
+
this.chars = opts.policy?.chars ?? DEFAULT_CHECKPOINT_CHARS;
|
|
938
|
+
this.intervalMs = opts.policy?.intervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
939
|
+
this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
|
|
940
|
+
this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
|
|
941
|
+
this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
|
|
942
|
+
this.openToken = opts.hostId ?? randomOpenToken();
|
|
943
|
+
if ("tables" in opts.checkpoint) {
|
|
944
|
+
if (!sink) throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
|
|
945
|
+
this.tables = opts.checkpoint.tables;
|
|
946
|
+
this.mapped = new MappedTableSql(opts.checkpoint.tables, sink.dialect);
|
|
947
|
+
// Options this mode cannot honour are refused loudly (the room-profile rule), never ignored.
|
|
948
|
+
if (opts.retainChars !== undefined) {
|
|
949
|
+
throw new TypeError(
|
|
950
|
+
"streams.retainChars is `commit`-mode only — mapped tables retain the whole text for compaction (§3.4)",
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
if (opts.hostId !== undefined && resolveStreamColumns(opts.checkpoint.tables.columns).host === undefined) {
|
|
954
|
+
throw new TypeError("streams.hostId does nothing in tables mode until columns.host names where to persist it");
|
|
955
|
+
}
|
|
956
|
+
// Compaction writes the WHOLE body at close, so the buffer is retained in full. `retainChars`
|
|
957
|
+
// is honoured only where the app owns persistence and may not need the tail.
|
|
958
|
+
this.retainChars = Number.POSITIVE_INFINITY;
|
|
959
|
+
} else {
|
|
960
|
+
this.retainChars = opts.retainChars ?? DEFAULT_RETAIN_CHARS;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Open a stream on an EXISTING message row (§5): the app's own mutator wrote it, alongside the
|
|
965
|
+
* user's prompt, so the pointer is already durable and every client's query already shows the
|
|
966
|
+
* message. This verifies it and flips it to `streaming`. */
|
|
967
|
+
async open(input: OpenStreamInput<User>): Promise<StreamHandle> {
|
|
968
|
+
if (this.live.has(input.streamId)) {
|
|
969
|
+
throw new Error(`stream ${input.streamId} is already open on this host`);
|
|
970
|
+
}
|
|
971
|
+
const stream = new LiveStream<User>(input.streamId, input.user, input.meta, this);
|
|
972
|
+
this.live.set(input.streamId, stream);
|
|
973
|
+
try {
|
|
974
|
+
if (this.mapped) await this.assertOpenable(input.streamId);
|
|
975
|
+
await this.commit({
|
|
976
|
+
kind: "open",
|
|
977
|
+
streamId: input.streamId,
|
|
978
|
+
meta: input.meta ?? null,
|
|
979
|
+
...(this.opts.hostId !== undefined ? { hostId: this.opts.hostId } : {}),
|
|
980
|
+
startedAt: Date.now(),
|
|
981
|
+
});
|
|
982
|
+
} catch (err) {
|
|
983
|
+
this.live.delete(input.streamId);
|
|
984
|
+
throw err;
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
streamId: stream.streamId,
|
|
988
|
+
get seq() {
|
|
989
|
+
return stream.seq;
|
|
990
|
+
},
|
|
991
|
+
get durableSeq() {
|
|
992
|
+
return stream.durableSeq;
|
|
993
|
+
},
|
|
994
|
+
get cancelled() {
|
|
995
|
+
return stream.cancelled;
|
|
996
|
+
},
|
|
997
|
+
push: (text) => stream.push(text),
|
|
998
|
+
flush: () => stream.flush(),
|
|
999
|
+
pump: async (deltas) => {
|
|
1000
|
+
// `for await` closes the iterator on `break`, which is what aborts the SDK's underlying
|
|
1001
|
+
// request — the whole point of honouring cancellation here rather than in the caller.
|
|
1002
|
+
for await (const d of deltas) {
|
|
1003
|
+
stream.push(d);
|
|
1004
|
+
if (stream.cancelled) break;
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
close: () => stream.seal(stream.cancelled ? "cancelled" : "complete"),
|
|
1008
|
+
fail: (error) => stream.seal("error", errText(error)),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The read-only precondition on the app's message row, checked ONCE per `open` (§5.1). Read-only on
|
|
1014
|
+
* purpose: it is a decision about the app's data, so re-deciding it per write attempt would let a
|
|
1015
|
+
* lost ack turn a success into a refusal.
|
|
1016
|
+
*
|
|
1017
|
+
* The `streaming` check is the **single-flight guard**, and it lives at the ROW rather than in this
|
|
1018
|
+
* process's map because the thing it defends against is distributed: the kick that starts a
|
|
1019
|
+
* generation is an at-least-once effect (a retried mutation envelope re-runs its post-commit code,
|
|
1020
|
+
* §10.5), so a second kick can land on another instance, where the in-memory map is empty. Two
|
|
1021
|
+
* producers on one `streamId` would interleave: both CAS the same length, one stalls, and whichever
|
|
1022
|
+
* closes last overwrites the body with ITS buffer. Cheap to refuse; expensive to debug.
|
|
1023
|
+
*
|
|
1024
|
+
* This probe alone is check-then-act — it and the open write are separate round trips, so two
|
|
1025
|
+
* SIMULTANEOUS kicks can both pass it. Mapping a `host` column closes that window: the open write
|
|
1026
|
+
* turns conditional and {@link verifyOpenWinner}'s read-back names the winner.
|
|
1027
|
+
*/
|
|
1028
|
+
private async assertOpenable(streamId: string): Promise<void> {
|
|
1029
|
+
const cols = resolveStreamColumns(this.tables!.columns);
|
|
1030
|
+
const rows = await this.sink!.sql.query<{ seq: number | null; status: string | null }>(
|
|
1031
|
+
this.mapped!.probeOpen(),
|
|
1032
|
+
[streamId],
|
|
1033
|
+
);
|
|
1034
|
+
const row = rows[0];
|
|
1035
|
+
if (!row) {
|
|
1036
|
+
throw new StreamOpenRefused(
|
|
1037
|
+
`stream ${streamId}: no row in "${this.tables!.message}" — the app's own mutator must write the message ` +
|
|
1038
|
+
`(with its chatId/role/…) BEFORE opening the stream on it`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
// A NULL length is refused as loudly as an advanced one, and for a subtler reason: it is the CAS
|
|
1042
|
+
// column, and `WHERE seq = 0` can never match NULL. A nullable column would let every chunk
|
|
1043
|
+
// insert land while its length CAS silently matched nothing — the one failure mode this plane
|
|
1044
|
+
// cannot detect (`batch` reports no row count) and one compaction would paper over (§5.1).
|
|
1045
|
+
if (typeof row.seq !== "number") {
|
|
1046
|
+
throw new StreamOpenRefused(
|
|
1047
|
+
`stream ${streamId}: "${this.tables!.message}"."${cols.seq}" is ${row.seq === null ? "NULL" : typeof row.seq} — ` +
|
|
1048
|
+
`the length column must be NOT NULL DEFAULT 0 (it is compare-and-swapped on every checkpoint, and no ` +
|
|
1049
|
+
`comparison matches NULL)`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (row.seq !== 0) {
|
|
1053
|
+
throw new StreamOpenRefused(
|
|
1054
|
+
`stream ${streamId}: the message row already holds ${row.seq} characters — a regeneration is a NEW message ` +
|
|
1055
|
+
`id, never a resume of an advanced row`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
if (row.status === STREAM_STATUS_STREAMING) {
|
|
1059
|
+
throw new StreamOpenRefused(
|
|
1060
|
+
`stream ${streamId}: "${cols.status}" is already "${STREAM_STATUS_STREAMING}" — another producer holds this ` +
|
|
1061
|
+
`stream. Two producers on one stream interleave their checkpoints; if the first one's host died, the ` +
|
|
1062
|
+
`sweeper marking it "interrupted" is what releases the row (§7)`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async subscribe(input: SubscribeStreamInput<User>): Promise<StreamSubscription> {
|
|
1068
|
+
const stream = this.live.get(input.streamId);
|
|
1069
|
+
const from = input.from ?? 0;
|
|
1070
|
+
// Authorize BEFORE existence is consulted: a denial must not double as an existence oracle.
|
|
1071
|
+
const verdict = await this.opts.authorize({
|
|
1072
|
+
user: input.user,
|
|
1073
|
+
streamId: input.streamId,
|
|
1074
|
+
from,
|
|
1075
|
+
meta: stream === undefined ? undefined : stream.meta,
|
|
1076
|
+
request: input.request,
|
|
1077
|
+
});
|
|
1078
|
+
if (verdict === false) throw new StreamForbidden(input.streamId);
|
|
1079
|
+
if (!stream) return oneFrame(input.streamId, { type: "absent" });
|
|
1080
|
+
return stream.subscribe(from);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
|
|
1084
|
+
* graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
|
|
1085
|
+
* than claiming completion (§5). Wire it to SIGTERM. */
|
|
1086
|
+
async drainStreams(): Promise<void> {
|
|
1087
|
+
await Promise.allSettled([...this.live.values()].map((s) => s.seal("interrupted")));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Teardown: drop readers and timers WITHOUT a durable write (that is `drainStreams`). */
|
|
1091
|
+
closeSync(): void {
|
|
1092
|
+
for (const s of this.live.values()) s.detachAll();
|
|
1093
|
+
this.live.clear();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
|
|
1097
|
+
* still gets `end` (and the tail it missed) rather than a bare `absent`. */
|
|
1098
|
+
retire(streamId: string): void {
|
|
1099
|
+
timer(() => {
|
|
1100
|
+
const entry = this.live.get(streamId);
|
|
1101
|
+
if (entry?.ended) {
|
|
1102
|
+
this.live.delete(streamId);
|
|
1103
|
+
entry.detachAll();
|
|
1104
|
+
}
|
|
1105
|
+
}, this.lingerMs);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
reportCheckpointError(err: unknown, info: { streamId: string; from: number; seq: number }): void {
|
|
1109
|
+
// Reporting runs INSIDE the drain loop, so a throwing hook would take the loop down with it and
|
|
1110
|
+
// wedge the stream — the one failure mode a diagnostic must never cause.
|
|
1111
|
+
try {
|
|
1112
|
+
if (this.opts.onCheckpointError) this.opts.onCheckpointError(err, info);
|
|
1113
|
+
else console.error(`[rindle api-server] stream ${info.streamId}: checkpoint ${info.from}→${info.seq} failed:`, err);
|
|
1114
|
+
} catch (hookErr) {
|
|
1115
|
+
console.error(`[rindle api-server] stream ${info.streamId}: onCheckpointError itself threw:`, hookErr);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Drive ONE checkpoint, retrying on failure. Every statement it emits is idempotent under replay
|
|
1120
|
+
* — the chunk insert dedups on its deterministic id, the length CAS refuses to apply twice, the
|
|
1121
|
+
* compaction is a whole-row overwrite — so "retry until it sticks" needs no dedup ledger and no
|
|
1122
|
+
* `lmid` (§3.3). */
|
|
1123
|
+
async commit(input: StreamCommitInput): Promise<{ cancelRequested?: boolean; seq?: number } | void> {
|
|
1124
|
+
let lastErr: unknown;
|
|
1125
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
1126
|
+
if (attempt > 0) await delay(RETRY_BACKOFF_MS * 2 ** (attempt - 1));
|
|
1127
|
+
try {
|
|
1128
|
+
return await this.commitOnce(input);
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
if (err instanceof StreamOpenRefused) throw err; // a settled verdict, not a blip
|
|
1131
|
+
lastErr = err;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
throw lastErr;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private async commitOnce(input: StreamCommitInput): Promise<{ cancelRequested?: boolean; seq?: number } | void> {
|
|
1138
|
+
const target = this.opts.checkpoint;
|
|
1139
|
+
if ("commit" in target) return (await target.commit(input)) ?? undefined;
|
|
1140
|
+
const sql = this.sink!.sql;
|
|
1141
|
+
const mapped = this.mapped!;
|
|
1142
|
+
switch (input.kind) {
|
|
1143
|
+
case "open": {
|
|
1144
|
+
// The PRECONDITION is checked once, in `assertOpenable`, before this write — never here.
|
|
1145
|
+
// Re-reading it per attempt would make a lost ack on the write below refuse its own success
|
|
1146
|
+
// (the row would already say `streaming`).
|
|
1147
|
+
await sql.batch([mapped.markStreaming(input.streamId, this.openToken)]);
|
|
1148
|
+
await this.verifyOpenWinner(input.streamId);
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
case "append": {
|
|
1152
|
+
try {
|
|
1153
|
+
await sql.batch(mapped.append(input.streamId, input.from, input.seq, input.text));
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
// The batch may have COMMITTED with its ack lost. The read-back is the truth: a row at
|
|
1156
|
+
// (or past) this slice's end means it landed, and the throw was only the reply.
|
|
1157
|
+
const truth = await this.readAppendState(input.streamId).catch(() => undefined);
|
|
1158
|
+
if (truth === undefined || truth.seq < input.seq) throw err;
|
|
1159
|
+
return truth;
|
|
1160
|
+
}
|
|
1161
|
+
// The read-back CONFIRMS the append (the guarded statements report no row count). If it
|
|
1162
|
+
// throws, the retry is safe: a replay of an applied slice no-ops on the guard and confirms
|
|
1163
|
+
// on its own read-back.
|
|
1164
|
+
return await this.readAppendState(input.streamId);
|
|
1165
|
+
}
|
|
1166
|
+
case "close": {
|
|
1167
|
+
await sql.batch(mapped.compact(input.streamId, input.body, input.status, input.error));
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** The read-back half of the open CAS, run only when a `host` column is mapped. The probe in
|
|
1174
|
+
* `assertOpenable` and the open write are separate round trips, so bare check-then-act leaves a
|
|
1175
|
+
* window where two simultaneous kicks both pass the probe; the conditional `markStreaming`
|
|
1176
|
+
* matches nothing when a rival got there first, and whichever token the row now holds names the
|
|
1177
|
+
* winner — a true CAS with no row counts needed. A replayed open (lost ack) reads back its OWN
|
|
1178
|
+
* token and proceeds. */
|
|
1179
|
+
private async verifyOpenWinner(streamId: string): Promise<void> {
|
|
1180
|
+
const probe = this.mapped!.probeHost();
|
|
1181
|
+
if (probe === undefined) return;
|
|
1182
|
+
const rows = await this.sink!.sql.query<{ host: unknown }>(probe, [streamId]);
|
|
1183
|
+
if (rows[0]?.host !== this.openToken) {
|
|
1184
|
+
throw new StreamOpenRefused(
|
|
1185
|
+
`stream ${streamId}: another producer won the open race (the row's host is ` +
|
|
1186
|
+
`${JSON.stringify(rows[0]?.host ?? null)}) — two producers on one stream interleave their checkpoints, ` +
|
|
1187
|
+
`so the loser stands down`,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** The post-append read-back (§3.2): the row's authoritative length — which both CONFIRMS an
|
|
1193
|
+
* append (the guarded batch reports no row count) and absorbs a committed-but-ack-lost one —
|
|
1194
|
+
* plus the reader's cancel flag when mapped (§6), riding the same indexed point read. This read
|
|
1195
|
+
* is LOAD-BEARING: a failure here fails the append attempt (retried, then stalled), because
|
|
1196
|
+
* claiming durability the store did not confirm is the one dishonesty this plane refuses. */
|
|
1197
|
+
private async readAppendState(streamId: string): Promise<{ seq: number; cancelRequested?: boolean }> {
|
|
1198
|
+
const rows = await this.sink!.sql.query<{ seq: unknown; cancel?: unknown }>(this.mapped!.probeState(), [
|
|
1199
|
+
streamId,
|
|
1200
|
+
]);
|
|
1201
|
+
const row = rows[0];
|
|
1202
|
+
if (row === undefined || typeof row.seq !== "number") {
|
|
1203
|
+
throw new Error(`stream ${streamId}: the read-back found no usable message row`);
|
|
1204
|
+
}
|
|
1205
|
+
return { seq: row.seq, ...(this.mapped!.hasCancel ? { cancelRequested: Boolean(row.cancel) } : {}) };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** The store's word on how much is durable — for resynchronizing after a FAILED append, where a
|
|
1209
|
+
* lost ack may have left the store ahead of the plane. `undefined` in `commit` mode (no readable
|
|
1210
|
+
* authority) or when the read itself fails (the next attempt retries the resync too). */
|
|
1211
|
+
async probeDurableSeq(streamId: string): Promise<number | undefined> {
|
|
1212
|
+
if (!this.mapped) return undefined;
|
|
1213
|
+
try {
|
|
1214
|
+
return (await this.readAppendState(streamId)).seq;
|
|
1215
|
+
} catch {
|
|
1216
|
+
return undefined;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** The open probe's verdict: the message row is missing or already advanced. Not retried — it is a
|
|
1222
|
+
* settled statement about the app's data, and retrying re-asks a question already answered. */
|
|
1223
|
+
export class StreamOpenRefused extends Error {
|
|
1224
|
+
constructor(message: string) {
|
|
1225
|
+
super(message);
|
|
1226
|
+
this.name = "StreamOpenRefused";
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Refused by {@link RindleStreamOptions.authorize}. Distinct from the api-server's own
|
|
1231
|
+
* `RindleApiError` so this module stays importable without the server (no cycle); the server
|
|
1232
|
+
* translates it to a 403 at the handler seam. */
|
|
1233
|
+
export class StreamForbidden extends Error {
|
|
1234
|
+
readonly streamId: string;
|
|
1235
|
+
|
|
1236
|
+
constructor(streamId: string) {
|
|
1237
|
+
super(`stream ${streamId}: forbidden`);
|
|
1238
|
+
this.name = "StreamForbidden";
|
|
1239
|
+
this.streamId = streamId;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function oneFrame(streamId: string, frame: StreamFrame): StreamSubscription {
|
|
1244
|
+
let taken = false;
|
|
1245
|
+
return {
|
|
1246
|
+
streamId,
|
|
1247
|
+
frames: {
|
|
1248
|
+
[Symbol.asyncIterator](): AsyncIterator<StreamFrame> {
|
|
1249
|
+
return {
|
|
1250
|
+
next(): Promise<IteratorResult<StreamFrame>> {
|
|
1251
|
+
if (taken) return Promise.resolve({ value: undefined as never, done: true });
|
|
1252
|
+
taken = true;
|
|
1253
|
+
return Promise.resolve({ value: frame, done: false });
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
close: () => {
|
|
1259
|
+
taken = true;
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ------------------------------------------------------------------------------- SSE transport
|
|
1265
|
+
|
|
1266
|
+
/** Headers for the SSE response. `x-accel-buffering` is the nginx-family opt-out — without it a
|
|
1267
|
+
* buffering proxy holds the tokens and hands the user a paragraph at a time. */
|
|
1268
|
+
export const STREAM_SSE_HEADERS: Record<string, string> = {
|
|
1269
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1270
|
+
"cache-control": "no-cache, no-transform",
|
|
1271
|
+
connection: "keep-alive",
|
|
1272
|
+
"x-accel-buffering": "no",
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
/** Pull a subscribe request out of a fetch-style GET: `?streamId=…&from=…`, with `Last-Event-ID`
|
|
1276
|
+
* winning over an explicit `from` (a reconnecting `EventSource` knows better than its own URL —
|
|
1277
|
+
* the URL is the ORIGINAL join point, the header is where it actually got to). */
|
|
1278
|
+
export function streamRequestFromHttp(req: {
|
|
1279
|
+
url: string;
|
|
1280
|
+
headers: { get(name: string): string | null };
|
|
1281
|
+
}): { streamId: string; from: number } {
|
|
1282
|
+
const url = new URL(req.url);
|
|
1283
|
+
const streamId = url.searchParams.get("streamId") ?? "";
|
|
1284
|
+
const lastEventId = req.headers.get("last-event-id");
|
|
1285
|
+
const raw = lastEventId ?? url.searchParams.get("from") ?? "0";
|
|
1286
|
+
const from = Number.parseInt(raw, 10);
|
|
1287
|
+
return { streamId, from: Number.isFinite(from) && from > 0 ? from : 0 };
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Encode a subscription as an SSE body. Each positional frame carries `id: <seq>`, so a browser
|
|
1292
|
+
* `EventSource` that drops the connection resumes at exactly the right offset with no application
|
|
1293
|
+
* code — its own `Last-Event-ID` header is the `from` of the next subscribe ({@link
|
|
1294
|
+
* streamRequestFromHttp}).
|
|
1295
|
+
*
|
|
1296
|
+
* The reader must close the `EventSource` on the `end` frame: `EventSource` reconnects on ANY close,
|
|
1297
|
+
* including a clean one.
|
|
1298
|
+
*/
|
|
1299
|
+
export function streamFramesToSse(
|
|
1300
|
+
sub: StreamSubscription,
|
|
1301
|
+
opts?: { keepAliveMs?: number },
|
|
1302
|
+
): ReadableStream<Uint8Array> {
|
|
1303
|
+
const encoder = new TextEncoder();
|
|
1304
|
+
const keepAliveMs = opts?.keepAliveMs ?? 15_000;
|
|
1305
|
+
let ping: ReturnType<typeof setTimeout> | null = null;
|
|
1306
|
+
let cancelled = false;
|
|
1307
|
+
let iter: AsyncIterator<StreamFrame>;
|
|
1308
|
+
// PULL-based on purpose: a frame is taken from the subscription only when the consumer has
|
|
1309
|
+
// demand, so a slow HTTP client backs frames up in the SUBSCRIBER's bounded queue — where
|
|
1310
|
+
// `maxQueuedFrames` drops it with `stale` (§4) — instead of unboundedly in this stream's own.
|
|
1311
|
+
return new ReadableStream<Uint8Array>({
|
|
1312
|
+
start(controller) {
|
|
1313
|
+
iter = sub.frames[Symbol.asyncIterator]();
|
|
1314
|
+
const beat = (): void => {
|
|
1315
|
+
if (cancelled) return;
|
|
1316
|
+
// A comment line: keeps intermediaries from reaping an idle connection, costs 8 bytes.
|
|
1317
|
+
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
1318
|
+
ping = timer(beat, keepAliveMs);
|
|
1319
|
+
};
|
|
1320
|
+
ping = timer(beat, keepAliveMs);
|
|
1321
|
+
},
|
|
1322
|
+
async pull(controller) {
|
|
1323
|
+
const next = await iter.next();
|
|
1324
|
+
if (cancelled) return;
|
|
1325
|
+
if (next.done) {
|
|
1326
|
+
if (ping) clearTimeout(ping);
|
|
1327
|
+
sub.close();
|
|
1328
|
+
controller.close();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const id = frameResumePoint(next.value);
|
|
1332
|
+
const head = id === undefined ? "" : `id: ${id}\n`;
|
|
1333
|
+
controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(next.value)}\n\n`));
|
|
1334
|
+
},
|
|
1335
|
+
cancel() {
|
|
1336
|
+
cancelled = true;
|
|
1337
|
+
if (ping) clearTimeout(ping);
|
|
1338
|
+
sub.close();
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
}
|