@rindle/remote 0.4.4 → 0.5.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/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/optimistic-source.d.ts +49 -4
- package/dist/optimistic-source.d.ts.map +1 -1
- package/dist/optimistic-source.js +154 -7
- package/dist/optimistic-source.js.map +1 -1
- package/dist/protocol.d.ts +10 -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/package.json +2 -2
- package/src/backend.ts +46 -3
- package/src/optimistic-source.ts +164 -7
- package/src/protocol.ts +11 -1
- package/src/query-error.ts +42 -0
- package/src/remote-source.ts +46 -3
|
@@ -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;
|