@syncular/client 0.29.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -16
- package/dist/availability.d.ts +8 -1
- package/dist/availability.js +17 -5
- package/dist/multi-tab.d.ts +54 -8
- package/dist/multi-tab.js +150 -24
- package/dist/reactive-store.js +69 -48
- package/dist/worker-host.d.ts +6 -0
- package/dist/worker-host.js +69 -3
- package/package.json +3 -3
- package/src/availability.ts +27 -7
- package/src/multi-tab.ts +208 -40
- package/src/reactive-store.ts +76 -51
- package/src/worker-host.ts +76 -2
package/README.md
CHANGED
|
@@ -209,20 +209,35 @@ A leader tab that processes no messages (hung or frozen) therefore blocks its
|
|
|
209
209
|
followers within `followerCallTimeoutMs`; a closed leader releases its Web
|
|
210
210
|
Lock and hands over without waiting for that deadline. A blocked follower keeps
|
|
211
211
|
probing at the same interval: an answer rebinds the same handle, and a granted
|
|
212
|
-
Web Lock promotes it.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`client.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
212
|
+
Web Lock promotes it. An unreachable `BroadcastChannel` is never treated as
|
|
213
|
+
evidence that the lock owner is stale, so it never authorizes a second worker
|
|
214
|
+
or database owner.
|
|
215
|
+
|
|
216
|
+
**Forwarded calls have no deadline.** The leader's core serializes window
|
|
217
|
+
edits and sync rounds, so a follower's `setWindow` can wait behind a long
|
|
218
|
+
bootstrap download, exactly as the same call does in the leader tab. While
|
|
219
|
+
the leader answers probes, a forwarded call waits for its result. It rejects
|
|
220
|
+
with `client.follower_timeout` when the link goes `blocked`, with
|
|
221
|
+
`client.leader_handover` when another leader announces or this tab promotes
|
|
222
|
+
before the answer arrives, and with `client.worker_failed` when the handle
|
|
223
|
+
closes. Only calls queued while no leader is bound keep the
|
|
224
|
+
`followerCallTimeoutMs` deadline; when this tab wins the lock itself, its own
|
|
225
|
+
core runs them.
|
|
226
|
+
|
|
227
|
+
**Tabs of different builds never serve each other.** Every `hello`, `req`,
|
|
228
|
+
and `announce` carries `MULTI_TAB_PROTOCOL_VERSION` and the schema version.
|
|
229
|
+
A follower whose leader differs sends it nothing: `handle.leadership` becomes
|
|
230
|
+
`{ state: 'blocked', reason: 'leader-incompatible', code:
|
|
231
|
+
'client.leader_incompatible', leader: 'older' | 'newer', retryable: true }`
|
|
232
|
+
and calls reject with `client.leader_incompatible`. A leader that hears a
|
|
233
|
+
`hello` from a newer tab closes its core and releases the Web Lock; the newer
|
|
234
|
+
tab promotes, and the former leader stays blocked with `leader: 'newer'`
|
|
235
|
+
until it reloads. A leader answers a request with another identity with a
|
|
236
|
+
`client.leader_incompatible` error. Tabs running 0.29.1 or older send no
|
|
237
|
+
identity and count as older: they never step down, so a newer tab next to
|
|
238
|
+
one shows `leader: 'older'` until that tab reloads or closes. Bump
|
|
239
|
+
`MULTI_TAB_PROTOCOL_VERSION` with every change to a message shape or to a
|
|
240
|
+
forwarded method's arguments or result.
|
|
226
241
|
|
|
227
242
|
**A hidden leader keeps leadership.** A visible tab does not take leadership
|
|
228
243
|
from a hidden leader. Web Locks `steal` revokes the lease while the leader's
|
|
@@ -291,8 +306,9 @@ errors or inspecting generated schema modules:
|
|
|
291
306
|
```
|
|
292
307
|
|
|
293
308
|
The state is a discriminated union covering startup, migration,
|
|
294
|
-
`client-upgrade-required`, `server-behind`, `incompatible-schema`,
|
|
295
|
-
`leader-unreachable
|
|
309
|
+
`client-upgrade-required`, `server-behind`, `incompatible-schema`,
|
|
310
|
+
`leader-unreachable`, and `leader-incompatible` (with `leader: 'older' |
|
|
311
|
+
'newer'`). Recovery changes the same handle/provider back to its
|
|
296
312
|
children; a blocked live query has `phase === 'blocked'`, never an indefinite
|
|
297
313
|
loading state.
|
|
298
314
|
|
package/dist/availability.d.ts
CHANGED
|
@@ -7,11 +7,18 @@ export type SyncAvailability = {
|
|
|
7
7
|
readonly currentSchemaVersion: number;
|
|
8
8
|
} | {
|
|
9
9
|
readonly state: 'blocked';
|
|
10
|
-
readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable';
|
|
10
|
+
readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable' | 'leader-incompatible';
|
|
11
11
|
readonly currentSchemaVersion: number;
|
|
12
|
+
/** `leader-incompatible`: whether the tab holding the database runs an
|
|
13
|
+
* older or a newer build than this one. */
|
|
14
|
+
readonly leader?: 'older' | 'newer';
|
|
12
15
|
readonly requiredSchemaVersion?: number;
|
|
13
16
|
readonly latestServerSchemaVersion?: number;
|
|
14
17
|
readonly retryable: boolean;
|
|
15
18
|
};
|
|
19
|
+
/** The availability of a tab whose leader link is blocked. */
|
|
20
|
+
export declare function blockedLeadershipAvailability(leadership: Extract<LeadershipState, {
|
|
21
|
+
state: 'blocked';
|
|
22
|
+
}>, currentSchemaVersion: number): SyncAvailability;
|
|
16
23
|
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
17
24
|
export declare function classifySyncAvailability(status: SyncStatusSnapshot, leadership?: LeadershipState): SyncAvailability;
|
package/dist/availability.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export function
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/** The availability of a tab whose leader link is blocked. */
|
|
2
|
+
export function blockedLeadershipAvailability(leadership, currentSchemaVersion) {
|
|
3
|
+
return leadership.reason === 'leader-incompatible'
|
|
4
|
+
? {
|
|
5
|
+
state: 'blocked',
|
|
6
|
+
reason: 'leader-incompatible',
|
|
7
|
+
currentSchemaVersion,
|
|
8
|
+
leader: leadership.leader,
|
|
9
|
+
retryable: true,
|
|
10
|
+
}
|
|
11
|
+
: {
|
|
6
12
|
state: 'blocked',
|
|
7
13
|
reason: 'leader-unreachable',
|
|
8
14
|
currentSchemaVersion,
|
|
9
15
|
retryable: true,
|
|
10
16
|
};
|
|
17
|
+
}
|
|
18
|
+
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
19
|
+
export function classifySyncAvailability(status, leadership) {
|
|
20
|
+
const currentSchemaVersion = status.currentSchemaVersion;
|
|
21
|
+
if (leadership?.state === 'blocked') {
|
|
22
|
+
return blockedLeadershipAvailability(leadership, currentSchemaVersion);
|
|
11
23
|
}
|
|
12
24
|
const required = status.schemaFloor?.requiredSchemaVersion;
|
|
13
25
|
const latest = status.schemaFloor?.latestSchemaVersion;
|
package/dist/multi-tab.d.ts
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
import type { SyncWorkerEvent } from './worker-protocol.js';
|
|
2
2
|
/** Client-local: a follower call could not reach a leader before its deadline. */
|
|
3
3
|
export declare const FOLLOWER_TIMEOUT_CODE = "client.follower_timeout";
|
|
4
|
-
/**
|
|
4
|
+
/** Client-local: the leader runs a different protocol or schema version. */
|
|
5
|
+
export declare const LEADER_INCOMPATIBLE_CODE = "client.leader_incompatible";
|
|
6
|
+
/** Client-local: the leader that received a call stopped leading before it answered. */
|
|
7
|
+
export declare const LEADER_HANDOVER_CODE = "client.leader_handover";
|
|
8
|
+
/**
|
|
9
|
+
* Version of the leader/follower wire and the forwarded handle API. Bump it
|
|
10
|
+
* with every change to a message shape or to a forwarded method's arguments
|
|
11
|
+
* or result, so tabs of different builds never serve each other.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MULTI_TAB_PROTOCOL_VERSION = 1;
|
|
14
|
+
/** What a tab must share with its leader to be served by it. */
|
|
15
|
+
export interface TabIdentity {
|
|
16
|
+
readonly protocol: number;
|
|
17
|
+
readonly schemaVersion: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Order a peer's identity against our own: -1 older, 0 same, 1 newer. A peer
|
|
21
|
+
* from before protocol 1 sends no identity and is older.
|
|
22
|
+
*/
|
|
23
|
+
export declare function compareTabIdentity(peer: {
|
|
24
|
+
readonly protocol?: number;
|
|
25
|
+
readonly schemaVersion?: number;
|
|
26
|
+
}, own: TabIdentity): -1 | 0 | 1;
|
|
27
|
+
/** Default deadline for a call queued while no leader is bound (the handover gap). */
|
|
5
28
|
export declare const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10000;
|
|
6
29
|
/** Max follower calls queued across a handover before we fail loudly. */
|
|
7
30
|
export declare const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
@@ -20,15 +43,27 @@ export type LeadershipState = {
|
|
|
20
43
|
readonly reason: 'leader-unreachable';
|
|
21
44
|
readonly code: typeof FOLLOWER_TIMEOUT_CODE;
|
|
22
45
|
readonly retryable: true;
|
|
46
|
+
} | {
|
|
47
|
+
readonly state: 'blocked';
|
|
48
|
+
readonly reason: 'leader-incompatible';
|
|
49
|
+
readonly code: typeof LEADER_INCOMPATIBLE_CODE;
|
|
50
|
+
/** Whether the tab holding the database runs an older or a newer build. */
|
|
51
|
+
readonly leader: 'older' | 'newer';
|
|
52
|
+
readonly retryable: true;
|
|
23
53
|
};
|
|
24
|
-
|
|
54
|
+
/** Identity fields; absent in messages from tabs before protocol 1. */
|
|
55
|
+
interface IdentityFields {
|
|
56
|
+
readonly protocol?: number;
|
|
57
|
+
readonly schemaVersion?: number;
|
|
58
|
+
}
|
|
59
|
+
interface HelloMessage extends IdentityFields {
|
|
25
60
|
readonly t: 'hello';
|
|
26
61
|
readonly fromId: string;
|
|
27
62
|
/** Highest epoch the sender has observed (helps a promoter monotonically
|
|
28
63
|
* advance even if it never saw the previous leader's announce). */
|
|
29
64
|
readonly epoch?: number;
|
|
30
65
|
}
|
|
31
|
-
interface ReqMessage {
|
|
66
|
+
interface ReqMessage extends IdentityFields {
|
|
32
67
|
readonly t: 'req';
|
|
33
68
|
readonly epoch: number;
|
|
34
69
|
readonly fromId: string;
|
|
@@ -36,7 +71,7 @@ interface ReqMessage {
|
|
|
36
71
|
readonly method: string;
|
|
37
72
|
readonly args: readonly unknown[];
|
|
38
73
|
}
|
|
39
|
-
interface AnnounceMessage {
|
|
74
|
+
interface AnnounceMessage extends IdentityFields {
|
|
40
75
|
readonly t: 'announce';
|
|
41
76
|
readonly epoch: number;
|
|
42
77
|
readonly clientId: string;
|
|
@@ -83,7 +118,9 @@ export declare function newTabId(): string;
|
|
|
83
118
|
* Runs on the leader tab. Announces leadership, answers follower `req`s by
|
|
84
119
|
* invoking the (already-running) worker via `invoke`, and rebroadcasts every
|
|
85
120
|
* worker event to followers. Owns nothing about the worker lifecycle — the
|
|
86
|
-
* worker-host stays the single core owner; this is a relay.
|
|
121
|
+
* worker-host stays the single core owner; this is a relay. A hello from a
|
|
122
|
+
* newer tab calls `onNewerTab`, whose owner closes the core and releases the
|
|
123
|
+
* lock.
|
|
87
124
|
*/
|
|
88
125
|
export declare class LeaderBridge {
|
|
89
126
|
#private;
|
|
@@ -91,7 +128,9 @@ export declare class LeaderBridge {
|
|
|
91
128
|
channel: CrossTabChannel;
|
|
92
129
|
epoch: number;
|
|
93
130
|
clientId: string;
|
|
131
|
+
identity: TabIdentity;
|
|
94
132
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
133
|
+
onNewerTab: () => void;
|
|
95
134
|
});
|
|
96
135
|
/** Announce leadership (on promotion and in answer to every `hello`). */
|
|
97
136
|
announce(): void;
|
|
@@ -111,13 +150,15 @@ type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
|
111
150
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
112
151
|
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
113
152
|
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
114
|
-
* when a probe goes unanswered
|
|
153
|
+
* when a probe goes unanswered, or when the leader's identity differs from
|
|
154
|
+
* this tab's (see the module comment).
|
|
115
155
|
*/
|
|
116
156
|
export declare class FollowerLink {
|
|
117
157
|
#private;
|
|
118
158
|
constructor(options: {
|
|
119
159
|
channel: CrossTabChannel;
|
|
120
160
|
fromId: string;
|
|
161
|
+
identity: TabIdentity;
|
|
121
162
|
onEvent: (event: SyncWorkerEvent) => void;
|
|
122
163
|
onLeaderChange: (clientId: string) => void;
|
|
123
164
|
onStateChange?: (state: LeadershipState) => void;
|
|
@@ -147,10 +188,15 @@ export declare class FollowerLink {
|
|
|
147
188
|
/**
|
|
148
189
|
* The leader we were bound to went away (its lock released). Un-bind so new
|
|
149
190
|
* calls queue again; re-request an announce so the next leader finds us.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
191
|
+
* Calls in flight to the departed leader reject; the winner's announce
|
|
192
|
+
* flushes the queued ones.
|
|
152
193
|
*/
|
|
153
194
|
unbind(): void;
|
|
195
|
+
/**
|
|
196
|
+
* This tab won the lock and runs its own core: calls queued for the next
|
|
197
|
+
* leader run on `invoke` instead of failing, then the link closes.
|
|
198
|
+
*/
|
|
199
|
+
handOver(invoke: (method: string, args: readonly unknown[]) => Promise<unknown>): void;
|
|
154
200
|
close(): void;
|
|
155
201
|
}
|
|
156
202
|
export {};
|
package/dist/multi-tab.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* survive `postMessage` on a BroadcastChannel):
|
|
12
12
|
*
|
|
13
13
|
* follower → leader
|
|
14
|
-
* hello {t,epoch?,fromId}
|
|
14
|
+
* hello {t,epoch?,fromId,protocol,schemaVersion}
|
|
15
|
+
* — "who's the leader?" on join,
|
|
15
16
|
* and the follower's liveness probe
|
|
16
|
-
* req {t,epoch,fromId,reqId,method,args}
|
|
17
|
+
* req {t,epoch,fromId,reqId,method,args,protocol,schemaVersion}
|
|
17
18
|
* leader → all
|
|
18
|
-
* announce {t,epoch,clientId}
|
|
19
|
+
* announce {t,epoch,clientId,protocol,schemaVersion}
|
|
20
|
+
* — "I am the leader, this epoch";
|
|
19
21
|
* the answer to every hello
|
|
20
22
|
* res {t,epoch,reqId,ok,value|error} — reply to one req
|
|
21
23
|
* event {t,epoch,event} — fan-out (invalidate/presence/…)
|
|
@@ -30,6 +32,14 @@
|
|
|
30
32
|
* promoter reads the highest epoch it has seen and adds one, so successive
|
|
31
33
|
* leaders always strictly increase it even across the lock-handover gap.
|
|
32
34
|
*
|
|
35
|
+
* Identity: every hello, req, and announce carries the sender's
|
|
36
|
+
* {@link MULTI_TAB_PROTOCOL_VERSION} and application schema version. A leader
|
|
37
|
+
* serves only requests with its own identity. A follower whose leader has a
|
|
38
|
+
* different identity goes `blocked` with `leader-incompatible` and sends it
|
|
39
|
+
* nothing. A leader that hears a hello from a newer tab closes its core and
|
|
40
|
+
* releases the lock, so the newer tab takes over. Tabs from before protocol 1
|
|
41
|
+
* send no identity and count as older.
|
|
42
|
+
*
|
|
33
43
|
* Liveness is follower-driven: the leader runs no timer. Browsers throttle
|
|
34
44
|
* the timers of a hidden tab (Chrome's intensive throttling wakes them about
|
|
35
45
|
* once a minute) but still dispatch its BroadcastChannel messages, so a leader
|
|
@@ -39,6 +49,9 @@
|
|
|
39
49
|
* stays unanswered for the rest of the timeout. A leader tab whose main thread
|
|
40
50
|
* processes no messages (hung or frozen) therefore still blocks its followers
|
|
41
51
|
* within `callTimeoutMs`, and a closed leader hands over through the lock.
|
|
52
|
+
* A forwarded call has no deadline of its own: a live leader may run it behind
|
|
53
|
+
* a long sync round, exactly as it runs its own tab's calls. It rejects when
|
|
54
|
+
* the link blocks, when another leader announces, or when the link closes.
|
|
42
55
|
*
|
|
43
56
|
* Presence identity: all tabs share the leader's one connection, so a device
|
|
44
57
|
* is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
|
|
@@ -49,7 +62,30 @@ import { ClientSyncError } from './errors.js';
|
|
|
49
62
|
import { WORKER_FAILED_CODE } from './worker-protocol.js';
|
|
50
63
|
/** Client-local: a follower call could not reach a leader before its deadline. */
|
|
51
64
|
export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
|
|
52
|
-
/**
|
|
65
|
+
/** Client-local: the leader runs a different protocol or schema version. */
|
|
66
|
+
export const LEADER_INCOMPATIBLE_CODE = 'client.leader_incompatible';
|
|
67
|
+
/** Client-local: the leader that received a call stopped leading before it answered. */
|
|
68
|
+
export const LEADER_HANDOVER_CODE = 'client.leader_handover';
|
|
69
|
+
/**
|
|
70
|
+
* Version of the leader/follower wire and the forwarded handle API. Bump it
|
|
71
|
+
* with every change to a message shape or to a forwarded method's arguments
|
|
72
|
+
* or result, so tabs of different builds never serve each other.
|
|
73
|
+
*/
|
|
74
|
+
export const MULTI_TAB_PROTOCOL_VERSION = 1;
|
|
75
|
+
/**
|
|
76
|
+
* Order a peer's identity against our own: -1 older, 0 same, 1 newer. A peer
|
|
77
|
+
* from before protocol 1 sends no identity and is older.
|
|
78
|
+
*/
|
|
79
|
+
export function compareTabIdentity(peer, own) {
|
|
80
|
+
if (peer.protocol === undefined || peer.schemaVersion === undefined)
|
|
81
|
+
return -1;
|
|
82
|
+
if (peer.protocol !== own.protocol)
|
|
83
|
+
return peer.protocol < own.protocol ? -1 : 1;
|
|
84
|
+
if (peer.schemaVersion !== own.schemaVersion)
|
|
85
|
+
return peer.schemaVersion < own.schemaVersion ? -1 : 1;
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
/** Default deadline for a call queued while no leader is bound (the handover gap). */
|
|
53
89
|
export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
|
|
54
90
|
/** Max follower calls queued across a handover before we fail loudly. */
|
|
55
91
|
export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
@@ -76,20 +112,26 @@ export function newTabId() {
|
|
|
76
112
|
* Runs on the leader tab. Announces leadership, answers follower `req`s by
|
|
77
113
|
* invoking the (already-running) worker via `invoke`, and rebroadcasts every
|
|
78
114
|
* worker event to followers. Owns nothing about the worker lifecycle — the
|
|
79
|
-
* worker-host stays the single core owner; this is a relay.
|
|
115
|
+
* worker-host stays the single core owner; this is a relay. A hello from a
|
|
116
|
+
* newer tab calls `onNewerTab`, whose owner closes the core and releases the
|
|
117
|
+
* lock.
|
|
80
118
|
*/
|
|
81
119
|
export class LeaderBridge {
|
|
82
120
|
#channel;
|
|
83
121
|
#epoch;
|
|
84
122
|
#clientId;
|
|
123
|
+
#identity;
|
|
85
124
|
#invoke;
|
|
125
|
+
#onNewerTab;
|
|
86
126
|
#onMessage;
|
|
87
127
|
#closed = false;
|
|
88
128
|
constructor(options) {
|
|
89
129
|
this.#channel = options.channel;
|
|
90
130
|
this.#epoch = options.epoch;
|
|
91
131
|
this.#clientId = options.clientId;
|
|
132
|
+
this.#identity = options.identity;
|
|
92
133
|
this.#invoke = options.invoke;
|
|
134
|
+
this.#onNewerTab = options.onNewerTab;
|
|
93
135
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
94
136
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
95
137
|
this.announce();
|
|
@@ -102,6 +144,7 @@ export class LeaderBridge {
|
|
|
102
144
|
t: 'announce',
|
|
103
145
|
epoch: this.#epoch,
|
|
104
146
|
clientId: this.#clientId,
|
|
147
|
+
...this.#identity,
|
|
105
148
|
});
|
|
106
149
|
}
|
|
107
150
|
/** Fan a worker event out to all followers on the current epoch. */
|
|
@@ -114,7 +157,12 @@ export class LeaderBridge {
|
|
|
114
157
|
if (this.#closed)
|
|
115
158
|
return;
|
|
116
159
|
if (message.t === 'hello') {
|
|
117
|
-
// A
|
|
160
|
+
// A newer tab takes over; any other follower learns who leads.
|
|
161
|
+
if (compareTabIdentity(message, this.#identity) > 0) {
|
|
162
|
+
this.close();
|
|
163
|
+
this.#onNewerTab();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
118
166
|
this.announce();
|
|
119
167
|
return;
|
|
120
168
|
}
|
|
@@ -125,6 +173,20 @@ export class LeaderBridge {
|
|
|
125
173
|
if (message.epoch !== this.#epoch)
|
|
126
174
|
return;
|
|
127
175
|
const { reqId, method, args } = message;
|
|
176
|
+
if (compareTabIdentity(message, this.#identity) !== 0) {
|
|
177
|
+
this.#channel.postMessage({
|
|
178
|
+
t: 'res',
|
|
179
|
+
epoch: this.#epoch,
|
|
180
|
+
reqId,
|
|
181
|
+
ok: false,
|
|
182
|
+
error: {
|
|
183
|
+
code: LEADER_INCOMPATIBLE_CODE,
|
|
184
|
+
message: 'the leader runs a different protocol or schema version',
|
|
185
|
+
retryable: true,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
128
190
|
this.#invoke(method, args).then((value) => {
|
|
129
191
|
this.#channel.postMessage({
|
|
130
192
|
t: 'res',
|
|
@@ -169,11 +231,13 @@ export class LeaderBridge {
|
|
|
169
231
|
* Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
|
|
170
232
|
* the resolved leader `clientId` back through `onLeaderChange`. Probes the
|
|
171
233
|
* bound leader with `hello` after a quiet period and reports `blocked` only
|
|
172
|
-
* when a probe goes unanswered
|
|
234
|
+
* when a probe goes unanswered, or when the leader's identity differs from
|
|
235
|
+
* this tab's (see the module comment).
|
|
173
236
|
*/
|
|
174
237
|
export class FollowerLink {
|
|
175
238
|
#channel;
|
|
176
239
|
#fromId;
|
|
240
|
+
#identity;
|
|
177
241
|
#onEvent;
|
|
178
242
|
#onLeaderChange;
|
|
179
243
|
#onStateChange;
|
|
@@ -203,6 +267,7 @@ export class FollowerLink {
|
|
|
203
267
|
constructor(options) {
|
|
204
268
|
this.#channel = options.channel;
|
|
205
269
|
this.#fromId = options.fromId;
|
|
270
|
+
this.#identity = options.identity;
|
|
206
271
|
this.#onEvent = options.onEvent;
|
|
207
272
|
this.#onLeaderChange = options.onLeaderChange;
|
|
208
273
|
this.#onStateChange = options.onStateChange ?? (() => { });
|
|
@@ -286,7 +351,7 @@ export class FollowerLink {
|
|
|
286
351
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
287
352
|
}
|
|
288
353
|
if (this.#state.state === 'blocked') {
|
|
289
|
-
return Promise.reject(
|
|
354
|
+
return Promise.reject(this.#blockedError());
|
|
290
355
|
}
|
|
291
356
|
return new Promise((resolve, reject) => {
|
|
292
357
|
const queued = {
|
|
@@ -316,16 +381,11 @@ export class FollowerLink {
|
|
|
316
381
|
}
|
|
317
382
|
#send(queued) {
|
|
318
383
|
const reqId = this.#nextReqId++;
|
|
319
|
-
//
|
|
320
|
-
// liveness probe
|
|
321
|
-
// link bound.
|
|
384
|
+
// No deadline: a live leader may queue the call behind a long sync round.
|
|
385
|
+
// The liveness probe, a leader change, or close settles it otherwise.
|
|
322
386
|
this.#inFlight.set(reqId, {
|
|
323
387
|
resolve: queued.resolve,
|
|
324
388
|
reject: queued.reject,
|
|
325
|
-
cancelTimer: this.#schedule(() => {
|
|
326
|
-
this.#inFlight.delete(reqId);
|
|
327
|
-
queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
|
|
328
|
-
}, this.#callTimeoutMs),
|
|
329
389
|
});
|
|
330
390
|
this.#channel.postMessage({
|
|
331
391
|
t: 'req',
|
|
@@ -334,8 +394,23 @@ export class FollowerLink {
|
|
|
334
394
|
reqId,
|
|
335
395
|
method: queued.method,
|
|
336
396
|
args: queued.args,
|
|
397
|
+
...this.#identity,
|
|
337
398
|
});
|
|
338
399
|
}
|
|
400
|
+
/** The rejection for a call made while the link is blocked. */
|
|
401
|
+
#blockedError() {
|
|
402
|
+
return this.#state.state === 'blocked' &&
|
|
403
|
+
this.#state.reason === 'leader-incompatible'
|
|
404
|
+
? new ClientSyncError(LEADER_INCOMPATIBLE_CODE, 'the tab that owns the database runs a different protocol or schema version', true)
|
|
405
|
+
: new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true);
|
|
406
|
+
}
|
|
407
|
+
/** Reject every forwarded call; their leader will not answer them. */
|
|
408
|
+
#rejectInFlight(error) {
|
|
409
|
+
const pending = [...this.#inFlight.values()];
|
|
410
|
+
this.#inFlight.clear();
|
|
411
|
+
for (const inflight of pending)
|
|
412
|
+
inflight.reject(error);
|
|
413
|
+
}
|
|
339
414
|
#dropQueued(queued) {
|
|
340
415
|
const index = this.#queue.indexOf(queued);
|
|
341
416
|
if (index >= 0)
|
|
@@ -351,8 +426,17 @@ export class FollowerLink {
|
|
|
351
426
|
// Ignore a stale announce (older than the leader we already track).
|
|
352
427
|
if (message.epoch < this.#epoch)
|
|
353
428
|
return;
|
|
429
|
+
const relation = compareTabIdentity(message, this.#identity);
|
|
430
|
+
if (relation !== 0) {
|
|
431
|
+
this.#setIncompatible(relation < 0 ? 'older' : 'newer');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
354
434
|
const changed = message.epoch !== this.#epoch ||
|
|
355
435
|
message.clientId !== this.#leaderClientId;
|
|
436
|
+
// Calls sent to the previous leader get no answer from this one.
|
|
437
|
+
if (changed && this.#epoch >= 0) {
|
|
438
|
+
this.#rejectInFlight(new ClientSyncError(LEADER_HANDOVER_CODE, 'the leader changed before it answered the call', true));
|
|
439
|
+
}
|
|
356
440
|
this.#epoch = message.epoch;
|
|
357
441
|
this.#leaderClientId = message.clientId;
|
|
358
442
|
if (changed)
|
|
@@ -376,7 +460,6 @@ export class FollowerLink {
|
|
|
376
460
|
if (inflight === undefined)
|
|
377
461
|
return;
|
|
378
462
|
this.#inFlight.delete(message.reqId);
|
|
379
|
-
inflight.cancelTimer();
|
|
380
463
|
if (message.ok) {
|
|
381
464
|
inflight.resolve(message.value);
|
|
382
465
|
}
|
|
@@ -406,18 +489,32 @@ export class FollowerLink {
|
|
|
406
489
|
/**
|
|
407
490
|
* The leader we were bound to went away (its lock released). Un-bind so new
|
|
408
491
|
* calls queue again; re-request an announce so the next leader finds us.
|
|
409
|
-
*
|
|
410
|
-
*
|
|
492
|
+
* Calls in flight to the departed leader reject; the winner's announce
|
|
493
|
+
* flushes the queued ones.
|
|
411
494
|
*/
|
|
412
495
|
unbind() {
|
|
413
496
|
if (this.#closed)
|
|
414
497
|
return;
|
|
498
|
+
this.#rejectInFlight(new ClientSyncError(LEADER_HANDOVER_CODE, 'the leader released the database before it answered the call', true));
|
|
415
499
|
this.#epoch = -1;
|
|
416
500
|
this.#leaderClientId = '';
|
|
417
501
|
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
418
502
|
this.#armUnboundDeadline();
|
|
419
503
|
this.#probe();
|
|
420
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* This tab won the lock and runs its own core: calls queued for the next
|
|
507
|
+
* leader run on `invoke` instead of failing, then the link closes.
|
|
508
|
+
*/
|
|
509
|
+
handOver(invoke) {
|
|
510
|
+
const queued = this.#queue;
|
|
511
|
+
this.#queue = [];
|
|
512
|
+
for (const call of queued) {
|
|
513
|
+
call.cancelTimer?.();
|
|
514
|
+
invoke(call.method, call.args).then(call.resolve, call.reject);
|
|
515
|
+
}
|
|
516
|
+
this.close();
|
|
517
|
+
}
|
|
421
518
|
close() {
|
|
422
519
|
if (this.#closed)
|
|
423
520
|
return;
|
|
@@ -425,11 +522,7 @@ export class FollowerLink {
|
|
|
425
522
|
this.#clearReachabilityTimers();
|
|
426
523
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
427
524
|
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
|
|
428
|
-
|
|
429
|
-
inflight.cancelTimer();
|
|
430
|
-
inflight.reject(closedError);
|
|
431
|
-
}
|
|
432
|
-
this.#inFlight.clear();
|
|
525
|
+
this.#rejectInFlight(closedError);
|
|
433
526
|
for (const queued of this.#queue) {
|
|
434
527
|
queued.cancelTimer?.();
|
|
435
528
|
queued.reject(closedError);
|
|
@@ -443,7 +536,12 @@ export class FollowerLink {
|
|
|
443
536
|
#setState(state) {
|
|
444
537
|
const previous = this.#state;
|
|
445
538
|
if (previous.state === state.state &&
|
|
446
|
-
(state.state === 'blocked'
|
|
539
|
+
((state.state === 'blocked' &&
|
|
540
|
+
previous.state === 'blocked' &&
|
|
541
|
+
previous.reason === state.reason &&
|
|
542
|
+
(state.reason !== 'leader-incompatible' ||
|
|
543
|
+
(previous.reason === 'leader-incompatible' &&
|
|
544
|
+
previous.leader === state.leader))) ||
|
|
447
545
|
(state.state === 'waiting' &&
|
|
448
546
|
previous.state === 'waiting' &&
|
|
449
547
|
previous.reason === state.reason) ||
|
|
@@ -467,8 +565,35 @@ export class FollowerLink {
|
|
|
467
565
|
t: 'hello',
|
|
468
566
|
fromId: this.#fromId,
|
|
469
567
|
epoch: this.#maxEpochSeen,
|
|
568
|
+
...this.#identity,
|
|
470
569
|
});
|
|
471
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* The leader runs another build. Send it nothing and stay unbound until a
|
|
573
|
+
* leader with this tab's identity announces (a newer leader's own hello
|
|
574
|
+
* already asked an older one to step down; see the module comment).
|
|
575
|
+
*/
|
|
576
|
+
#setIncompatible(leader) {
|
|
577
|
+
this.#clearReachabilityTimers();
|
|
578
|
+
this.#epoch = -1;
|
|
579
|
+
this.#leaderClientId = '';
|
|
580
|
+
this.#setState({
|
|
581
|
+
state: 'blocked',
|
|
582
|
+
reason: 'leader-incompatible',
|
|
583
|
+
code: LEADER_INCOMPATIBLE_CODE,
|
|
584
|
+
leader,
|
|
585
|
+
retryable: true,
|
|
586
|
+
});
|
|
587
|
+
const error = this.#blockedError();
|
|
588
|
+
this.#rejectInFlight(error);
|
|
589
|
+
const queued = this.#queue;
|
|
590
|
+
this.#queue = [];
|
|
591
|
+
for (const call of queued) {
|
|
592
|
+
call.cancelTimer?.();
|
|
593
|
+
call.reject(error);
|
|
594
|
+
}
|
|
595
|
+
this.#resolveBindWaiters();
|
|
596
|
+
}
|
|
472
597
|
/**
|
|
473
598
|
* No leader answered in time. Calls reject immediately from here on; keep
|
|
474
599
|
* probing so a leader that answers again rebinds this link.
|
|
@@ -481,6 +606,7 @@ export class FollowerLink {
|
|
|
481
606
|
code: FOLLOWER_TIMEOUT_CODE,
|
|
482
607
|
retryable: true,
|
|
483
608
|
});
|
|
609
|
+
this.#rejectInFlight(this.#blockedError());
|
|
484
610
|
const reprobe = () => {
|
|
485
611
|
this.#cancelProbe = this.#schedule(() => {
|
|
486
612
|
reprobe();
|