@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.
@@ -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?.(() => this.resync());
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 || !this.sawRoutedEndpoint) return;
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, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
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.subs.delete(msg.queryId);
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 query (each re-resolves its
314
- * lease, so a restarted daemon re-materializes + re-leases on the fly). */
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
- | { t: "queryError"; queryId: number; message: string }
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
+ }
@@ -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.subs.delete(msg.queryId);
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
- const ws = new WebSocket(this.url);
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;