@rindle/api-server 0.9.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/src/streams.ts
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
|
|
@@ -177,6 +177,40 @@ export interface AuthorizeStreamInput<User> {
|
|
|
177
177
|
request?: unknown;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Optional cross-process transport for the LIVE plane
|
|
182
|
+
* (designs-implemented/LM-STREAM-RELAY-DESIGN.md). Both methods are independently optional; which
|
|
183
|
+
* ones you implement is which topology you built — an addressing adapter (a Durable Object named by
|
|
184
|
+
* `streamId`, `fly-replay`) implements only `attach`; a broadcast adapter (Redis pub/sub, NATS)
|
|
185
|
+
* mirrors with `publish` and subscribes with `attach`; a log adapter (Redis Streams, Kafka) appends
|
|
186
|
+
* and replays. Never consulted for the durable plane — checkpoints are unaffected by any of this.
|
|
187
|
+
*
|
|
188
|
+
* The plane does not trust what `attach` yields: frames are run through the conform pass
|
|
189
|
+
* ({@link StreamRelayConform}) and any contract violation downgrades the subscription to `stale`,
|
|
190
|
+
* which already means "you are on the durable plane now". A broken relay costs a reader smooth
|
|
191
|
+
* tokens, never corrupted text — and can never reach the producer.
|
|
192
|
+
*/
|
|
193
|
+
export interface StreamRelay {
|
|
194
|
+
/** Producer side: every frame this process's producer fans out (`chunk`, `durable`, and the
|
|
195
|
+
* terminal `end`), mirrored outward. MUST NOT block; a throw or rejected promise is caught and
|
|
196
|
+
* routed to {@link RindleStreamOptions.onRelayError} — a relay outage may cost the live leg,
|
|
197
|
+
* never the generation. Returned promises are observed but never awaited. */
|
|
198
|
+
publish?(streamId: string, frame: StreamFrame): void | PromiseLike<void>;
|
|
199
|
+
/** Subscriber side: this process is not hosting `streamId`. Return a frame source, or `undefined`
|
|
200
|
+
* for `absent` — exactly the no-relay answer. Consulted only AFTER `authorize` has passed, and
|
|
201
|
+
* only on a live-plane miss (a local stream always wins). The plane closes the source
|
|
202
|
+
* (`return()`) when the reader disconnects. An adapter that cannot serve `from` (pub/sub has no
|
|
203
|
+
* history) yields `stale` and stops — the reader converges on the durable plane (§5). */
|
|
204
|
+
attach?(streamId: string, from: number): Promise<AsyncIterable<StreamFrame> | undefined>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface StreamRelayErrorInfo {
|
|
208
|
+
streamId: string;
|
|
209
|
+
/** Where it failed: mirroring a frame out (`publish`), dialing the adapter (`attach`), or
|
|
210
|
+
* consuming/conforming its frames (`frames`). */
|
|
211
|
+
phase: "publish" | "attach" | "frames";
|
|
212
|
+
}
|
|
213
|
+
|
|
180
214
|
export interface RindleStreamOptions<User> {
|
|
181
215
|
/** Where checkpoints land: the app's tables (the default path) or a raw `commit` callback. */
|
|
182
216
|
checkpoint: StreamCheckpointTarget;
|
|
@@ -199,11 +233,26 @@ export interface RindleStreamOptions<User> {
|
|
|
199
233
|
retainChars?: number;
|
|
200
234
|
/** How long a sealed stream stays joinable before eviction. Default 30s. */
|
|
201
235
|
lingerMs?: number;
|
|
202
|
-
/** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024.
|
|
236
|
+
/** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024.
|
|
237
|
+
* Relayed readers reuse the same bound: a slow reader on a relayed stream costs itself the live
|
|
238
|
+
* leg exactly as a local one does. */
|
|
203
239
|
maxQueuedFrames?: number;
|
|
204
240
|
/** A checkpoint that exhausted its retries. The stream keeps streaming — this is a durability
|
|
205
241
|
* stall, not a stream stall — so the error must not vanish. Absent ⇒ `console.error`. */
|
|
206
242
|
onCheckpointError?: (err: unknown, info: { streamId: string; from: number; seq: number }) => void;
|
|
243
|
+
/** Cross-process transport for the live plane ({@link StreamRelay}). Without one, a subscriber
|
|
244
|
+
* that lands on a process not hosting its stream gets `absent` and reads the durable plane at
|
|
245
|
+
* checkpoint granularity — correct, just chunky. */
|
|
246
|
+
relay?: StreamRelay;
|
|
247
|
+
/** Bound on `relay.attach`: a hung adapter yields `absent`, not a hung HTTP request. An
|
|
248
|
+
* addressing adapter MAY deliberately spend this window waiting out a subscribe that races its
|
|
249
|
+
* own kick. Default 2000. */
|
|
250
|
+
relayAttachTimeoutMs?: number;
|
|
251
|
+
/** A diagnostic, never a control path: relay failures (a throwing or rejecting `publish`, a failed
|
|
252
|
+
* or timed-out `attach`, a conform violation in the frames) land here, wrapped so a throwing hook
|
|
253
|
+
* cannot reach the plane. The reader-facing outcome is always the same legal `absent`/`stale`.
|
|
254
|
+
* Absent ⇒ `console.error`. */
|
|
255
|
+
onRelayError?: (err: unknown, info: StreamRelayErrorInfo) => void;
|
|
207
256
|
}
|
|
208
257
|
|
|
209
258
|
// ------------------------------------------------------------------------------- producer handle
|
|
@@ -271,6 +320,7 @@ const DEFAULT_CHECKPOINT_RETRIES = 3;
|
|
|
271
320
|
const DEFAULT_RETAIN_CHARS = 64 * 1024;
|
|
272
321
|
const DEFAULT_LINGER_MS = 30_000;
|
|
273
322
|
const DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
323
|
+
const DEFAULT_RELAY_ATTACH_TIMEOUT_MS = 2000;
|
|
274
324
|
/** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
|
|
275
325
|
* authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
|
|
276
326
|
const RETRY_BACKOFF_MS = 50;
|
|
@@ -565,6 +615,156 @@ class Subscriber {
|
|
|
565
615
|
}
|
|
566
616
|
}
|
|
567
617
|
|
|
618
|
+
// ------------------------------------------------------------------------------- the relay conform pass
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* One frame source arriving over a relay, conformed to the CP §4 contract
|
|
622
|
+
* (designs-implemented/LM-STREAM-RELAY-DESIGN.md §4).
|
|
623
|
+
*
|
|
624
|
+
* An adapter is app code talking to Redis or a socket, and its frames feed `spliceStreamText` on a
|
|
625
|
+
* browser — so the plane does not trust them. This pass enforces the frame invariants against the
|
|
626
|
+
* prefix actually delivered and downgrades EVERY violation to a legal `stale` and nothing else:
|
|
627
|
+
* `stale` already means "you are on the durable plane now, the store is the whole truth", so a
|
|
628
|
+
* broken relay costs a reader smooth tokens, never corrupted text — and cannot wedge a producer.
|
|
629
|
+
*
|
|
630
|
+
* Replayed spans (a reconnecting adapter re-delivering what it already sent) are ABSORBED rather
|
|
631
|
+
* than punished — deduping against the delivered prefix is what makes reconnect-replay safe without
|
|
632
|
+
* every adapter hand-rolling it. Spans that overlap the prefix but extend past it pass through
|
|
633
|
+
* whole: the client splices at the frame's own offset, so an exact overlap re-covers and appends.
|
|
634
|
+
*
|
|
635
|
+
* Pure state, no I/O, no plane: `feed` maps one incoming frame to 0-2 outgoing frames (a missing
|
|
636
|
+
* `open` is synthesized at the join offset); `end`/`fail` close out a source that finished or threw
|
|
637
|
+
* without a terminal. After a terminal, every method returns `[]`.
|
|
638
|
+
*/
|
|
639
|
+
export class StreamRelayConform {
|
|
640
|
+
private readonly streamId: string;
|
|
641
|
+
/** The requested join offset — the synthesized `open`'s position, and where the prefix starts. */
|
|
642
|
+
private readonly from: number;
|
|
643
|
+
private readonly onViolation: ((reason: string) => void) | undefined;
|
|
644
|
+
/** End of the delivered prefix. */
|
|
645
|
+
private pos: number;
|
|
646
|
+
private lastDurable = 0;
|
|
647
|
+
private opened = false;
|
|
648
|
+
private done = false;
|
|
649
|
+
|
|
650
|
+
constructor(streamId: string, from: number, onViolation?: (reason: string) => void) {
|
|
651
|
+
this.streamId = streamId;
|
|
652
|
+
this.from = from;
|
|
653
|
+
this.pos = from;
|
|
654
|
+
this.onViolation = onViolation;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
feed(frame: StreamFrame): StreamFrame[] {
|
|
658
|
+
if (this.done) return [];
|
|
659
|
+
switch (frame.type) {
|
|
660
|
+
case "open": {
|
|
661
|
+
if (this.opened) return []; // a reconnecting adapter's second open: absorbed
|
|
662
|
+
if (
|
|
663
|
+
frame.streamId !== this.streamId ||
|
|
664
|
+
!Number.isInteger(frame.from) ||
|
|
665
|
+
!Number.isInteger(frame.seq) ||
|
|
666
|
+
!Number.isInteger(frame.durableSeq) ||
|
|
667
|
+
frame.from < 0 ||
|
|
668
|
+
frame.seq < frame.from
|
|
669
|
+
) {
|
|
670
|
+
return this.violate(`relay open for ${JSON.stringify(frame.streamId)} at ${frame.from} is malformed`);
|
|
671
|
+
}
|
|
672
|
+
// An open PAST the requested offset is the adapter saying it cannot serve `from` (pub/sub
|
|
673
|
+
// has no history, §5): delivering it would leave a hole in the middle of the response, so
|
|
674
|
+
// the honest answer is the durable plane.
|
|
675
|
+
if (frame.from > this.from) {
|
|
676
|
+
return this.violate(`relay open at ${frame.from} cannot serve the requested ${this.from}`);
|
|
677
|
+
}
|
|
678
|
+
this.opened = true;
|
|
679
|
+
this.pos = frame.from;
|
|
680
|
+
return [frame];
|
|
681
|
+
}
|
|
682
|
+
case "chunk": {
|
|
683
|
+
if (
|
|
684
|
+
!Number.isInteger(frame.from) ||
|
|
685
|
+
!Number.isInteger(frame.seq) ||
|
|
686
|
+
frame.from < 0 ||
|
|
687
|
+
typeof frame.text !== "string" ||
|
|
688
|
+
frame.text.length !== frame.seq - frame.from
|
|
689
|
+
) {
|
|
690
|
+
return this.violate(`relay chunk ${frame.from}→${frame.seq} does not span exactly its offsets`);
|
|
691
|
+
}
|
|
692
|
+
if (frame.from > this.pos) {
|
|
693
|
+
return this.violate(`relay chunk at ${frame.from} leaves a gap after ${this.pos}`);
|
|
694
|
+
}
|
|
695
|
+
if (frame.seq <= this.pos) return []; // entirely within the delivered prefix (a replay): absorbed
|
|
696
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
697
|
+
this.pos = frame.seq;
|
|
698
|
+
out.push(frame);
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
case "durable": {
|
|
702
|
+
// Purely informational to a reader (the hook ignores it; SSE uses it as a resume id), so a
|
|
703
|
+
// claim that rewinds — or outruns what this subscription has SEEN produced, which P forbids
|
|
704
|
+
// — is dropped rather than downgraded.
|
|
705
|
+
if (!Number.isInteger(frame.seq) || frame.seq < this.lastDurable || frame.seq > this.pos) return [];
|
|
706
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
707
|
+
this.lastDurable = frame.seq;
|
|
708
|
+
out.push(frame);
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
case "end": {
|
|
712
|
+
if (!Number.isInteger(frame.seq) || frame.seq < 0) {
|
|
713
|
+
return this.violate(`relay end at ${String(frame.seq)} is malformed`);
|
|
714
|
+
}
|
|
715
|
+
// An `end` whose durable length outruns the delivered prefix means the adapter LOST text
|
|
716
|
+
// (durable never exceeds produced), so the reader is short and must not be told it saw
|
|
717
|
+
// everything. Downgrading keeps `end` meaning the same thing relayed as local: the whole
|
|
718
|
+
// produced text arrived.
|
|
719
|
+
if (frame.seq > this.pos) {
|
|
720
|
+
return this.violate(`relay end at ${frame.seq} outruns the ${this.pos} characters delivered`);
|
|
721
|
+
}
|
|
722
|
+
this.done = true;
|
|
723
|
+
const out = this.opened ? [] : [this.synthOpen()];
|
|
724
|
+
out.push(frame);
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
case "stale":
|
|
728
|
+
case "absent": {
|
|
729
|
+
// Legal bare — the local plane's own floor/eviction answers carry no `open` either.
|
|
730
|
+
this.done = true;
|
|
731
|
+
return [frame];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The source completed without a terminal (a truncated relay): the reader falls back. */
|
|
737
|
+
end(): StreamFrame[] {
|
|
738
|
+
if (this.done) return [];
|
|
739
|
+
this.onViolation?.("relay source ended without a terminal frame");
|
|
740
|
+
return this.terminate();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The source threw mid-iteration, or the plane is dropping a reader that stopped draining:
|
|
744
|
+
* a bare `stale` at the delivered position. */
|
|
745
|
+
fail(): StreamFrame[] {
|
|
746
|
+
return this.done ? [] : this.terminate();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** A synthesized join, for an adapter that (correctly, in broadcast mode) never mirrors the
|
|
750
|
+
* per-subscriber `open`: positioned at the requested offset, which the reader asked from because
|
|
751
|
+
* its durable view already holds it. */
|
|
752
|
+
private synthOpen(): StreamFrame {
|
|
753
|
+
this.opened = true;
|
|
754
|
+
return { type: "open", streamId: this.streamId, from: this.from, seq: this.from, durableSeq: this.from, ended: false };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private terminate(): StreamFrame[] {
|
|
758
|
+
this.done = true;
|
|
759
|
+
return [{ type: "stale", floorSeq: this.pos, durableSeq: this.lastDurable }];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private violate(reason: string): StreamFrame[] {
|
|
763
|
+
this.onViolation?.(reason);
|
|
764
|
+
return this.terminate();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
568
768
|
// ------------------------------------------------------------------------------- the live stream
|
|
569
769
|
|
|
570
770
|
interface Waiter {
|
|
@@ -812,6 +1012,8 @@ class LiveStream<User> {
|
|
|
812
1012
|
status: seal.status,
|
|
813
1013
|
...(seal.error !== undefined ? { error: seal.error } : {}),
|
|
814
1014
|
};
|
|
1015
|
+
// The terminal reaches the relay too (it bypasses `fanout` locally only to bypass the cap).
|
|
1016
|
+
this.plane.publishRelay(this.streamId, frame);
|
|
815
1017
|
for (const sub of [...this.subs]) sub.finish(frame);
|
|
816
1018
|
this.plane.retire(this.streamId);
|
|
817
1019
|
if (this.sealError) this.sealed?.reject(this.sealError);
|
|
@@ -852,6 +1054,9 @@ class LiveStream<User> {
|
|
|
852
1054
|
// ---- subscriber side
|
|
853
1055
|
|
|
854
1056
|
private fanout(frame: StreamFrame): void {
|
|
1057
|
+
// Every frame local subscribers get, the relay gets — including when nobody local is attached
|
|
1058
|
+
// (a broadcast relay's whole point). Wrapped so an outage costs the live leg, never this stream.
|
|
1059
|
+
this.plane.publishRelay(this.streamId, frame);
|
|
855
1060
|
for (const sub of [...this.subs]) {
|
|
856
1061
|
if (!sub.offer(frame)) {
|
|
857
1062
|
// Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
|
|
@@ -921,8 +1126,11 @@ export class StreamPlane<User> {
|
|
|
921
1126
|
readonly retainChars: number;
|
|
922
1127
|
readonly lingerMs: number;
|
|
923
1128
|
readonly maxQueuedFrames: number;
|
|
1129
|
+
readonly relayAttachTimeoutMs: number;
|
|
924
1130
|
|
|
925
1131
|
private readonly live = new Map<string, LiveStream<User>>();
|
|
1132
|
+
/** Live relayed subscriptions, so teardown ({@link closeSync}) releases their drivers too. */
|
|
1133
|
+
private readonly relayed = new Set<Subscriber>();
|
|
926
1134
|
private readonly opts: RindleStreamOptions<User>;
|
|
927
1135
|
private readonly sink: StreamSqlSink | undefined;
|
|
928
1136
|
private readonly mapped: MappedTableSql | undefined;
|
|
@@ -939,7 +1147,15 @@ export class StreamPlane<User> {
|
|
|
939
1147
|
this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
|
|
940
1148
|
this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
|
|
941
1149
|
this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
|
|
1150
|
+
this.relayAttachTimeoutMs = opts.relayAttachTimeoutMs ?? DEFAULT_RELAY_ATTACH_TIMEOUT_MS;
|
|
942
1151
|
this.openToken = opts.hostId ?? randomOpenToken();
|
|
1152
|
+
// Relay misconfiguration is refused loudly (the room-profile rule), never ignored.
|
|
1153
|
+
if (opts.relay !== undefined && opts.relay.publish === undefined && opts.relay.attach === undefined) {
|
|
1154
|
+
throw new TypeError("streams.relay implements neither publish nor attach — which topology is this? (LM-STREAM-RELAY §3.1)");
|
|
1155
|
+
}
|
|
1156
|
+
if (opts.relay === undefined && (opts.relayAttachTimeoutMs !== undefined || opts.onRelayError !== undefined)) {
|
|
1157
|
+
throw new TypeError("streams.relayAttachTimeoutMs/onRelayError do nothing without streams.relay");
|
|
1158
|
+
}
|
|
943
1159
|
if ("tables" in opts.checkpoint) {
|
|
944
1160
|
if (!sink) throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
|
|
945
1161
|
this.tables = opts.checkpoint.tables;
|
|
@@ -1076,10 +1292,164 @@ export class StreamPlane<User> {
|
|
|
1076
1292
|
request: input.request,
|
|
1077
1293
|
});
|
|
1078
1294
|
if (verdict === false) throw new StreamForbidden(input.streamId);
|
|
1079
|
-
if (!stream)
|
|
1295
|
+
if (!stream) {
|
|
1296
|
+
// The relay is consulted only on a live-plane miss, and only after `authorize` passed — a
|
|
1297
|
+
// denial must not become an existence probe against the relay either. A local stream always
|
|
1298
|
+
// wins: the producer's own readers keep the lowest-latency path.
|
|
1299
|
+
const relayed = await this.attachRelay(input.streamId, from);
|
|
1300
|
+
return relayed ?? oneFrame(input.streamId, { type: "absent" });
|
|
1301
|
+
}
|
|
1080
1302
|
return stream.subscribe(from);
|
|
1081
1303
|
}
|
|
1082
1304
|
|
|
1305
|
+
/** The subscribe-miss leg (LM-STREAM-RELAY §3): ask the app's relay for the frames of a stream
|
|
1306
|
+
* this process is not hosting. `undefined` — no relay, no `attach`, the adapter declined, timed
|
|
1307
|
+
* out, or threw — is `absent`, exactly today's answer. */
|
|
1308
|
+
private async attachRelay(streamId: string, from: number): Promise<StreamSubscription | undefined> {
|
|
1309
|
+
const relay = this.opts.relay;
|
|
1310
|
+
if (relay?.attach === undefined) return undefined;
|
|
1311
|
+
// Floored like the local join, but clamped only below: the producer's length is not known here.
|
|
1312
|
+
const at = Number.isFinite(from) ? Math.max(Math.floor(from), 0) : 0;
|
|
1313
|
+
let source: AsyncIterable<StreamFrame> | undefined;
|
|
1314
|
+
try {
|
|
1315
|
+
source = await this.boundedAttach(relay, streamId, at);
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
this.reportRelayError(err, { streamId, phase: "attach" });
|
|
1318
|
+
return undefined;
|
|
1319
|
+
}
|
|
1320
|
+
if (source === undefined) return undefined;
|
|
1321
|
+
return this.relaySubscription(streamId, at, source);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** `attach`, bounded by {@link RindleStreamOptions.relayAttachTimeoutMs}: a hung adapter yields
|
|
1325
|
+
* `absent`, not a hung HTTP request. A source that resolves after the deadline is closed, not
|
|
1326
|
+
* leaked. */
|
|
1327
|
+
private boundedAttach(
|
|
1328
|
+
relay: StreamRelay,
|
|
1329
|
+
streamId: string,
|
|
1330
|
+
from: number,
|
|
1331
|
+
): Promise<AsyncIterable<StreamFrame> | undefined> {
|
|
1332
|
+
// `async` wrapping so a synchronously-throwing adapter is an attach failure, not a plane throw.
|
|
1333
|
+
const attempt = (async () => relay.attach!(streamId, from))();
|
|
1334
|
+
return new Promise((resolve, reject) => {
|
|
1335
|
+
let late = false;
|
|
1336
|
+
const t = timer(() => {
|
|
1337
|
+
late = true;
|
|
1338
|
+
reject(new Error(`stream ${streamId}: relay.attach timed out after ${this.relayAttachTimeoutMs}ms`));
|
|
1339
|
+
}, this.relayAttachTimeoutMs);
|
|
1340
|
+
attempt.then(
|
|
1341
|
+
(source) => {
|
|
1342
|
+
clearTimeout(t);
|
|
1343
|
+
if (!late) return resolve(source);
|
|
1344
|
+
closeFrameSource(source); // too late to serve the reader; don't leak the channel
|
|
1345
|
+
},
|
|
1346
|
+
(err) => {
|
|
1347
|
+
clearTimeout(t);
|
|
1348
|
+
if (!late) reject(err);
|
|
1349
|
+
},
|
|
1350
|
+
);
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Wrap an adapter's frame source as a plane subscription: conform every frame (LM-STREAM-RELAY
|
|
1355
|
+
* §4), bound the reader with the same queue cap as a local one (§7), and tear the adapter down
|
|
1356
|
+
* when either side lets go. The driver never throws into the plane: adapter failures become one
|
|
1357
|
+
* `stale`. */
|
|
1358
|
+
private relaySubscription(streamId: string, from: number, source: AsyncIterable<StreamFrame>): StreamSubscription {
|
|
1359
|
+
const conform = new StreamRelayConform(streamId, from, (reason) =>
|
|
1360
|
+
this.reportRelayError(new Error(reason), { streamId, phase: "frames" }),
|
|
1361
|
+
);
|
|
1362
|
+
let closed = false;
|
|
1363
|
+
let signalClose!: () => void;
|
|
1364
|
+
const closedP = new Promise<void>((resolve) => (signalClose = resolve));
|
|
1365
|
+
const closedTag = closedP.then(() => "closed" as const);
|
|
1366
|
+
const release = (): void => {
|
|
1367
|
+
if (!closed) {
|
|
1368
|
+
closed = true;
|
|
1369
|
+
signalClose();
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
const sub = new Subscriber(this.maxQueuedFrames, (s) => {
|
|
1373
|
+
this.relayed.delete(s);
|
|
1374
|
+
release();
|
|
1375
|
+
});
|
|
1376
|
+
this.relayed.add(sub);
|
|
1377
|
+
let it: AsyncIterator<StreamFrame> | undefined;
|
|
1378
|
+
/** @returns false once the subscription finished (terminal delivered, or the reader dropped). */
|
|
1379
|
+
const deliver = (frames: StreamFrame[]): boolean => {
|
|
1380
|
+
for (const frame of frames) {
|
|
1381
|
+
if (frame.type === "end" || frame.type === "stale" || frame.type === "absent") {
|
|
1382
|
+
sub.finish(frame);
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
if (!sub.offer(frame)) {
|
|
1386
|
+
// The same bound as a local reader: a relayed subscriber that stops draining costs
|
|
1387
|
+
// itself the live leg, never unbounded memory.
|
|
1388
|
+
const [stale] = conform.fail();
|
|
1389
|
+
if (stale) sub.finish(stale);
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return true;
|
|
1394
|
+
};
|
|
1395
|
+
void (async () => {
|
|
1396
|
+
try {
|
|
1397
|
+
// Iterator construction is adapter code too: keep a throwing factory inside the same
|
|
1398
|
+
// stale/report/cleanup boundary as a throwing `next()`.
|
|
1399
|
+
it = source[Symbol.asyncIterator]();
|
|
1400
|
+
for (;;) {
|
|
1401
|
+
// Raced rather than awaited bare: a reader disconnect must release this driver even when
|
|
1402
|
+
// the adapter never yields another frame (its own `return()` may be queued behind the
|
|
1403
|
+
// pending `next()` forever).
|
|
1404
|
+
const res = await Promise.race([it.next(), closedTag]);
|
|
1405
|
+
if (res === "closed") return;
|
|
1406
|
+
if (res.done) {
|
|
1407
|
+
deliver(conform.end());
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (!deliver(conform.feed(res.value))) return;
|
|
1411
|
+
}
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
this.reportRelayError(err, { streamId, phase: "frames" });
|
|
1414
|
+
deliver(conform.fail());
|
|
1415
|
+
} finally {
|
|
1416
|
+
release();
|
|
1417
|
+
// If construction itself threw there is no iterator to return, and asking the source to
|
|
1418
|
+
// construct a second one during cleanup could repeat side effects or throw again.
|
|
1419
|
+
closeFrameSource(undefined, it);
|
|
1420
|
+
}
|
|
1421
|
+
})();
|
|
1422
|
+
return { streamId, frames: sub.frames(), close: () => sub.close() };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** Mirror one producer frame outward (LM-STREAM-RELAY §3). Never blocks or breaks the producer:
|
|
1426
|
+
* a throw or rejected promise is reported and swallowed — a relay outage may cost relayed
|
|
1427
|
+
* readers the live leg, never the generation or its checkpoints. */
|
|
1428
|
+
publishRelay(streamId: string, frame: StreamFrame): void {
|
|
1429
|
+
const relay = this.opts.relay;
|
|
1430
|
+
if (relay?.publish === undefined) return;
|
|
1431
|
+
try {
|
|
1432
|
+
const published = relay.publish(streamId, frame);
|
|
1433
|
+
if (published !== undefined) {
|
|
1434
|
+
void Promise.resolve(published).catch((err) =>
|
|
1435
|
+
this.reportRelayError(err, { streamId, phase: "publish" }),
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
this.reportRelayError(err, { streamId, phase: "publish" });
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
reportRelayError(err: unknown, info: StreamRelayErrorInfo): void {
|
|
1444
|
+
// Same discipline as reportCheckpointError: a diagnostic must never take its caller down.
|
|
1445
|
+
try {
|
|
1446
|
+
if (this.opts.onRelayError) this.opts.onRelayError(err, info);
|
|
1447
|
+
else console.error(`[rindle api-server] stream ${info.streamId}: relay ${info.phase} failed:`, err);
|
|
1448
|
+
} catch (hookErr) {
|
|
1449
|
+
console.error(`[rindle api-server] stream ${info.streamId}: onRelayError itself threw:`, hookErr);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1083
1453
|
/** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
|
|
1084
1454
|
* graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
|
|
1085
1455
|
* than claiming completion (§5). Wire it to SIGTERM. */
|
|
@@ -1091,6 +1461,8 @@ export class StreamPlane<User> {
|
|
|
1091
1461
|
closeSync(): void {
|
|
1092
1462
|
for (const s of this.live.values()) s.detachAll();
|
|
1093
1463
|
this.live.clear();
|
|
1464
|
+
for (const s of [...this.relayed]) s.close();
|
|
1465
|
+
this.relayed.clear();
|
|
1094
1466
|
}
|
|
1095
1467
|
|
|
1096
1468
|
/** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
|
|
@@ -1240,6 +1612,18 @@ export class StreamForbidden extends Error {
|
|
|
1240
1612
|
}
|
|
1241
1613
|
}
|
|
1242
1614
|
|
|
1615
|
+
/** Best-effort adapter teardown (the `for await` discipline, by hand): never awaited into the
|
|
1616
|
+
* plane — a hung `return()` must not hold anything — and a synchronously-throwing one is the
|
|
1617
|
+
* adapter's bug, not the plane's problem. */
|
|
1618
|
+
function closeFrameSource(source: AsyncIterable<StreamFrame> | undefined, it?: AsyncIterator<StreamFrame>): void {
|
|
1619
|
+
try {
|
|
1620
|
+
const iter = it ?? source?.[Symbol.asyncIterator]();
|
|
1621
|
+
void Promise.resolve(iter?.return?.()).catch(() => {});
|
|
1622
|
+
} catch {
|
|
1623
|
+
// ignored — see above
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1243
1627
|
function oneFrame(streamId: string, frame: StreamFrame): StreamSubscription {
|
|
1244
1628
|
let taken = false;
|
|
1245
1629
|
return {
|