@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/dist/transport.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface Transport {
|
|
|
23
23
|
* it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
|
|
24
24
|
export declare class WsTransport implements Transport {
|
|
25
25
|
private readonly url;
|
|
26
|
+
/** Reads the subprotocols to offer at each (re)connect — in affinity mode, `["rindle.v1", "aff.…"]`
|
|
27
|
+
* with the CURRENT ticket (FOLLOWER-AFFINITY-DESIGN.md §5). Undefined ⇒ offer none (today's
|
|
28
|
+
* single-daemon behavior, byte-identical). Evaluated per connect so a reconnect presents the
|
|
29
|
+
* freshest (or freshly cleared) ticket. */
|
|
30
|
+
private readonly subprotocols?;
|
|
26
31
|
private ws;
|
|
27
32
|
private handler;
|
|
28
33
|
private reconnectHandler;
|
|
@@ -41,6 +46,7 @@ export declare class WsTransport implements Transport {
|
|
|
41
46
|
private downFired;
|
|
42
47
|
constructor(url: string, opts?: {
|
|
43
48
|
downThreshold?: number;
|
|
49
|
+
subprotocols?: () => string[];
|
|
44
50
|
});
|
|
45
51
|
private connect;
|
|
46
52
|
private scheduleReconnect;
|
package/dist/transport.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1D,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,gEAAgE;IAChE,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI,CAAC;IACnD;;wEAEoE;IACpE,WAAW,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACxC;;;;wCAIoC;IACpC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACnC,gCAAgC;IAChC,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;mFAGmF;AACnF,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,gBAAgB,CAAwB;IAChD,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,cAAc,CAA4C;IAClE,gGAAgG;IAChG,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC;;2CAEuC;IACvC,OAAO,CAAC,SAAS,CAAS;gBAEd,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAO;
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1D,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,gEAAgE;IAChE,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI,CAAC;IACnD;;wEAEoE;IACpE,WAAW,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACxC;;;;wCAIoC;IACpC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACnC,gCAAgC;IAChC,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;mFAGmF;AACnF,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B;;;gDAG4C;IAC5C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAiB;IAC/C,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,gBAAgB,CAAwB;IAChD,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,cAAc,CAA4C;IAClE,gGAAgG;IAChG,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC;;2CAEuC;IACvC,OAAO,CAAC,SAAS,CAAS;gBAEd,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,MAAM,EAAE,CAAA;KAAO;IAO7F,OAAO,CAAC,OAAO;IAiCf,OAAO,CAAC,iBAAiB;IAiBzB,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI;IAK1B,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAIlD,WAAW,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAItC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAIjC,KAAK,IAAI,IAAI;CAQd"}
|
package/dist/transport.js
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
|
|
8
8
|
export class WsTransport {
|
|
9
9
|
url;
|
|
10
|
+
/** Reads the subprotocols to offer at each (re)connect — in affinity mode, `["rindle.v1", "aff.…"]`
|
|
11
|
+
* with the CURRENT ticket (FOLLOWER-AFFINITY-DESIGN.md §5). Undefined ⇒ offer none (today's
|
|
12
|
+
* single-daemon behavior, byte-identical). Evaluated per connect so a reconnect presents the
|
|
13
|
+
* freshest (or freshly cleared) ticket. */
|
|
14
|
+
subprotocols;
|
|
10
15
|
ws;
|
|
11
16
|
handler = () => { };
|
|
12
17
|
reconnectHandler = () => { };
|
|
@@ -26,10 +31,14 @@ export class WsTransport {
|
|
|
26
31
|
constructor(url, opts = {}) {
|
|
27
32
|
this.url = url;
|
|
28
33
|
this.downThreshold = opts.downThreshold ?? 4;
|
|
34
|
+
this.subprotocols = opts.subprotocols;
|
|
29
35
|
this.ws = this.connect();
|
|
30
36
|
}
|
|
31
37
|
connect() {
|
|
32
|
-
|
|
38
|
+
// Offer the affinity subprotocols (base + current ticket) when configured; otherwise open bare,
|
|
39
|
+
// exactly as before. An empty list is treated as bare (never send `Sec-WebSocket-Protocol: `).
|
|
40
|
+
const protocols = this.subprotocols?.();
|
|
41
|
+
const ws = protocols && protocols.length > 0 ? new WebSocket(this.url, protocols) : new WebSocket(this.url);
|
|
33
42
|
ws.addEventListener("open", () => {
|
|
34
43
|
this.open = true;
|
|
35
44
|
this.attempt = 0;
|
package/dist/transport.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,2FAA2F;AAC3F,gFAAgF;AAuBhF;;;mFAGmF;AACnF,MAAM,OAAO,WAAW;IACL,GAAG,CAAS;
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rindle/remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@rindle/client": "0.
|
|
28
|
+
"@rindle/client": "0.6.3"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^22.10.0",
|
package/src/affinity.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Browser-side follower-affinity ticket transport (FOLLOWER-AFFINITY-DESIGN.md §3, §5). The ticket
|
|
2
|
+
// is OPAQUE here — the browser never mints or verifies it (placement, not authorization; §3, §9),
|
|
3
|
+
// so this module carries no crypto. It is the thin seam three pieces share:
|
|
4
|
+
//
|
|
5
|
+
// - {@link WsTransport} offers the held ticket as a ws subprotocol at each connect (so the Fly
|
|
6
|
+
// edge `fly-replay`s to the pinned follower);
|
|
7
|
+
// - {@link RemoteOptimisticSource} writes the follower's minted ticket from the `{t:"affinity"}`
|
|
8
|
+
// frame, and CLEARS it on a sustained outage (the pinned follower is gone → the next reconnect
|
|
9
|
+
// anycasts to a live one and re-pins, §8);
|
|
10
|
+
// - the lease POST forwards the held ticket as `affinity`, sequenced AFTER a fresh connect's mint
|
|
11
|
+
// frame ({@link AffinityTicketStore.waitForTicket}) so both legs land on the same follower (§4.1).
|
|
12
|
+
|
|
13
|
+
/** The base ws subprotocol the browser offers alongside its `aff.*` ticket; the fleet follower
|
|
14
|
+
* echoes it on the 101 (a strict browser closes a socket whose selected subprotocol it never
|
|
15
|
+
* offered, so the base is always offered in affinity mode). Inlined — NOT imported from
|
|
16
|
+
* `@rindle/affinity`'s `WS_SUBPROTOCOL` — to keep that crate's `node:crypto` out of the browser
|
|
17
|
+
* bundle (the same duplicate-to-stay-bundle-clean discipline the lease wire types use). Keep the
|
|
18
|
+
* two spellings in lock-step. */
|
|
19
|
+
export const WS_SUBPROTOCOL = "rindle.v1";
|
|
20
|
+
|
|
21
|
+
/** A mutable holder for the current opaque affinity ticket, shared by the transport, the source,
|
|
22
|
+
* and the lease POST (see the module header). */
|
|
23
|
+
export interface AffinityTicketStore {
|
|
24
|
+
/** The ticket to offer on the next ws handshake, or undefined — ticketless, so the edge
|
|
25
|
+
* anycasts + mints a fresh one. A persisted/previous-connection ticket may be returned here
|
|
26
|
+
* even while it is NOT yet safe for a lease; see {@link leaseTicket}. */
|
|
27
|
+
get(): string | undefined;
|
|
28
|
+
/** Record a freshly minted/refreshed ticket (from a connection's `{t:"affinity"}` frame). */
|
|
29
|
+
set(ticket: string): void;
|
|
30
|
+
/** Drop the ticket so the next connect goes ticketless (the pinned follower is gone, §8). */
|
|
31
|
+
clear(): void;
|
|
32
|
+
/** Mark the held ticket handshake-only until this connection emits a fresh mint frame. Called
|
|
33
|
+
* before the first connection and every reconnect, after the held ticket was already selected
|
|
34
|
+
* for the ws subprotocol offer. This prevents a restored/rotated/expired persisted ticket from
|
|
35
|
+
* racing an HTTP lease onto an independently-anycast follower. */
|
|
36
|
+
connectionPending(): void;
|
|
37
|
+
/** Resolve with a ticket minted/refreshed on the CURRENT connection, or the next one
|
|
38
|
+
* {@link set}. A persisted ticket deliberately does not resolve this wait. */
|
|
39
|
+
waitForTicket(): Promise<string>;
|
|
40
|
+
/** Obtain the current connection's ticket for an HTTP lease. The first missing-ticket wait is
|
|
41
|
+
* bounded; its timeout removes the waiter and latches ticketless fallback, so later leases
|
|
42
|
+
* return immediately until a mint frame arrives. Concurrent callers share the one timer.
|
|
43
|
+
* `timedOut` is true only for that transition, allowing one warning per fallback episode. */
|
|
44
|
+
leaseTicket(timeoutMs: number): Promise<{ ticket?: string; timedOut: boolean }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where a store persists the ticket across reloads. The browser backs this with sessionStorage
|
|
48
|
+
* (per-TAB, so two tabs can pin two regions — design §13); tests pass none (pure in-memory). */
|
|
49
|
+
export interface TicketPersistence {
|
|
50
|
+
load(): string | undefined;
|
|
51
|
+
save(ticket: string): void;
|
|
52
|
+
clear(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build an {@link AffinityTicketStore}, optionally persisted. Pure/in-memory when `persist` is
|
|
56
|
+
* omitted. */
|
|
57
|
+
export function createAffinityTicketStore(persist?: TicketPersistence): AffinityTicketStore {
|
|
58
|
+
let current = persist?.load();
|
|
59
|
+
// A loaded ticket is useful for the ws handshake, but is not lease-safe until the follower on
|
|
60
|
+
// THIS connection confirms it by minting a fresh frame. The same distinction is re-armed on
|
|
61
|
+
// every reconnect via `connectionPending`.
|
|
62
|
+
let currentConnectionFresh = false;
|
|
63
|
+
let ticketlessFallback = false;
|
|
64
|
+
let waiters: Array<(ticket: string) => void> = [];
|
|
65
|
+
let leaseWait:
|
|
66
|
+
| {
|
|
67
|
+
promise: Promise<{ ticket?: string; timedOut: boolean }>;
|
|
68
|
+
resolve: (result: { ticket?: string; timedOut: boolean }) => void;
|
|
69
|
+
timer: ReturnType<typeof setTimeout>;
|
|
70
|
+
}
|
|
71
|
+
| undefined;
|
|
72
|
+
|
|
73
|
+
const finishLeaseWait = (result: { ticket?: string; timedOut: boolean }): void => {
|
|
74
|
+
const pending = leaseWait;
|
|
75
|
+
if (!pending) return;
|
|
76
|
+
clearTimeout(pending.timer);
|
|
77
|
+
leaseWait = undefined;
|
|
78
|
+
pending.resolve(result);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
get: () => current,
|
|
83
|
+
set: (ticket) => {
|
|
84
|
+
current = ticket;
|
|
85
|
+
currentConnectionFresh = true;
|
|
86
|
+
ticketlessFallback = false;
|
|
87
|
+
persist?.save(ticket);
|
|
88
|
+
// Resolve everyone waiting for the (now-arrived) ticket, in FIFO order.
|
|
89
|
+
const pending = waiters;
|
|
90
|
+
waiters = [];
|
|
91
|
+
for (const resolve of pending) resolve(ticket);
|
|
92
|
+
finishLeaseWait({ ticket, timedOut: false });
|
|
93
|
+
},
|
|
94
|
+
clear: () => {
|
|
95
|
+
// Drop the ticket but leave any waiters pending — the next connect's mint frame resolves them
|
|
96
|
+
// (a dead-follower reassignment must still be able to lease once the new ticket arrives).
|
|
97
|
+
current = undefined;
|
|
98
|
+
currentConnectionFresh = false;
|
|
99
|
+
persist?.clear();
|
|
100
|
+
},
|
|
101
|
+
connectionPending: () => {
|
|
102
|
+
currentConnectionFresh = false;
|
|
103
|
+
},
|
|
104
|
+
waitForTicket: () =>
|
|
105
|
+
currentConnectionFresh && current !== undefined
|
|
106
|
+
? Promise.resolve(current)
|
|
107
|
+
: new Promise<string>((resolve) => waiters.push(resolve)),
|
|
108
|
+
leaseTicket: (timeoutMs) => {
|
|
109
|
+
if (currentConnectionFresh && current !== undefined) {
|
|
110
|
+
return Promise.resolve({ ticket: current, timedOut: false });
|
|
111
|
+
}
|
|
112
|
+
if (ticketlessFallback) return Promise.resolve({ timedOut: false });
|
|
113
|
+
if (leaseWait) return leaseWait.promise;
|
|
114
|
+
|
|
115
|
+
let resolve!: (result: { ticket?: string; timedOut: boolean }) => void;
|
|
116
|
+
const promise = new Promise<{ ticket?: string; timedOut: boolean }>((r) => {
|
|
117
|
+
resolve = r;
|
|
118
|
+
});
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
// Clear the shared waiter before resolving callers. A later mint starts a new, fresh
|
|
121
|
+
// episode; no abandoned resolver accumulates per lease while affinity is off.
|
|
122
|
+
leaseWait = undefined;
|
|
123
|
+
ticketlessFallback = true;
|
|
124
|
+
resolve({ timedOut: true });
|
|
125
|
+
}, Math.max(0, timeoutMs));
|
|
126
|
+
leaseWait = { promise, resolve, timer };
|
|
127
|
+
return promise;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The subprotocol list to offer for one connection: the base protocol always, plus the held ticket
|
|
133
|
+
* (already an `aff.<…>` token) when one exists. */
|
|
134
|
+
export function offerSubprotocols(store: AffinityTicketStore): string[] {
|
|
135
|
+
const ticket = store.get();
|
|
136
|
+
return ticket ? [WS_SUBPROTOCOL, ticket] : [WS_SUBPROTOCOL];
|
|
137
|
+
}
|
package/src/backend.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type SubscribeResolver,
|
|
21
21
|
} from "./subscribe.ts";
|
|
22
22
|
import { WsTransport } from "./transport.ts";
|
|
23
|
+
import { retryDelayMs } from "./query-error.ts";
|
|
23
24
|
import type { Transport } from "./transport.ts";
|
|
24
25
|
|
|
25
26
|
interface QState {
|
|
@@ -31,6 +32,10 @@ interface QState {
|
|
|
31
32
|
resubscribing: boolean;
|
|
32
33
|
/** Monotonic token that cancels stale async lease resolutions. */
|
|
33
34
|
subscribeTicket: number;
|
|
35
|
+
/** Pending retryable-error re-subscribe timer (FOLLOWER-LAG-SHED §6.3), if any. */
|
|
36
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
/** Consecutive retryable errors without a successful hello — the backoff exponent. */
|
|
38
|
+
retryAttempt: number;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface RemoteBackendOptions {
|
|
@@ -60,11 +65,13 @@ export class RemoteBackend implements Backend {
|
|
|
60
65
|
console.error(`[rindle-remote] flat query ${qid} has no named remote identity`);
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
|
-
this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
|
|
68
|
+
this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0, retryTimer: undefined, retryAttempt: 0 });
|
|
64
69
|
this.subscribe(qid, remote);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
unregisterQuery(qid: QueryId): void {
|
|
73
|
+
const s = this.subs.get(qid);
|
|
74
|
+
if (s?.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
68
75
|
this.subs.delete(qid);
|
|
69
76
|
this.transport.send({ t: "unsubscribe", queryId: qid });
|
|
70
77
|
}
|
|
@@ -93,6 +100,12 @@ export class RemoteBackend implements Backend {
|
|
|
93
100
|
private subscribe(qid: QueryId, remote: RemoteQuery): void {
|
|
94
101
|
const s = this.subs.get(qid);
|
|
95
102
|
if (!s) return;
|
|
103
|
+
// A fresh subscribe (gap recovery, the retry timer itself) supersedes any scheduled
|
|
104
|
+
// retryable-error retry — never leave two subscribe paths racing for one query.
|
|
105
|
+
if (s.retryTimer !== undefined) {
|
|
106
|
+
clearTimeout(s.retryTimer);
|
|
107
|
+
s.retryTimer = undefined;
|
|
108
|
+
}
|
|
96
109
|
const request = { queryId: qid, remote, mode: "flat" as const };
|
|
97
110
|
const ticket = ++s.subscribeTicket;
|
|
98
111
|
const send = (target: ReturnType<typeof defaultSubscribeTarget>) => {
|
|
@@ -117,8 +130,7 @@ export class RemoteBackend implements Backend {
|
|
|
117
130
|
|
|
118
131
|
private onServerMsg(msg: ServerMsg): void {
|
|
119
132
|
if (msg.t === "queryError") {
|
|
120
|
-
this.
|
|
121
|
-
console.error(`[rindle-remote] query ${msg.queryId} subscription rejected: ${msg.message}`);
|
|
133
|
+
this.onQueryError(msg.queryId, msg);
|
|
122
134
|
return;
|
|
123
135
|
}
|
|
124
136
|
// This backend is flat-only; it ignores normalized frames (`nhello`/`nbatch`).
|
|
@@ -129,6 +141,36 @@ export class RemoteBackend implements Backend {
|
|
|
129
141
|
else this.applyBatch(msg.queryId, s, msg.batch);
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
/** Route a `queryError` by its 101 §5 classification: retryable ⇒ keep the QState (and the
|
|
145
|
+
* rows already folded downstream — 101 §6) and re-subscribe after a jittered backoff
|
|
146
|
+
* honoring `retryAfterMs`; terminal (or pre-classification servers) ⇒ drop the
|
|
147
|
+
* subscription, exactly as before (FOLLOWER-LAG-SHED §6.3). */
|
|
148
|
+
private onQueryError(qid: QueryId, err: { message: string; code?: string; retryable?: boolean; retryAfterMs?: number }): void {
|
|
149
|
+
const s = this.subs.get(qid);
|
|
150
|
+
if (!s) return;
|
|
151
|
+
if (err.retryable !== true) {
|
|
152
|
+
if (s.retryTimer !== undefined) clearTimeout(s.retryTimer);
|
|
153
|
+
this.subs.delete(qid);
|
|
154
|
+
console.error(`[rindle-remote] query ${qid} subscription rejected: ${err.message}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (s.retryTimer !== undefined) return; // a retry is already scheduled — don't stack them
|
|
158
|
+
s.subscriber = null; // stop validating the dead epoch; recovery is a fresh seq-0 hydrate
|
|
159
|
+
s.resubscribing = true;
|
|
160
|
+
const delay = retryDelayMs(s.retryAttempt++, err.retryAfterMs);
|
|
161
|
+
console.warn(
|
|
162
|
+
`[rindle-remote] query ${qid} ${err.code ?? "error"} (retryable): re-subscribing in ${delay}ms: ${err.message}`,
|
|
163
|
+
);
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
const cur = this.subs.get(qid);
|
|
166
|
+
if (cur !== s) return;
|
|
167
|
+
s.retryTimer = undefined;
|
|
168
|
+
this.subscribe(qid, s.remote);
|
|
169
|
+
}, delay);
|
|
170
|
+
(timer as { unref?: () => void }).unref?.();
|
|
171
|
+
s.retryTimer = timer;
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
private openSubscriber(qid: QueryId, s: QState, hello: Hello): void {
|
|
133
175
|
try {
|
|
134
176
|
// The Subscriber ctor validates the comparator + fingerprint and emits the `hello`
|
|
@@ -136,6 +178,7 @@ export class RemoteBackend implements Backend {
|
|
|
136
178
|
s.subscriber = new Subscriber(hello, (ev) => this.emitServerEvent(qid, ev));
|
|
137
179
|
s.epoch = hello.epoch;
|
|
138
180
|
s.resubscribing = false;
|
|
181
|
+
s.retryAttempt = 0; // a successful hello resets the retryable-error backoff
|
|
139
182
|
} catch (e) {
|
|
140
183
|
// A comparator/schema mismatch at hello is unrecoverable (a code-contract divergence) —
|
|
141
184
|
// leave the view pending and report it; do not loop.
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,10 @@ export { RemoteBackend, createRemoteStore } from "./backend.ts";
|
|
|
9
9
|
export type { RemoteBackendOptions } from "./backend.ts";
|
|
10
10
|
export { WsTransport } from "./transport.ts";
|
|
11
11
|
export type { Transport } from "./transport.ts";
|
|
12
|
+
|
|
13
|
+
// Browser-side follower-affinity ticket transport (FOLLOWER-AFFINITY-DESIGN.md §3, §5).
|
|
14
|
+
export { WS_SUBPROTOCOL, createAffinityTicketStore, offerSubprotocols } from "./affinity.ts";
|
|
15
|
+
export type { AffinityTicketStore, TicketPersistence } from "./affinity.ts";
|
|
12
16
|
export type {
|
|
13
17
|
MutationEnvelopeSender,
|
|
14
18
|
RawMutationSender,
|