@rindle/remote 0.4.4 → 0.6.3
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/dist/affinity.d.ts +49 -0
- package/dist/affinity.d.ts.map +1 -0
- package/dist/affinity.js +95 -0
- package/dist/affinity.js.map +1 -0
- package/dist/backend.d.ts +5 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +44 -3
- package/dist/backend.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/optimistic-source.d.ts +48 -3
- package/dist/optimistic-source.d.ts.map +1 -1
- package/dist/optimistic-source.js +169 -7
- package/dist/optimistic-source.js.map +1 -1
- package/dist/protocol.d.ts +13 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/query-error.d.ts +17 -0
- package/dist/query-error.d.ts.map +1 -0
- package/dist/query-error.js +29 -0
- package/dist/query-error.js.map +1 -0
- package/dist/remote-source.d.ts +5 -0
- package/dist/remote-source.d.ts.map +1 -1
- package/dist/remote-source.js +44 -3
- package/dist/remote-source.js.map +1 -1
- package/dist/transport.d.ts +6 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +10 -1
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/affinity.ts +137 -0
- package/src/backend.ts +46 -3
- package/src/index.ts +4 -0
- package/src/optimistic-source.ts +184 -7
- package/src/protocol.ts +17 -1
- package/src/query-error.ts +42 -0
- package/src/remote-source.ts +46 -3
- package/src/transport.ts +11 -2
package/src/optimistic-source.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { LMID_QUERY_NAME } from "@rindle/client";
|
|
17
17
|
import type {
|
|
18
18
|
MutationEnvelope,
|
|
19
|
+
MutationOutcomeFrame,
|
|
19
20
|
NormalizedEvent,
|
|
20
21
|
NormalizedTableSchema,
|
|
21
22
|
OptimisticSource,
|
|
@@ -24,7 +25,9 @@ import type {
|
|
|
24
25
|
RemoteQuery,
|
|
25
26
|
} from "@rindle/client";
|
|
26
27
|
|
|
28
|
+
import type { AffinityTicketStore } from "./affinity.ts";
|
|
27
29
|
import { NormalizedSubscriber } from "./normalized.ts";
|
|
30
|
+
import { retryDelayMs } from "./query-error.ts";
|
|
28
31
|
import type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
|
|
29
32
|
import { ProtocolError } from "./protocol.ts";
|
|
30
33
|
import type { ServerMsg } from "./protocol.ts";
|
|
@@ -48,6 +51,16 @@ interface QState {
|
|
|
48
51
|
resubscribing: boolean;
|
|
49
52
|
/** Monotonic token that cancels stale async lease resolutions. */
|
|
50
53
|
subscribeTicket: number;
|
|
54
|
+
/** Pending retryable-error re-subscribe timer (FOLLOWER-LAG-SHED §6.3), if any. */
|
|
55
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
/** Consecutive retryable errors without a successful hello — the backoff exponent. */
|
|
57
|
+
retryAttempt: number;
|
|
58
|
+
/** Whether the LAST subscribe sent for this query presented a `leaseToken` — i.e. its hello
|
|
59
|
+
* proves the connection is (re-)AUTHENTICATED (a room shell sets the socket's subject from the
|
|
60
|
+
* first verified token; `pushMutation` requires it). After a reconnect on a lease-auth session,
|
|
61
|
+
* queued pushes flush at the first such hello, never earlier — an envelope racing ahead of the
|
|
62
|
+
* token subscribe would be refused by the shell's subject gate (H-v §7.5 rule 3). */
|
|
63
|
+
authed: boolean;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/** Build a transport to a follower's public ws endpoint (READ-ROUTER-DESIGN.md §2.3). Default
|
|
@@ -71,6 +84,11 @@ export interface RemoteOptimisticSourceOptions {
|
|
|
71
84
|
resolveSubscribe?: SubscribeResolver;
|
|
72
85
|
/** Override named-mutator delivery, e.g. POST envelopes to the app API server. */
|
|
73
86
|
pushMutation?: MutationEnvelopeSender;
|
|
87
|
+
/** Follower-affinity mode (FOLLOWER-AFFINITY-DESIGN.md §3): the shared ticket store. When set, the
|
|
88
|
+
* source records the follower's minted ticket from the `{t:"affinity"}` frame and CLEARS it on a
|
|
89
|
+
* sustained outage so the next (ticketless) reconnect anycasts to a live follower and re-pins
|
|
90
|
+
* (§8). Absent ⇒ affinity off (today's behavior). */
|
|
91
|
+
affinity?: AffinityTicketStore;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
export class RemoteOptimisticSource implements OptimisticSource {
|
|
@@ -83,9 +101,23 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
83
101
|
private readonly clientID: string;
|
|
84
102
|
private readonly resolveSubscribe: SubscribeResolver;
|
|
85
103
|
private readonly pushMutationSender?: MutationEnvelopeSender;
|
|
104
|
+
/** The affinity ticket store (undefined ⇒ affinity off). See {@link RemoteOptimisticSourceOptions.affinity}. */
|
|
105
|
+
private readonly affinityStore?: AffinityTicketStore;
|
|
86
106
|
private handler: (qid: QueryId, ev: NormalizedEvent) => void = () => {};
|
|
87
107
|
private progressHandler: (frame: ProgressFrame) => void = () => {};
|
|
88
108
|
private restartHandler: () => void = () => {};
|
|
109
|
+
private outcomeHandler: (frame: MutationOutcomeFrame) => void = () => {};
|
|
110
|
+
private resyncHandler: () => void = () => {};
|
|
111
|
+
/** Set by {@link resync} when this is a LEASE-AUTH session (some sub presented a `leaseToken`):
|
|
112
|
+
* transport pushes queue in {@link pendingPushes} until the first authenticated re-subscribe's
|
|
113
|
+
* hello re-establishes the socket's subject, then flush (see QState.authed). Never set on a
|
|
114
|
+
* token-less (embedded/rindled) session — its pushes need no subject and go straight out. */
|
|
115
|
+
private awaitingAuthedHello = false;
|
|
116
|
+
/** Envelopes held while {@link awaitingAuthedHello} (H-v §7.5 rule 3). Every entry corresponds
|
|
117
|
+
* to a still-pending backend mutation (the re-send reconstructs from pending entries; app
|
|
118
|
+
* invokes in the window are pending by definition), so a superseding resync may CLEAR this —
|
|
119
|
+
* its own re-send regenerates whatever still matters. */
|
|
120
|
+
private pendingPushes: MutationEnvelope[] = [];
|
|
89
121
|
private readonly subs = new Map<QueryId, QState>();
|
|
90
122
|
/** Queries whose subscribe is waiting for a transport to exist — endpoint-less subscribes issued
|
|
91
123
|
* in pure-lazy mode before any lease opens a transport (the lmid system query is registered by
|
|
@@ -110,6 +142,7 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
110
142
|
this.clientID = clientID;
|
|
111
143
|
this.resolveSubscribe = opts.resolveSubscribe ?? defaultSubscribeTarget;
|
|
112
144
|
this.pushMutationSender = opts.pushMutation;
|
|
145
|
+
this.affinityStore = opts.affinity;
|
|
113
146
|
if (isTransport(connection)) {
|
|
114
147
|
this.transportFactory = undefined;
|
|
115
148
|
this.bringUp(connection, undefined);
|
|
@@ -126,7 +159,13 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
126
159
|
private attach(transport: Transport): void {
|
|
127
160
|
transport.onMessage((msg) => this.onServerMsg(msg));
|
|
128
161
|
// Heal a dropped/restarted connection (same endpoint): on reconnect, replay init + re-subscribe.
|
|
129
|
-
transport.onReconnect?.(() =>
|
|
162
|
+
transport.onReconnect?.(() => {
|
|
163
|
+
// The held ticket was useful for routing this ws handshake, but HTTP re-leases must wait for
|
|
164
|
+
// THIS connection's first affinity frame. Otherwise an expired/rotated persisted ticket can
|
|
165
|
+
// independently re-pin the control leg before the fresh frame arrives.
|
|
166
|
+
this.affinityStore?.connectionPending();
|
|
167
|
+
this.resync();
|
|
168
|
+
});
|
|
130
169
|
// Sustained outage on this endpoint: re-lease (the router may move us off a dead follower, §3).
|
|
131
170
|
transport.onDown?.(() => this.onDown());
|
|
132
171
|
}
|
|
@@ -135,6 +174,10 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
135
174
|
private bringUp(transport: Transport, endpoint: string | undefined): void {
|
|
136
175
|
this.transport = transport;
|
|
137
176
|
this.currentEndpoint = endpoint;
|
|
177
|
+
// `WsTransport` has already evaluated the ticket thunk while constructing this connection.
|
|
178
|
+
// From this point the old/persisted ticket is handshake-only until the follower confirms the
|
|
179
|
+
// selected machine with its first affinity frame.
|
|
180
|
+
this.affinityStore?.connectionPending();
|
|
138
181
|
this.attach(transport);
|
|
139
182
|
transport.send({ t: "init", clientID: this.clientID });
|
|
140
183
|
this.flushDeferred();
|
|
@@ -187,7 +230,16 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
187
230
|
* lease ever carried a `wsEndpoint`) — there is nowhere to move, so we keep the pre-router
|
|
188
231
|
* behavior and let the transport's own reconnect→resync recover. */
|
|
189
232
|
private onDown(): void {
|
|
190
|
-
if (this.closed
|
|
233
|
+
if (this.closed) return;
|
|
234
|
+
if (this.affinityStore) {
|
|
235
|
+
// Affinity: the pinned follower is gone (sustained outage). Drop the ticket so the transport's
|
|
236
|
+
// ongoing reconnects go TICKETLESS — the Fly edge then anycasts to a live follower, which mints
|
|
237
|
+
// a fresh ticket, and that reconnect's `onReconnect` → resync re-leases there (FOLLOWER-AFFINITY
|
|
238
|
+
// §8, one bounded reassignment). Nothing to migrate: the ws host is fixed, Fly routes by ticket.
|
|
239
|
+
this.affinityStore.clear();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!this.sawRoutedEndpoint) return;
|
|
191
243
|
this.resubscribeAll();
|
|
192
244
|
}
|
|
193
245
|
|
|
@@ -196,8 +248,12 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
196
248
|
close(): void {
|
|
197
249
|
this.closed = true;
|
|
198
250
|
this.transport?.close();
|
|
251
|
+
for (const s of this.subs.values()) {
|
|
252
|
+
if (s.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
253
|
+
}
|
|
199
254
|
this.subs.clear();
|
|
200
255
|
this.deferred.clear();
|
|
256
|
+
this.pendingPushes.length = 0;
|
|
201
257
|
}
|
|
202
258
|
|
|
203
259
|
/** Register a handler fired when the DAEMON restarts (a new boot id) — the backend resets its
|
|
@@ -211,11 +267,22 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
211
267
|
}
|
|
212
268
|
|
|
213
269
|
registerQuery(qid: QueryId, remote: RemoteQuery): void {
|
|
214
|
-
this.subs.set(qid, {
|
|
270
|
+
this.subs.set(qid, {
|
|
271
|
+
remote,
|
|
272
|
+
subscriber: null,
|
|
273
|
+
epoch: 0,
|
|
274
|
+
resubscribing: false,
|
|
275
|
+
subscribeTicket: 0,
|
|
276
|
+
retryTimer: undefined,
|
|
277
|
+
retryAttempt: 0,
|
|
278
|
+
authed: false,
|
|
279
|
+
});
|
|
215
280
|
this.subscribe(qid, remote);
|
|
216
281
|
}
|
|
217
282
|
|
|
218
283
|
unregisterQuery(qid: QueryId): void {
|
|
284
|
+
const s = this.subs.get(qid);
|
|
285
|
+
if (s?.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
219
286
|
this.subs.delete(qid);
|
|
220
287
|
this.deferred.delete(qid);
|
|
221
288
|
this.transport?.send({ t: "unsubscribe", queryId: qid });
|
|
@@ -223,6 +290,13 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
223
290
|
|
|
224
291
|
pushMutation(envelope: MutationEnvelope): Promise<void> {
|
|
225
292
|
if (this.pushMutationSender) return Promise.resolve(this.pushMutationSender(envelope));
|
|
293
|
+
// A lease-auth session that just reconnected is not yet re-authenticated (the shell's
|
|
294
|
+
// `pushMutation` subject gate would refuse) — hold the envelope until the first token
|
|
295
|
+
// re-subscribe's hello, then flush in order (H-v §7.5 rule 3).
|
|
296
|
+
if (this.awaitingAuthedHello) {
|
|
297
|
+
this.pendingPushes.push(envelope);
|
|
298
|
+
return Promise.resolve();
|
|
299
|
+
}
|
|
226
300
|
this.transport?.send({ t: "pushMutation", envelope });
|
|
227
301
|
return Promise.resolve();
|
|
228
302
|
}
|
|
@@ -235,11 +309,33 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
235
309
|
this.progressHandler = handler;
|
|
236
310
|
}
|
|
237
311
|
|
|
312
|
+
/** The room deopt handshake's verdict stream (H-v). Dispatched OUT-OF-BAND on arrival — see
|
|
313
|
+
* {@link onServerMsg}'s `mutationOutcome` arm for why it must never wait behind the cv buffer. */
|
|
314
|
+
onMutationOutcome(handler: (frame: MutationOutcomeFrame) => void): void {
|
|
315
|
+
this.outcomeHandler = handler;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Fired once per re-established session, SYNCHRONOUSLY inside {@link resync} — before any
|
|
319
|
+
* post-reconnect frame can release (the §7.5 rule-3 window: a replayed lmid snapshot must not
|
|
320
|
+
* retire an entry whose outcome frame died with the old socket before the re-send captured
|
|
321
|
+
* it). The backend re-sends the domain's unconfirmed pending envelopes with their original
|
|
322
|
+
* mids; on a lease-auth session their DELIVERY is deferred until the first token hello
|
|
323
|
+
* re-authenticates the socket ({@link pendingPushes}). */
|
|
324
|
+
onResync(handler: () => void): void {
|
|
325
|
+
this.resyncHandler = handler;
|
|
326
|
+
}
|
|
327
|
+
|
|
238
328
|
// --- internals ---------------------------------------------------------------
|
|
239
329
|
|
|
240
330
|
private subscribe(qid: QueryId, remote: RemoteQuery): void {
|
|
241
331
|
const s = this.subs.get(qid);
|
|
242
332
|
if (!s) return;
|
|
333
|
+
// A fresh subscribe (reconnect resync, gap recovery, the retry timer itself) supersedes any
|
|
334
|
+
// scheduled retryable-error retry — never leave two subscribe paths racing for one query.
|
|
335
|
+
if (s.retryTimer !== undefined) {
|
|
336
|
+
clearTimeout(s.retryTimer);
|
|
337
|
+
s.retryTimer = undefined;
|
|
338
|
+
}
|
|
243
339
|
const request = { queryId: qid, remote, mode: "normalized" as const };
|
|
244
340
|
const ticket = ++s.subscribeTicket;
|
|
245
341
|
const send = (target: SubscribeTarget) => {
|
|
@@ -247,6 +343,7 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
247
343
|
const cur = this.subs.get(qid);
|
|
248
344
|
if (cur !== s || cur.subscribeTicket !== ticket) return;
|
|
249
345
|
const endpoint = "leaseToken" in target ? target.wsEndpoint : undefined;
|
|
346
|
+
s.authed = "leaseToken" in target; // a token subscribe (re-)authenticates the socket (H-v)
|
|
250
347
|
if (endpoint !== undefined) this.sawRoutedEndpoint = true;
|
|
251
348
|
if (this.transport) {
|
|
252
349
|
if (endpoint !== undefined && endpoint !== this.currentEndpoint && this.transportFactory) {
|
|
@@ -291,13 +388,34 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
291
388
|
}
|
|
292
389
|
|
|
293
390
|
private onServerMsg(msg: ServerMsg): void {
|
|
391
|
+
if (msg.t === "affinity") {
|
|
392
|
+
// Connection-level: the follower minted/refreshed this connection's placement ticket. Persist
|
|
393
|
+
// it (via the store) so the next connect offers it as a subprotocol and the lease POST forwards
|
|
394
|
+
// it — both legs then pin THIS follower (§4). Off ⇒ no store ⇒ dropped.
|
|
395
|
+
this.affinityStore?.set(msg.ticket);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
294
398
|
if (msg.t === "progress") {
|
|
295
399
|
this.progressHandler(msg.frame);
|
|
296
400
|
return;
|
|
297
401
|
}
|
|
402
|
+
if (msg.t === "mutationOutcome") {
|
|
403
|
+
// OUT-OF-BAND BY DESIGN (H-v): the frame has no `cv`, so it must NEVER be routed through the
|
|
404
|
+
// backend's cv buffer — dispatch immediately. A deopt has to migrate its pending entry to
|
|
405
|
+
// the daemon stream BEFORE the buffered lmid release that would otherwise retire it as a
|
|
406
|
+
// success (silence + lmid coverage ⇒ applied), and the §7.3 hold-back trigger — keyed on the
|
|
407
|
+
// entry's confirming domain — would then park its staged writes the wrong way.
|
|
408
|
+
this.outcomeHandler({
|
|
409
|
+
mid: msg.mid,
|
|
410
|
+
kind: msg.kind,
|
|
411
|
+
...(msg.reason !== undefined ? { reason: msg.reason } : {}),
|
|
412
|
+
...(msg.name !== undefined ? { name: msg.name } : {}),
|
|
413
|
+
...("args" in msg ? { args: msg.args } : {}),
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
298
417
|
if (msg.t === "queryError") {
|
|
299
|
-
this.
|
|
300
|
-
console.error(`[rindle-remote] optimistic query ${msg.queryId} subscription rejected: ${msg.message}`);
|
|
418
|
+
this.onQueryError(msg.queryId, msg);
|
|
301
419
|
return;
|
|
302
420
|
}
|
|
303
421
|
if (msg.t !== "nhello" && msg.t !== "nbatch") return;
|
|
@@ -310,11 +428,26 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
310
428
|
else this.applyBatch(msg.queryId, s, msg.batch);
|
|
311
429
|
}
|
|
312
430
|
|
|
313
|
-
/** On reconnect: re-announce identity and re-subscribe every live
|
|
314
|
-
* lease, so a restarted daemon re-materializes + re-leases on the
|
|
431
|
+
/** On reconnect: re-announce identity, fire the `onResync` re-send, and re-subscribe every live
|
|
432
|
+
* query (each re-resolves its lease, so a restarted daemon re-materializes + re-leases on the
|
|
433
|
+
* fly). The re-send fires HERE — synchronously, before any post-reconnect frame can be
|
|
434
|
+
* processed — because the §7.5 rule-3 window closes fast: the re-subscribed lmid stream's
|
|
435
|
+
* fresh snapshot may cover a mid whose outcome frame died with the OLD socket, and once the
|
|
436
|
+
* release retires that entry as an apparent success there is nothing left to re-send (the
|
|
437
|
+
* lost-deopt write would silently vanish). Firing now captures the in-flight set intact; on a
|
|
438
|
+
* lease-auth session the envelopes themselves are HELD ({@link pendingPushes}) until the first
|
|
439
|
+
* token re-subscribe's hello re-authenticates the socket, then flush in order — so the shell's
|
|
440
|
+
* subject gate never refuses them, and its re-answer (a recorded outcome for any non-applied
|
|
441
|
+
* mid) resolves even an already-retired entry via the handshake's not-found arm. */
|
|
315
442
|
private resync(): void {
|
|
316
443
|
if (this.closed) return;
|
|
317
444
|
this.transport?.send({ t: "init", clientID: this.clientID });
|
|
445
|
+
// Lease-auth session ⇒ hold pushes until re-authed. A stale queue from a superseded resync is
|
|
446
|
+
// cleared first: every held envelope maps to a still-pending mutation, and THIS resync's
|
|
447
|
+
// re-send below regenerates whatever still matters (no loss, no stale duplicates).
|
|
448
|
+
this.pendingPushes.length = 0;
|
|
449
|
+
this.awaitingAuthedHello = [...this.subs.values()].some((s) => s.authed);
|
|
450
|
+
this.resyncHandler();
|
|
318
451
|
this.resubscribeAll();
|
|
319
452
|
}
|
|
320
453
|
|
|
@@ -325,11 +458,55 @@ export class RemoteOptimisticSource implements OptimisticSource {
|
|
|
325
458
|
this.lastBootId = bootId;
|
|
326
459
|
}
|
|
327
460
|
|
|
461
|
+
/** Route a `queryError` by its 101 §5 classification: retryable ⇒ keep the QState (and the
|
|
462
|
+
* rows already folded downstream — 101 §6) and re-subscribe after a jittered backoff
|
|
463
|
+
* honoring `retryAfterMs`; terminal (or pre-classification servers) ⇒ drop the
|
|
464
|
+
* subscription, exactly as before. Fixes the stranded-client gap: a worker fault's or a
|
|
465
|
+
* shedding follower's error now heals end-to-end (FOLLOWER-LAG-SHED §6.3). */
|
|
466
|
+
private onQueryError(qid: QueryId, err: { message: string; code?: string; retryable?: boolean; retryAfterMs?: number }): void {
|
|
467
|
+
const s = this.subs.get(qid);
|
|
468
|
+
if (!s) return;
|
|
469
|
+
if (err.retryable !== true) {
|
|
470
|
+
if (s.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
471
|
+
this.subs.delete(qid);
|
|
472
|
+
console.error(`[rindle-remote] optimistic query ${qid} subscription rejected: ${err.message}`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (s.retryTimer !== undefined) return; // a retry is already scheduled — don't stack them
|
|
476
|
+
s.subscriber = null; // stop validating the dead epoch; recovery is a fresh seq-0 hydrate
|
|
477
|
+
s.resubscribing = true;
|
|
478
|
+
const delay = retryDelayMs(s.retryAttempt++, err.retryAfterMs);
|
|
479
|
+
console.warn(
|
|
480
|
+
`[rindle-remote] optimistic query ${qid} ${err.code ?? "error"} (retryable): re-subscribing in ${delay}ms: ${err.message}`,
|
|
481
|
+
);
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
const cur = this.subs.get(qid);
|
|
484
|
+
if (cur !== s) return;
|
|
485
|
+
s.retryTimer = undefined;
|
|
486
|
+
this.subscribe(qid, s.remote);
|
|
487
|
+
}, delay);
|
|
488
|
+
// Node returns a Timeout (unref keeps a retiring process from being pinned by a retry);
|
|
489
|
+
// browsers return a number, where the optional call is a no-op.
|
|
490
|
+
(timer as { unref?: () => void }).unref?.();
|
|
491
|
+
s.retryTimer = timer;
|
|
492
|
+
}
|
|
493
|
+
|
|
328
494
|
private openSubscriber(qid: QueryId, s: QState, hello: NormalizedHello): void {
|
|
329
495
|
try {
|
|
330
496
|
s.subscriber = new NormalizedSubscriber(hello, (ev) => this.handler(qid, ev), this.clientTables);
|
|
331
497
|
s.epoch = hello.epoch;
|
|
332
498
|
s.resubscribing = false;
|
|
499
|
+
s.retryAttempt = 0; // a successful hello resets the retryable-error backoff
|
|
500
|
+
// H-v §7.5 rule 3: the first AUTHENTICATED hello after a reconnect proves the socket is
|
|
501
|
+
// re-authorized (the shell set its subject from the verified token — pushMutation-ready):
|
|
502
|
+
// flush the held envelopes, in order. A token-less hello (the lmid system query) does not
|
|
503
|
+
// qualify — an envelope racing ahead of the lease-token subscribe would be refused.
|
|
504
|
+
if (this.awaitingAuthedHello && s.authed) {
|
|
505
|
+
this.awaitingAuthedHello = false;
|
|
506
|
+
for (const envelope of this.pendingPushes.splice(0)) {
|
|
507
|
+
this.transport?.send({ t: "pushMutation", envelope });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
333
510
|
} catch (e) {
|
|
334
511
|
// A comparator/fp mismatch at hello is unrecoverable (a code-contract divergence).
|
|
335
512
|
s.subscriber = null;
|
package/src/protocol.ts
CHANGED
|
@@ -135,8 +135,24 @@ export type ServerMsg =
|
|
|
135
135
|
// or `cv` state — so the client must force a clean re-hydrate (reset its `cv` watermark).
|
|
136
136
|
| { t: "nhello"; queryId: number; hello: NormalizedHello; bootId?: string }
|
|
137
137
|
| { t: "nbatch"; queryId: number; batch: NormalizedBatch }
|
|
138
|
-
|
|
138
|
+
// `code`/`retryable`/`retryAfterMs` classify the error per 101-QUERY-ERRORS §5 (absent from
|
|
139
|
+
// older servers ⇒ terminal): a retryable error (`code:"shed"` from a load-shedding follower,
|
|
140
|
+
// `code:"faulted"` from a worker fault) schedules a backed-off re-subscribe instead of
|
|
141
|
+
// stranding the subscription (FOLLOWER-LAG-SHED §6.3).
|
|
142
|
+
| { t: "queryError"; queryId: number; message: string; code?: string; retryable?: boolean; retryAfterMs?: number }
|
|
139
143
|
| { t: "progress"; frame: ProgressFrame }
|
|
144
|
+
// The follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), minted by the fleet follower as
|
|
145
|
+
// the FIRST frame after the ws opens (`rust/rindle-server/src/net.rs` `serve_ws_conn`). Opaque +
|
|
146
|
+
// connection-level (no `queryId`, no `cv`): the client persists it and offers it as a subprotocol
|
|
147
|
+
// on the next connect + forwards it on the lease POST so both legs pin the same follower. Absent
|
|
148
|
+
// on a single/affinity-off daemon (never sent) — old clients drop the unknown `t`.
|
|
149
|
+
| { t: "affinity"; ticket: string }
|
|
150
|
+
// The room deopt handshake's verdict frame (RINDLE-REALTIME-QUERY-ENABLEMENT §3.3, H-iv-b):
|
|
151
|
+
// sent on the author's socket for every NON-applied mutation, BEFORE the lmid ack that burns
|
|
152
|
+
// the mid; `name`/`args` ride `kind:"deopt"` only (self-contained re-invoke). Connection-level
|
|
153
|
+
// and cv-less — the client dispatches it OUT-OF-BAND, never behind the cv buffer. Additive:
|
|
154
|
+
// old clients drop unknown `t`.
|
|
155
|
+
| { t: "mutationOutcome"; mid: number; kind: "deopt" | "rejected"; reason?: string; name?: string; args?: unknown }
|
|
140
156
|
// A per-connection error reply: the server could not process this client's message (bad
|
|
141
157
|
// table/AST, a commit/derive failure). Sent INSTEAD of crashing the process — the connection
|
|
142
158
|
// stays open and every other connection is unaffected. Existing clients ignore unknown `t`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Retryable-vs-terminal `queryError` handling (101-QUERY-ERRORS-DESIGN.md §5, the client half
|
|
2
|
+
// of FOLLOWER-LAG-SHED-DESIGN.md §6.3 — the load-bearing contract fix).
|
|
3
|
+
//
|
|
4
|
+
// A `queryError` used to be terminal on every source: `subs.delete(queryId)`, no re-subscribe,
|
|
5
|
+
// so a server-side worker fault or a follower shedding load STRANDED the subscription forever
|
|
6
|
+
// (only a transport reconnect or a seq gap ever re-subscribed). Per 101 §5 the server now
|
|
7
|
+
// classifies: a frame carrying `retryable: true` (a shed follower's `code:"shed"`, a worker
|
|
8
|
+
// fault's `code:"faulted"`, a lease expiry) means the subscription is still WANTED and the
|
|
9
|
+
// server expects the client to come back — with jittered backoff honoring `retryAfterMs`.
|
|
10
|
+
//
|
|
11
|
+
// Rows already folded downstream are deliberately NOT cleared on a retryable error (101 §6 —
|
|
12
|
+
// row lifecycle stays server-driven): the degraded UX is a frozen but fully-rendered consistent
|
|
13
|
+
// view, the optimistic layer still absorbing local writes, then a fresh seq-0 hydrate on
|
|
14
|
+
// recovery. An absent/false `retryable` keeps today's terminal behavior.
|
|
15
|
+
|
|
16
|
+
/** The wire shape of a `queryError` frame (the optional fields ride 101 §4's reason shape). */
|
|
17
|
+
export interface QueryErrorFields {
|
|
18
|
+
message: string;
|
|
19
|
+
/** A machine code (`"shed"`, `"faulted"`, …); absent from older servers. */
|
|
20
|
+
code?: string;
|
|
21
|
+
/** True ⇒ schedule a re-subscribe; absent/false ⇒ terminal (today's behavior). */
|
|
22
|
+
retryable?: boolean;
|
|
23
|
+
/** The server's suggested floor for the first retry delay. */
|
|
24
|
+
retryAfterMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Cap on the exponential backoff between re-subscribe attempts. */
|
|
28
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
/** Default first-attempt delay when the server suggests none. */
|
|
31
|
+
const DEFAULT_RETRY_DELAY_MS = 500;
|
|
32
|
+
|
|
33
|
+
/** The `attempt`-th (0-based) re-subscribe delay: exponential from the server's suggested
|
|
34
|
+
* `retryAfterMs` (or 500 ms when it suggests none), capped at 30 s, with up-to-25 % upward
|
|
35
|
+
* jitter so a shed follower's re-admission gate is not hit by a thundering herd of
|
|
36
|
+
* synchronized retries. Never returns less than the server's suggestion — the floor is
|
|
37
|
+
* honored exactly, and the server picked it knowing its own re-admission rate. */
|
|
38
|
+
export function retryDelayMs(attempt: number, retryAfterMs: number | undefined): number {
|
|
39
|
+
const base = Math.max(retryAfterMs ?? DEFAULT_RETRY_DELAY_MS, 1);
|
|
40
|
+
const backoff = Math.min(base * 2 ** attempt, MAX_RETRY_DELAY_MS);
|
|
41
|
+
return Math.round(backoff * (1 + Math.random() * 0.25));
|
|
42
|
+
}
|
package/src/remote-source.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
} from "@rindle/client";
|
|
19
19
|
|
|
20
20
|
import { NormalizedSubscriber } from "./normalized.ts";
|
|
21
|
+
import { retryDelayMs } from "./query-error.ts";
|
|
21
22
|
import type { NormalizedBatch, NormalizedHello } from "./normalized.ts";
|
|
22
23
|
import { ProtocolError } from "./protocol.ts";
|
|
23
24
|
import type { ServerMsg } from "./protocol.ts";
|
|
@@ -41,6 +42,10 @@ interface QState {
|
|
|
41
42
|
resubscribing: boolean;
|
|
42
43
|
/** Monotonic token that cancels stale async lease resolutions. */
|
|
43
44
|
subscribeTicket: number;
|
|
45
|
+
/** Pending retryable-error re-subscribe timer (FOLLOWER-LAG-SHED §6.3), if any. */
|
|
46
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
/** Consecutive retryable errors without a successful hello — the backoff exponent. */
|
|
48
|
+
retryAttempt: number;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export interface RemoteNormalizedSourceOptions {
|
|
@@ -71,11 +76,13 @@ export class RemoteNormalizedSource implements NormalizedSource {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
registerQuery(qid: QueryId, remote: RemoteQuery): void {
|
|
74
|
-
this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
|
|
79
|
+
this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0, retryTimer: undefined, retryAttempt: 0 });
|
|
75
80
|
this.subscribe(qid, remote);
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
unregisterQuery(qid: QueryId): void {
|
|
84
|
+
const s = this.subs.get(qid);
|
|
85
|
+
if (s?.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
79
86
|
this.subs.delete(qid);
|
|
80
87
|
this.transport.send({ t: "unsubscribe", queryId: qid });
|
|
81
88
|
}
|
|
@@ -95,6 +102,12 @@ export class RemoteNormalizedSource implements NormalizedSource {
|
|
|
95
102
|
private subscribe(qid: QueryId, remote: RemoteQuery): void {
|
|
96
103
|
const s = this.subs.get(qid);
|
|
97
104
|
if (!s) return;
|
|
105
|
+
// A fresh subscribe (gap recovery, the retry timer itself) supersedes any scheduled
|
|
106
|
+
// retryable-error retry — never leave two subscribe paths racing for one query.
|
|
107
|
+
if (s.retryTimer !== undefined) {
|
|
108
|
+
clearTimeout(s.retryTimer);
|
|
109
|
+
s.retryTimer = undefined;
|
|
110
|
+
}
|
|
98
111
|
const request = { queryId: qid, remote, mode: "normalized" as const };
|
|
99
112
|
const ticket = ++s.subscribeTicket;
|
|
100
113
|
const send = (target: SubscribeTarget) => {
|
|
@@ -121,8 +134,7 @@ export class RemoteNormalizedSource implements NormalizedSource {
|
|
|
121
134
|
|
|
122
135
|
private onServerMsg(msg: ServerMsg): void {
|
|
123
136
|
if (msg.t === "queryError") {
|
|
124
|
-
this.
|
|
125
|
-
console.error(`[rindle-remote] normalized query ${msg.queryId} subscription rejected: ${msg.message}`);
|
|
137
|
+
this.onQueryError(msg.queryId, msg);
|
|
126
138
|
return;
|
|
127
139
|
}
|
|
128
140
|
// This source is normalized-only; it sees `nhello`/`nbatch` (flat frames are ignored).
|
|
@@ -133,11 +145,42 @@ export class RemoteNormalizedSource implements NormalizedSource {
|
|
|
133
145
|
else this.applyBatch(msg.queryId, s, msg.batch);
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
/** Route a `queryError` by its 101 §5 classification: retryable ⇒ keep the QState (and the
|
|
149
|
+
* rows already folded downstream — 101 §6) and re-subscribe after a jittered backoff
|
|
150
|
+
* honoring `retryAfterMs`; terminal (or pre-classification servers) ⇒ drop the
|
|
151
|
+
* subscription, exactly as before (FOLLOWER-LAG-SHED §6.3). */
|
|
152
|
+
private onQueryError(qid: QueryId, err: { message: string; code?: string; retryable?: boolean; retryAfterMs?: number }): void {
|
|
153
|
+
const s = this.subs.get(qid);
|
|
154
|
+
if (!s) return;
|
|
155
|
+
if (err.retryable !== true) {
|
|
156
|
+
if (s.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
157
|
+
this.subs.delete(qid);
|
|
158
|
+
console.error(`[rindle-remote] normalized query ${qid} subscription rejected: ${err.message}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (s.retryTimer !== undefined) return; // a retry is already scheduled — don't stack them
|
|
162
|
+
s.subscriber = null; // stop validating the dead epoch; recovery is a fresh seq-0 hydrate
|
|
163
|
+
s.resubscribing = true;
|
|
164
|
+
const delay = retryDelayMs(s.retryAttempt++, err.retryAfterMs);
|
|
165
|
+
console.warn(
|
|
166
|
+
`[rindle-remote] normalized query ${qid} ${err.code ?? "error"} (retryable): re-subscribing in ${delay}ms: ${err.message}`,
|
|
167
|
+
);
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
const cur = this.subs.get(qid);
|
|
170
|
+
if (cur !== s) return;
|
|
171
|
+
s.retryTimer = undefined;
|
|
172
|
+
this.subscribe(qid, s.remote);
|
|
173
|
+
}, delay);
|
|
174
|
+
(timer as { unref?: () => void }).unref?.();
|
|
175
|
+
s.retryTimer = timer;
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
private openSubscriber(qid: QueryId, s: QState, hello: NormalizedHello): void {
|
|
137
179
|
try {
|
|
138
180
|
s.subscriber = new NormalizedSubscriber(hello, (ev) => this.handler(qid, ev), this.clientTables);
|
|
139
181
|
s.epoch = hello.epoch;
|
|
140
182
|
s.resubscribing = false;
|
|
183
|
+
s.retryAttempt = 0; // a successful hello resets the retryable-error backoff
|
|
141
184
|
} catch (e) {
|
|
142
185
|
// A comparator/fp mismatch at hello is unrecoverable (a code-contract divergence).
|
|
143
186
|
s.subscriber = null;
|
package/src/transport.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface Transport {
|
|
|
29
29
|
* it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
|
|
30
30
|
export class WsTransport implements Transport {
|
|
31
31
|
private readonly url: string;
|
|
32
|
+
/** Reads the subprotocols to offer at each (re)connect — in affinity mode, `["rindle.v1", "aff.…"]`
|
|
33
|
+
* with the CURRENT ticket (FOLLOWER-AFFINITY-DESIGN.md §5). Undefined ⇒ offer none (today's
|
|
34
|
+
* single-daemon behavior, byte-identical). Evaluated per connect so a reconnect presents the
|
|
35
|
+
* freshest (or freshly cleared) ticket. */
|
|
36
|
+
private readonly subprotocols?: () => string[];
|
|
32
37
|
private ws: WebSocket;
|
|
33
38
|
private handler: (msg: ServerMsg) => void = () => {};
|
|
34
39
|
private reconnectHandler: () => void = () => {};
|
|
@@ -46,14 +51,18 @@ export class WsTransport implements Transport {
|
|
|
46
51
|
* storm while a follower is gone). */
|
|
47
52
|
private downFired = false;
|
|
48
53
|
|
|
49
|
-
constructor(url: string, opts: { downThreshold?: number } = {}) {
|
|
54
|
+
constructor(url: string, opts: { downThreshold?: number; subprotocols?: () => string[] } = {}) {
|
|
50
55
|
this.url = url;
|
|
51
56
|
this.downThreshold = opts.downThreshold ?? 4;
|
|
57
|
+
this.subprotocols = opts.subprotocols;
|
|
52
58
|
this.ws = this.connect();
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
private connect(): WebSocket {
|
|
56
|
-
|
|
62
|
+
// Offer the affinity subprotocols (base + current ticket) when configured; otherwise open bare,
|
|
63
|
+
// exactly as before. An empty list is treated as bare (never send `Sec-WebSocket-Protocol: `).
|
|
64
|
+
const protocols = this.subprotocols?.();
|
|
65
|
+
const ws = protocols && protocols.length > 0 ? new WebSocket(this.url, protocols) : new WebSocket(this.url);
|
|
57
66
|
ws.addEventListener("open", () => {
|
|
58
67
|
this.open = true;
|
|
59
68
|
this.attempt = 0;
|