@rindle/api-server 0.8.0 → 0.10.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 +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/streams.d.ts +112 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +329 -3
- package/dist/streams.js.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +4 -1
- package/src/streams.ts +387 -3
package/dist/streams.d.ts
CHANGED
|
@@ -138,6 +138,38 @@ export interface AuthorizeStreamInput<User> {
|
|
|
138
138
|
meta: unknown;
|
|
139
139
|
request?: unknown;
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Optional cross-process transport for the LIVE plane
|
|
143
|
+
* (designs-implemented/LM-STREAM-RELAY-DESIGN.md). Both methods are independently optional; which
|
|
144
|
+
* ones you implement is which topology you built — an addressing adapter (a Durable Object named by
|
|
145
|
+
* `streamId`, `fly-replay`) implements only `attach`; a broadcast adapter (Redis pub/sub, NATS)
|
|
146
|
+
* mirrors with `publish` and subscribes with `attach`; a log adapter (Redis Streams, Kafka) appends
|
|
147
|
+
* and replays. Never consulted for the durable plane — checkpoints are unaffected by any of this.
|
|
148
|
+
*
|
|
149
|
+
* The plane does not trust what `attach` yields: frames are run through the conform pass
|
|
150
|
+
* ({@link StreamRelayConform}) and any contract violation downgrades the subscription to `stale`,
|
|
151
|
+
* which already means "you are on the durable plane now". A broken relay costs a reader smooth
|
|
152
|
+
* tokens, never corrupted text — and can never reach the producer.
|
|
153
|
+
*/
|
|
154
|
+
export interface StreamRelay {
|
|
155
|
+
/** Producer side: every frame this process's producer fans out (`chunk`, `durable`, and the
|
|
156
|
+
* terminal `end`), mirrored outward. MUST NOT block; a throw or rejected promise is caught and
|
|
157
|
+
* routed to {@link RindleStreamOptions.onRelayError} — a relay outage may cost the live leg,
|
|
158
|
+
* never the generation. Returned promises are observed but never awaited. */
|
|
159
|
+
publish?(streamId: string, frame: StreamFrame): void | PromiseLike<void>;
|
|
160
|
+
/** Subscriber side: this process is not hosting `streamId`. Return a frame source, or `undefined`
|
|
161
|
+
* for `absent` — exactly the no-relay answer. Consulted only AFTER `authorize` has passed, and
|
|
162
|
+
* only on a live-plane miss (a local stream always wins). The plane closes the source
|
|
163
|
+
* (`return()`) when the reader disconnects. An adapter that cannot serve `from` (pub/sub has no
|
|
164
|
+
* history) yields `stale` and stops — the reader converges on the durable plane (§5). */
|
|
165
|
+
attach?(streamId: string, from: number): Promise<AsyncIterable<StreamFrame> | undefined>;
|
|
166
|
+
}
|
|
167
|
+
export interface StreamRelayErrorInfo {
|
|
168
|
+
streamId: string;
|
|
169
|
+
/** Where it failed: mirroring a frame out (`publish`), dialing the adapter (`attach`), or
|
|
170
|
+
* consuming/conforming its frames (`frames`). */
|
|
171
|
+
phase: "publish" | "attach" | "frames";
|
|
172
|
+
}
|
|
141
173
|
export interface RindleStreamOptions<User> {
|
|
142
174
|
/** Where checkpoints land: the app's tables (the default path) or a raw `commit` callback. */
|
|
143
175
|
checkpoint: StreamCheckpointTarget;
|
|
@@ -160,7 +192,9 @@ export interface RindleStreamOptions<User> {
|
|
|
160
192
|
retainChars?: number;
|
|
161
193
|
/** How long a sealed stream stays joinable before eviction. Default 30s. */
|
|
162
194
|
lingerMs?: number;
|
|
163
|
-
/** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024.
|
|
195
|
+
/** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024.
|
|
196
|
+
* Relayed readers reuse the same bound: a slow reader on a relayed stream costs itself the live
|
|
197
|
+
* leg exactly as a local one does. */
|
|
164
198
|
maxQueuedFrames?: number;
|
|
165
199
|
/** A checkpoint that exhausted its retries. The stream keeps streaming — this is a durability
|
|
166
200
|
* stall, not a stream stall — so the error must not vanish. Absent ⇒ `console.error`. */
|
|
@@ -169,6 +203,19 @@ export interface RindleStreamOptions<User> {
|
|
|
169
203
|
from: number;
|
|
170
204
|
seq: number;
|
|
171
205
|
}) => void;
|
|
206
|
+
/** Cross-process transport for the live plane ({@link StreamRelay}). Without one, a subscriber
|
|
207
|
+
* that lands on a process not hosting its stream gets `absent` and reads the durable plane at
|
|
208
|
+
* checkpoint granularity — correct, just chunky. */
|
|
209
|
+
relay?: StreamRelay;
|
|
210
|
+
/** Bound on `relay.attach`: a hung adapter yields `absent`, not a hung HTTP request. An
|
|
211
|
+
* addressing adapter MAY deliberately spend this window waiting out a subscribe that races its
|
|
212
|
+
* own kick. Default 2000. */
|
|
213
|
+
relayAttachTimeoutMs?: number;
|
|
214
|
+
/** A diagnostic, never a control path: relay failures (a throwing or rejecting `publish`, a failed
|
|
215
|
+
* or timed-out `attach`, a conform violation in the frames) land here, wrapped so a throwing hook
|
|
216
|
+
* cannot reach the plane. The reader-facing outcome is always the same legal `absent`/`stale`.
|
|
217
|
+
* Absent ⇒ `console.error`. */
|
|
218
|
+
onRelayError?: (err: unknown, info: StreamRelayErrorInfo) => void;
|
|
172
219
|
}
|
|
173
220
|
export interface OpenStreamInput<User> {
|
|
174
221
|
user: User;
|
|
@@ -245,6 +292,49 @@ export declare function streamChunkId(streamId: string, seq: number): string;
|
|
|
245
292
|
* generated rather than hand-written.
|
|
246
293
|
*/
|
|
247
294
|
export declare function streamChunkTableDdl(tables: StreamTables, dialect: SqlDialect): string[];
|
|
295
|
+
/**
|
|
296
|
+
* One frame source arriving over a relay, conformed to the CP §4 contract
|
|
297
|
+
* (designs-implemented/LM-STREAM-RELAY-DESIGN.md §4).
|
|
298
|
+
*
|
|
299
|
+
* An adapter is app code talking to Redis or a socket, and its frames feed `spliceStreamText` on a
|
|
300
|
+
* browser — so the plane does not trust them. This pass enforces the frame invariants against the
|
|
301
|
+
* prefix actually delivered and downgrades EVERY violation to a legal `stale` and nothing else:
|
|
302
|
+
* `stale` already means "you are on the durable plane now, the store is the whole truth", so a
|
|
303
|
+
* broken relay costs a reader smooth tokens, never corrupted text — and cannot wedge a producer.
|
|
304
|
+
*
|
|
305
|
+
* Replayed spans (a reconnecting adapter re-delivering what it already sent) are ABSORBED rather
|
|
306
|
+
* than punished — deduping against the delivered prefix is what makes reconnect-replay safe without
|
|
307
|
+
* every adapter hand-rolling it. Spans that overlap the prefix but extend past it pass through
|
|
308
|
+
* whole: the client splices at the frame's own offset, so an exact overlap re-covers and appends.
|
|
309
|
+
*
|
|
310
|
+
* Pure state, no I/O, no plane: `feed` maps one incoming frame to 0-2 outgoing frames (a missing
|
|
311
|
+
* `open` is synthesized at the join offset); `end`/`fail` close out a source that finished or threw
|
|
312
|
+
* without a terminal. After a terminal, every method returns `[]`.
|
|
313
|
+
*/
|
|
314
|
+
export declare class StreamRelayConform {
|
|
315
|
+
private readonly streamId;
|
|
316
|
+
/** The requested join offset — the synthesized `open`'s position, and where the prefix starts. */
|
|
317
|
+
private readonly from;
|
|
318
|
+
private readonly onViolation;
|
|
319
|
+
/** End of the delivered prefix. */
|
|
320
|
+
private pos;
|
|
321
|
+
private lastDurable;
|
|
322
|
+
private opened;
|
|
323
|
+
private done;
|
|
324
|
+
constructor(streamId: string, from: number, onViolation?: (reason: string) => void);
|
|
325
|
+
feed(frame: StreamFrame): StreamFrame[];
|
|
326
|
+
/** The source completed without a terminal (a truncated relay): the reader falls back. */
|
|
327
|
+
end(): StreamFrame[];
|
|
328
|
+
/** The source threw mid-iteration, or the plane is dropping a reader that stopped draining:
|
|
329
|
+
* a bare `stale` at the delivered position. */
|
|
330
|
+
fail(): StreamFrame[];
|
|
331
|
+
/** A synthesized join, for an adapter that (correctly, in broadcast mode) never mirrors the
|
|
332
|
+
* per-subscriber `open`: positioned at the requested offset, which the reader asked from because
|
|
333
|
+
* its durable view already holds it. */
|
|
334
|
+
private synthOpen;
|
|
335
|
+
private terminate;
|
|
336
|
+
private violate;
|
|
337
|
+
}
|
|
248
338
|
/** What the plane needs from the api-server to write a checkpoint: the backend's OUTSIDE-transaction
|
|
249
339
|
* SQL surface (`batch` is one transaction on every backend) and its dialect. Deliberately narrow so
|
|
250
340
|
* `streams.ts` never imports the server (no cycle). */
|
|
@@ -259,7 +349,10 @@ export declare class StreamPlane<User> {
|
|
|
259
349
|
readonly retainChars: number;
|
|
260
350
|
readonly lingerMs: number;
|
|
261
351
|
readonly maxQueuedFrames: number;
|
|
352
|
+
readonly relayAttachTimeoutMs: number;
|
|
262
353
|
private readonly live;
|
|
354
|
+
/** Live relayed subscriptions, so teardown ({@link closeSync}) releases their drivers too. */
|
|
355
|
+
private readonly relayed;
|
|
263
356
|
private readonly opts;
|
|
264
357
|
private readonly sink;
|
|
265
358
|
private readonly mapped;
|
|
@@ -290,6 +383,24 @@ export declare class StreamPlane<User> {
|
|
|
290
383
|
*/
|
|
291
384
|
private assertOpenable;
|
|
292
385
|
subscribe(input: SubscribeStreamInput<User>): Promise<StreamSubscription>;
|
|
386
|
+
/** The subscribe-miss leg (LM-STREAM-RELAY §3): ask the app's relay for the frames of a stream
|
|
387
|
+
* this process is not hosting. `undefined` — no relay, no `attach`, the adapter declined, timed
|
|
388
|
+
* out, or threw — is `absent`, exactly today's answer. */
|
|
389
|
+
private attachRelay;
|
|
390
|
+
/** `attach`, bounded by {@link RindleStreamOptions.relayAttachTimeoutMs}: a hung adapter yields
|
|
391
|
+
* `absent`, not a hung HTTP request. A source that resolves after the deadline is closed, not
|
|
392
|
+
* leaked. */
|
|
393
|
+
private boundedAttach;
|
|
394
|
+
/** Wrap an adapter's frame source as a plane subscription: conform every frame (LM-STREAM-RELAY
|
|
395
|
+
* §4), bound the reader with the same queue cap as a local one (§7), and tear the adapter down
|
|
396
|
+
* when either side lets go. The driver never throws into the plane: adapter failures become one
|
|
397
|
+
* `stale`. */
|
|
398
|
+
private relaySubscription;
|
|
399
|
+
/** Mirror one producer frame outward (LM-STREAM-RELAY §3). Never blocks or breaks the producer:
|
|
400
|
+
* a throw or rejected promise is reported and swallowed — a relay outage may cost relayed
|
|
401
|
+
* readers the live leg, never the generation or its checkpoints. */
|
|
402
|
+
publishRelay(streamId: string, frame: StreamFrame): void;
|
|
403
|
+
reportRelayError(err: unknown, info: StreamRelayErrorInfo): void;
|
|
293
404
|
/** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
|
|
294
405
|
* graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
|
|
295
406
|
* than claiming completion (§5). Wire it to SIGTERM. */
|
package/dist/streams.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"streams.d.ts","sourceRoot":"","sources":["../src/streams.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"streams.d.ts","sourceRoot":"","sources":["../src/streams.ts"],"names":[],"mappings":"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"}
|
package/dist/streams.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// LM stream checkpointing — the two-plane response path (designs/LM-STREAM-CHECKPOINT-DESIGN.md).
|
|
1
|
+
// LM stream checkpointing — the two-plane response path (designs-implemented/LM-STREAM-CHECKPOINT-DESIGN.md).
|
|
2
2
|
//
|
|
3
3
|
// A model response arrives as hundreds of tiny deltas per second. Every one wants to be on a screen
|
|
4
4
|
// immediately; none wants to be a durable write. So the response runs on TWO planes sharing ONE
|
|
@@ -49,6 +49,7 @@ const DEFAULT_CHECKPOINT_RETRIES = 3;
|
|
|
49
49
|
const DEFAULT_RETAIN_CHARS = 64 * 1024;
|
|
50
50
|
const DEFAULT_LINGER_MS = 30_000;
|
|
51
51
|
const DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
52
|
+
const DEFAULT_RELAY_ATTACH_TIMEOUT_MS = 2000;
|
|
52
53
|
/** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
|
|
53
54
|
* authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
|
|
54
55
|
const RETRY_BACKOFF_MS = 50;
|
|
@@ -303,6 +304,148 @@ class Subscriber {
|
|
|
303
304
|
};
|
|
304
305
|
}
|
|
305
306
|
}
|
|
307
|
+
// ------------------------------------------------------------------------------- the relay conform pass
|
|
308
|
+
/**
|
|
309
|
+
* One frame source arriving over a relay, conformed to the CP §4 contract
|
|
310
|
+
* (designs-implemented/LM-STREAM-RELAY-DESIGN.md §4).
|
|
311
|
+
*
|
|
312
|
+
* An adapter is app code talking to Redis or a socket, and its frames feed `spliceStreamText` on a
|
|
313
|
+
* browser — so the plane does not trust them. This pass enforces the frame invariants against the
|
|
314
|
+
* prefix actually delivered and downgrades EVERY violation to a legal `stale` and nothing else:
|
|
315
|
+
* `stale` already means "you are on the durable plane now, the store is the whole truth", so a
|
|
316
|
+
* broken relay costs a reader smooth tokens, never corrupted text — and cannot wedge a producer.
|
|
317
|
+
*
|
|
318
|
+
* Replayed spans (a reconnecting adapter re-delivering what it already sent) are ABSORBED rather
|
|
319
|
+
* than punished — deduping against the delivered prefix is what makes reconnect-replay safe without
|
|
320
|
+
* every adapter hand-rolling it. Spans that overlap the prefix but extend past it pass through
|
|
321
|
+
* whole: the client splices at the frame's own offset, so an exact overlap re-covers and appends.
|
|
322
|
+
*
|
|
323
|
+
* Pure state, no I/O, no plane: `feed` maps one incoming frame to 0-2 outgoing frames (a missing
|
|
324
|
+
* `open` is synthesized at the join offset); `end`/`fail` close out a source that finished or threw
|
|
325
|
+
* without a terminal. After a terminal, every method returns `[]`.
|
|
326
|
+
*/
|
|
327
|
+
export class StreamRelayConform {
|
|
328
|
+
streamId;
|
|
329
|
+
/** The requested join offset — the synthesized `open`'s position, and where the prefix starts. */
|
|
330
|
+
from;
|
|
331
|
+
onViolation;
|
|
332
|
+
/** End of the delivered prefix. */
|
|
333
|
+
pos;
|
|
334
|
+
lastDurable = 0;
|
|
335
|
+
opened = false;
|
|
336
|
+
done = false;
|
|
337
|
+
constructor(streamId, from, onViolation) {
|
|
338
|
+
this.streamId = streamId;
|
|
339
|
+
this.from = from;
|
|
340
|
+
this.pos = from;
|
|
341
|
+
this.onViolation = onViolation;
|
|
342
|
+
}
|
|
343
|
+
feed(frame) {
|
|
344
|
+
if (this.done)
|
|
345
|
+
return [];
|
|
346
|
+
switch (frame.type) {
|
|
347
|
+
case "open": {
|
|
348
|
+
if (this.opened)
|
|
349
|
+
return []; // a reconnecting adapter's second open: absorbed
|
|
350
|
+
if (frame.streamId !== this.streamId ||
|
|
351
|
+
!Number.isInteger(frame.from) ||
|
|
352
|
+
!Number.isInteger(frame.seq) ||
|
|
353
|
+
!Number.isInteger(frame.durableSeq) ||
|
|
354
|
+
frame.from < 0 ||
|
|
355
|
+
frame.seq < frame.from) {
|
|
356
|
+
return this.violate(`relay open for ${JSON.stringify(frame.streamId)} at ${frame.from} is malformed`);
|
|
357
|
+
}
|
|
358
|
+
// An open PAST the requested offset is the adapter saying it cannot serve `from` (pub/sub
|
|
359
|
+
// has no history, §5): delivering it would leave a hole in the middle of the response, so
|
|
360
|
+
// the honest answer is the durable plane.
|
|
361
|
+
if (frame.from > this.from) {
|
|
362
|
+
return this.violate(`relay open at ${frame.from} cannot serve the requested ${this.from}`);
|
|
363
|
+
}
|
|
364
|
+
this.opened = true;
|
|
365
|
+
this.pos = frame.from;
|
|
366
|
+
return [frame];
|
|
367
|
+
}
|
|
368
|
+
case "chunk": {
|
|
369
|
+
if (!Number.isInteger(frame.from) ||
|
|
370
|
+
!Number.isInteger(frame.seq) ||
|
|
371
|
+
frame.from < 0 ||
|
|
372
|
+
typeof frame.text !== "string" ||
|
|
373
|
+
frame.text.length !== frame.seq - frame.from) {
|
|
374
|
+
return this.violate(`relay chunk ${frame.from}→${frame.seq} does not span exactly its offsets`);
|
|
375
|
+
}
|
|
376
|
+
if (frame.from > this.pos) {
|
|
377
|
+
return this.violate(`relay chunk at ${frame.from} leaves a gap after ${this.pos}`);
|
|
378
|
+
}
|
|
379
|
+
if (frame.seq <= this.pos)
|
|
380
|
+
return []; // entirely within the delivered prefix (a replay): absorbed
|
|
381
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
382
|
+
this.pos = frame.seq;
|
|
383
|
+
out.push(frame);
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
case "durable": {
|
|
387
|
+
// Purely informational to a reader (the hook ignores it; SSE uses it as a resume id), so a
|
|
388
|
+
// claim that rewinds — or outruns what this subscription has SEEN produced, which P forbids
|
|
389
|
+
// — is dropped rather than downgraded.
|
|
390
|
+
if (!Number.isInteger(frame.seq) || frame.seq < this.lastDurable || frame.seq > this.pos)
|
|
391
|
+
return [];
|
|
392
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
393
|
+
this.lastDurable = frame.seq;
|
|
394
|
+
out.push(frame);
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
case "end": {
|
|
398
|
+
if (!Number.isInteger(frame.seq) || frame.seq < 0) {
|
|
399
|
+
return this.violate(`relay end at ${String(frame.seq)} is malformed`);
|
|
400
|
+
}
|
|
401
|
+
// An `end` whose durable length outruns the delivered prefix means the adapter LOST text
|
|
402
|
+
// (durable never exceeds produced), so the reader is short and must not be told it saw
|
|
403
|
+
// everything. Downgrading keeps `end` meaning the same thing relayed as local: the whole
|
|
404
|
+
// produced text arrived.
|
|
405
|
+
if (frame.seq > this.pos) {
|
|
406
|
+
return this.violate(`relay end at ${frame.seq} outruns the ${this.pos} characters delivered`);
|
|
407
|
+
}
|
|
408
|
+
this.done = true;
|
|
409
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
410
|
+
out.push(frame);
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
case "stale":
|
|
414
|
+
case "absent": {
|
|
415
|
+
// Legal bare — the local plane's own floor/eviction answers carry no `open` either.
|
|
416
|
+
this.done = true;
|
|
417
|
+
return [frame];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** The source completed without a terminal (a truncated relay): the reader falls back. */
|
|
422
|
+
end() {
|
|
423
|
+
if (this.done)
|
|
424
|
+
return [];
|
|
425
|
+
this.onViolation?.("relay source ended without a terminal frame");
|
|
426
|
+
return this.terminate();
|
|
427
|
+
}
|
|
428
|
+
/** The source threw mid-iteration, or the plane is dropping a reader that stopped draining:
|
|
429
|
+
* a bare `stale` at the delivered position. */
|
|
430
|
+
fail() {
|
|
431
|
+
return this.done ? [] : this.terminate();
|
|
432
|
+
}
|
|
433
|
+
/** A synthesized join, for an adapter that (correctly, in broadcast mode) never mirrors the
|
|
434
|
+
* per-subscriber `open`: positioned at the requested offset, which the reader asked from because
|
|
435
|
+
* its durable view already holds it. */
|
|
436
|
+
synthOpen() {
|
|
437
|
+
this.opened = true;
|
|
438
|
+
return { type: "open", streamId: this.streamId, from: this.from, seq: this.from, durableSeq: this.from, ended: false };
|
|
439
|
+
}
|
|
440
|
+
terminate() {
|
|
441
|
+
this.done = true;
|
|
442
|
+
return [{ type: "stale", floorSeq: this.pos, durableSeq: this.lastDurable }];
|
|
443
|
+
}
|
|
444
|
+
violate(reason) {
|
|
445
|
+
this.onViolation?.(reason);
|
|
446
|
+
return this.terminate();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
306
449
|
class LiveStream {
|
|
307
450
|
/** Retained text; `buf[0]` sits at offset {@link bufFrom}. */
|
|
308
451
|
buf = "";
|
|
@@ -541,6 +684,8 @@ class LiveStream {
|
|
|
541
684
|
status: seal.status,
|
|
542
685
|
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
543
686
|
};
|
|
687
|
+
// The terminal reaches the relay too (it bypasses `fanout` locally only to bypass the cap).
|
|
688
|
+
this.plane.publishRelay(this.streamId, frame);
|
|
544
689
|
for (const sub of [...this.subs])
|
|
545
690
|
sub.finish(frame);
|
|
546
691
|
this.plane.retire(this.streamId);
|
|
@@ -587,6 +732,9 @@ class LiveStream {
|
|
|
587
732
|
}
|
|
588
733
|
// ---- subscriber side
|
|
589
734
|
fanout(frame) {
|
|
735
|
+
// Every frame local subscribers get, the relay gets — including when nobody local is attached
|
|
736
|
+
// (a broadcast relay's whole point). Wrapped so an outage costs the live leg, never this stream.
|
|
737
|
+
this.plane.publishRelay(this.streamId, frame);
|
|
590
738
|
for (const sub of [...this.subs]) {
|
|
591
739
|
if (!sub.offer(frame)) {
|
|
592
740
|
// Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
|
|
@@ -644,7 +792,10 @@ export class StreamPlane {
|
|
|
644
792
|
retainChars;
|
|
645
793
|
lingerMs;
|
|
646
794
|
maxQueuedFrames;
|
|
795
|
+
relayAttachTimeoutMs;
|
|
647
796
|
live = new Map();
|
|
797
|
+
/** Live relayed subscriptions, so teardown ({@link closeSync}) releases their drivers too. */
|
|
798
|
+
relayed = new Set();
|
|
648
799
|
opts;
|
|
649
800
|
sink;
|
|
650
801
|
mapped;
|
|
@@ -660,7 +811,15 @@ export class StreamPlane {
|
|
|
660
811
|
this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
|
|
661
812
|
this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
|
|
662
813
|
this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
|
|
814
|
+
this.relayAttachTimeoutMs = opts.relayAttachTimeoutMs ?? DEFAULT_RELAY_ATTACH_TIMEOUT_MS;
|
|
663
815
|
this.openToken = opts.hostId ?? randomOpenToken();
|
|
816
|
+
// Relay misconfiguration is refused loudly (the room-profile rule), never ignored.
|
|
817
|
+
if (opts.relay !== undefined && opts.relay.publish === undefined && opts.relay.attach === undefined) {
|
|
818
|
+
throw new TypeError("streams.relay implements neither publish nor attach — which topology is this? (LM-STREAM-RELAY §3.1)");
|
|
819
|
+
}
|
|
820
|
+
if (opts.relay === undefined && (opts.relayAttachTimeoutMs !== undefined || opts.onRelayError !== undefined)) {
|
|
821
|
+
throw new TypeError("streams.relayAttachTimeoutMs/onRelayError do nothing without streams.relay");
|
|
822
|
+
}
|
|
664
823
|
if ("tables" in opts.checkpoint) {
|
|
665
824
|
if (!sink)
|
|
666
825
|
throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
|
|
@@ -787,10 +946,162 @@ export class StreamPlane {
|
|
|
787
946
|
});
|
|
788
947
|
if (verdict === false)
|
|
789
948
|
throw new StreamForbidden(input.streamId);
|
|
790
|
-
if (!stream)
|
|
791
|
-
|
|
949
|
+
if (!stream) {
|
|
950
|
+
// The relay is consulted only on a live-plane miss, and only after `authorize` passed — a
|
|
951
|
+
// denial must not become an existence probe against the relay either. A local stream always
|
|
952
|
+
// wins: the producer's own readers keep the lowest-latency path.
|
|
953
|
+
const relayed = await this.attachRelay(input.streamId, from);
|
|
954
|
+
return relayed ?? oneFrame(input.streamId, { type: "absent" });
|
|
955
|
+
}
|
|
792
956
|
return stream.subscribe(from);
|
|
793
957
|
}
|
|
958
|
+
/** The subscribe-miss leg (LM-STREAM-RELAY §3): ask the app's relay for the frames of a stream
|
|
959
|
+
* this process is not hosting. `undefined` — no relay, no `attach`, the adapter declined, timed
|
|
960
|
+
* out, or threw — is `absent`, exactly today's answer. */
|
|
961
|
+
async attachRelay(streamId, from) {
|
|
962
|
+
const relay = this.opts.relay;
|
|
963
|
+
if (relay?.attach === undefined)
|
|
964
|
+
return undefined;
|
|
965
|
+
// Floored like the local join, but clamped only below: the producer's length is not known here.
|
|
966
|
+
const at = Number.isFinite(from) ? Math.max(Math.floor(from), 0) : 0;
|
|
967
|
+
let source;
|
|
968
|
+
try {
|
|
969
|
+
source = await this.boundedAttach(relay, streamId, at);
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
this.reportRelayError(err, { streamId, phase: "attach" });
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
if (source === undefined)
|
|
976
|
+
return undefined;
|
|
977
|
+
return this.relaySubscription(streamId, at, source);
|
|
978
|
+
}
|
|
979
|
+
/** `attach`, bounded by {@link RindleStreamOptions.relayAttachTimeoutMs}: a hung adapter yields
|
|
980
|
+
* `absent`, not a hung HTTP request. A source that resolves after the deadline is closed, not
|
|
981
|
+
* leaked. */
|
|
982
|
+
boundedAttach(relay, streamId, from) {
|
|
983
|
+
// `async` wrapping so a synchronously-throwing adapter is an attach failure, not a plane throw.
|
|
984
|
+
const attempt = (async () => relay.attach(streamId, from))();
|
|
985
|
+
return new Promise((resolve, reject) => {
|
|
986
|
+
let late = false;
|
|
987
|
+
const t = timer(() => {
|
|
988
|
+
late = true;
|
|
989
|
+
reject(new Error(`stream ${streamId}: relay.attach timed out after ${this.relayAttachTimeoutMs}ms`));
|
|
990
|
+
}, this.relayAttachTimeoutMs);
|
|
991
|
+
attempt.then((source) => {
|
|
992
|
+
clearTimeout(t);
|
|
993
|
+
if (!late)
|
|
994
|
+
return resolve(source);
|
|
995
|
+
closeFrameSource(source); // too late to serve the reader; don't leak the channel
|
|
996
|
+
}, (err) => {
|
|
997
|
+
clearTimeout(t);
|
|
998
|
+
if (!late)
|
|
999
|
+
reject(err);
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
/** Wrap an adapter's frame source as a plane subscription: conform every frame (LM-STREAM-RELAY
|
|
1004
|
+
* §4), bound the reader with the same queue cap as a local one (§7), and tear the adapter down
|
|
1005
|
+
* when either side lets go. The driver never throws into the plane: adapter failures become one
|
|
1006
|
+
* `stale`. */
|
|
1007
|
+
relaySubscription(streamId, from, source) {
|
|
1008
|
+
const conform = new StreamRelayConform(streamId, from, (reason) => this.reportRelayError(new Error(reason), { streamId, phase: "frames" }));
|
|
1009
|
+
let closed = false;
|
|
1010
|
+
let signalClose;
|
|
1011
|
+
const closedP = new Promise((resolve) => (signalClose = resolve));
|
|
1012
|
+
const closedTag = closedP.then(() => "closed");
|
|
1013
|
+
const release = () => {
|
|
1014
|
+
if (!closed) {
|
|
1015
|
+
closed = true;
|
|
1016
|
+
signalClose();
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
const sub = new Subscriber(this.maxQueuedFrames, (s) => {
|
|
1020
|
+
this.relayed.delete(s);
|
|
1021
|
+
release();
|
|
1022
|
+
});
|
|
1023
|
+
this.relayed.add(sub);
|
|
1024
|
+
let it;
|
|
1025
|
+
/** @returns false once the subscription finished (terminal delivered, or the reader dropped). */
|
|
1026
|
+
const deliver = (frames) => {
|
|
1027
|
+
for (const frame of frames) {
|
|
1028
|
+
if (frame.type === "end" || frame.type === "stale" || frame.type === "absent") {
|
|
1029
|
+
sub.finish(frame);
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
if (!sub.offer(frame)) {
|
|
1033
|
+
// The same bound as a local reader: a relayed subscriber that stops draining costs
|
|
1034
|
+
// itself the live leg, never unbounded memory.
|
|
1035
|
+
const [stale] = conform.fail();
|
|
1036
|
+
if (stale)
|
|
1037
|
+
sub.finish(stale);
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return true;
|
|
1042
|
+
};
|
|
1043
|
+
void (async () => {
|
|
1044
|
+
try {
|
|
1045
|
+
// Iterator construction is adapter code too: keep a throwing factory inside the same
|
|
1046
|
+
// stale/report/cleanup boundary as a throwing `next()`.
|
|
1047
|
+
it = source[Symbol.asyncIterator]();
|
|
1048
|
+
for (;;) {
|
|
1049
|
+
// Raced rather than awaited bare: a reader disconnect must release this driver even when
|
|
1050
|
+
// the adapter never yields another frame (its own `return()` may be queued behind the
|
|
1051
|
+
// pending `next()` forever).
|
|
1052
|
+
const res = await Promise.race([it.next(), closedTag]);
|
|
1053
|
+
if (res === "closed")
|
|
1054
|
+
return;
|
|
1055
|
+
if (res.done) {
|
|
1056
|
+
deliver(conform.end());
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (!deliver(conform.feed(res.value)))
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
this.reportRelayError(err, { streamId, phase: "frames" });
|
|
1065
|
+
deliver(conform.fail());
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
release();
|
|
1069
|
+
// If construction itself threw there is no iterator to return, and asking the source to
|
|
1070
|
+
// construct a second one during cleanup could repeat side effects or throw again.
|
|
1071
|
+
closeFrameSource(undefined, it);
|
|
1072
|
+
}
|
|
1073
|
+
})();
|
|
1074
|
+
return { streamId, frames: sub.frames(), close: () => sub.close() };
|
|
1075
|
+
}
|
|
1076
|
+
/** Mirror one producer frame outward (LM-STREAM-RELAY §3). Never blocks or breaks the producer:
|
|
1077
|
+
* a throw or rejected promise is reported and swallowed — a relay outage may cost relayed
|
|
1078
|
+
* readers the live leg, never the generation or its checkpoints. */
|
|
1079
|
+
publishRelay(streamId, frame) {
|
|
1080
|
+
const relay = this.opts.relay;
|
|
1081
|
+
if (relay?.publish === undefined)
|
|
1082
|
+
return;
|
|
1083
|
+
try {
|
|
1084
|
+
const published = relay.publish(streamId, frame);
|
|
1085
|
+
if (published !== undefined) {
|
|
1086
|
+
void Promise.resolve(published).catch((err) => this.reportRelayError(err, { streamId, phase: "publish" }));
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
this.reportRelayError(err, { streamId, phase: "publish" });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
reportRelayError(err, info) {
|
|
1094
|
+
// Same discipline as reportCheckpointError: a diagnostic must never take its caller down.
|
|
1095
|
+
try {
|
|
1096
|
+
if (this.opts.onRelayError)
|
|
1097
|
+
this.opts.onRelayError(err, info);
|
|
1098
|
+
else
|
|
1099
|
+
console.error(`[rindle api-server] stream ${info.streamId}: relay ${info.phase} failed:`, err);
|
|
1100
|
+
}
|
|
1101
|
+
catch (hookErr) {
|
|
1102
|
+
console.error(`[rindle api-server] stream ${info.streamId}: onRelayError itself threw:`, hookErr);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
794
1105
|
/** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
|
|
795
1106
|
* graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
|
|
796
1107
|
* than claiming completion (§5). Wire it to SIGTERM. */
|
|
@@ -802,6 +1113,9 @@ export class StreamPlane {
|
|
|
802
1113
|
for (const s of this.live.values())
|
|
803
1114
|
s.detachAll();
|
|
804
1115
|
this.live.clear();
|
|
1116
|
+
for (const s of [...this.relayed])
|
|
1117
|
+
s.close();
|
|
1118
|
+
this.relayed.clear();
|
|
805
1119
|
}
|
|
806
1120
|
/** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
|
|
807
1121
|
* still gets `end` (and the tail it missed) rather than a bare `absent`. */
|
|
@@ -950,6 +1264,18 @@ export class StreamForbidden extends Error {
|
|
|
950
1264
|
this.streamId = streamId;
|
|
951
1265
|
}
|
|
952
1266
|
}
|
|
1267
|
+
/** Best-effort adapter teardown (the `for await` discipline, by hand): never awaited into the
|
|
1268
|
+
* plane — a hung `return()` must not hold anything — and a synchronously-throwing one is the
|
|
1269
|
+
* adapter's bug, not the plane's problem. */
|
|
1270
|
+
function closeFrameSource(source, it) {
|
|
1271
|
+
try {
|
|
1272
|
+
const iter = it ?? source?.[Symbol.asyncIterator]();
|
|
1273
|
+
void Promise.resolve(iter?.return?.()).catch(() => { });
|
|
1274
|
+
}
|
|
1275
|
+
catch {
|
|
1276
|
+
// ignored — see above
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
953
1279
|
function oneFrame(streamId, frame) {
|
|
954
1280
|
let taken = false;
|
|
955
1281
|
return {
|