@procedures/store 0.23.0 → 0.24.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/CHANGELOG.md +31 -0
- package/README.md +59 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.d.ts +3 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/browser.js.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/tab-coordinator.d.ts +194 -145
- package/dist/storage/tab-coordinator.d.ts.map +1 -1
- package/dist/storage/tab-coordinator.js +1018 -251
- package/dist/storage/tab-coordinator.js.map +1 -1
- package/package.json +4 -4
- package/src/browser.ts +6 -1
- package/src/storage/index.ts +5 -1
- package/src/storage/tab-coordinator.ts +1199 -256
|
@@ -25,6 +25,39 @@
|
|
|
25
25
|
* - The leader sends the status of its sync to the followers: at each change, and in answer to a
|
|
26
26
|
* hello. `getStatus()` and `onStatus()` give the status in each tab (roadmap CLI-65).
|
|
27
27
|
*
|
|
28
|
+
* A `TabGroup` runs the sync of many collections with one lock and one channel (roadmap CLI-64),
|
|
29
|
+
* for example all the collections of a tenant.
|
|
30
|
+
*
|
|
31
|
+
* - The leader of the group runs one `HybridStorage` for each collection. Each message has the
|
|
32
|
+
* field `collection`, and each collection has its own batches, acknowledgements and status.
|
|
33
|
+
* - Each collection leads when its own queue loaded, and it tells the other tabs at once. Thus a
|
|
34
|
+
* slow collection does not stop the batches of the other collections.
|
|
35
|
+
* - A collection whose setup fails tries again alone after its backoff, and the other collections
|
|
36
|
+
* keep their sync. The tab sends a status with the error of the setup to the other tabs.
|
|
37
|
+
* - When no collection of the group leads or loads its queue, the tab releases the lock. It asks for
|
|
38
|
+
* the lock again after the backoff of the group, as a `TabCoordinator` does. This is also true for
|
|
39
|
+
* a collection that the application adds after the tab got the lock.
|
|
40
|
+
* - The lock and the channel of a group have the prefix `@procedures/store/tabs:`. A
|
|
41
|
+
* `TabCoordinator` has the prefix `@procedures/store/tab:`. Thus a group and a coordinator never
|
|
42
|
+
* share a lock or a channel, also with the same name.
|
|
43
|
+
* - With the option `open`, the leader makes a collection that another tab has and that this tab
|
|
44
|
+
* does not have. Thus the tabs can open different collections.
|
|
45
|
+
*
|
|
46
|
+
* A snapshot for a new tab (roadmap CLI-66): with `copy: "tab"` and the option `snapshot`, a new
|
|
47
|
+
* tab asks the leader for the entries of its local copy.
|
|
48
|
+
*
|
|
49
|
+
* - The leader reads the entries of its copy, and it sends them in chunks of `snapshot.chunk`
|
|
50
|
+
* entries. Each chunk has the numbers of the last batches that the copy holds, one for each tab.
|
|
51
|
+
* - The new tab keeps the batches of the other tabs until the last chunk comes. It writes the
|
|
52
|
+
* entries to its copy, and then it writes the batches with a number after the snapshot.
|
|
53
|
+
* - The new tab keeps the values of its own writes after the snapshot.
|
|
54
|
+
* - A set in the entries can come again in a batch after the snapshot, for example a write of the
|
|
55
|
+
* leader during its read. The second write of the set gives the same state.
|
|
56
|
+
* - When the leader changes before the last chunk, the tab asks the new leader. When no chunk comes
|
|
57
|
+
* in the time `snapshot.timeout`, the tab reports an error and continues without the snapshot.
|
|
58
|
+
* - In the tab that holds the lock, the wait renews itself while the collection loads its queue, at
|
|
59
|
+
* most `SNAPSHOT_RENEWALS` times. Thus the batches of the other tabs wait for a limited time.
|
|
60
|
+
*
|
|
28
61
|
* The limits:
|
|
29
62
|
* - When the leader stops during a push, the next leader sends the operation again: the delivery to
|
|
30
63
|
* the remote is at least once.
|
|
@@ -32,6 +65,12 @@
|
|
|
32
65
|
* capture. The tabs agree again after the push and the next pull.
|
|
33
66
|
* - The load of the queue in a new leader writes the queued operations to the local copy again, and
|
|
34
67
|
* the observers get them again with the origin `syncOrigin`.
|
|
68
|
+
* - The leader reads all the entries of a snapshot at one time, and the new tab keeps all the chunks
|
|
69
|
+
* until the last one. Thus a snapshot is for a local copy that fits in the memory of a tab.
|
|
70
|
+
* - `snapshot.chunk` counts the entries of a message, not its bytes: large items give large messages.
|
|
71
|
+
* - A `TabCoordinator` and a `TabGroup` of one collection do not see each other. When tabs of both
|
|
72
|
+
* releases are open, two tabs lead the collection. Give the group a new queue store, or close
|
|
73
|
+
* the tabs of the older release first.
|
|
35
74
|
*
|
|
36
75
|
* Nothing in this module reads a global value when it loads: Node can import it. The coordinator
|
|
37
76
|
* reads `navigator.locks` and `BroadcastChannel` when it starts, and a test gives its own.
|
|
@@ -80,7 +119,7 @@ export interface TabChannelLike {
|
|
|
80
119
|
export type TabChannelFactory = (name: string) => TabChannelLike;
|
|
81
120
|
|
|
82
121
|
/**
|
|
83
|
-
* The role of a tab.
|
|
122
|
+
* The role of a tab, or of a collection of a `TabGroup` in a tab.
|
|
84
123
|
*
|
|
85
124
|
* - `follower`: the tab waits for the lock. It copies the changes of the other tabs, and it sends
|
|
86
125
|
* its own changes to the leader. A tab whose setup failed is a follower until its next try.
|
|
@@ -88,6 +127,9 @@ export type TabChannelFactory = (name: string) => TabChannelLike;
|
|
|
88
127
|
* - `single`: the browser has no Web Locks API or no `BroadcastChannel`. The tab runs the sync
|
|
89
128
|
* alone, and it opens no channel.
|
|
90
129
|
* - `closed`: after `close()`.
|
|
130
|
+
*
|
|
131
|
+
* A `TabGroup` is `leader` when it holds the lock. Each collection of the group is `leader` only
|
|
132
|
+
* when its own `HybridStorage` runs.
|
|
91
133
|
*/
|
|
92
134
|
export type TabRole = "follower" | "leader" | "single" | "closed";
|
|
93
135
|
|
|
@@ -95,7 +137,7 @@ export type TabRole = "follower" | "leader" | "single" | "closed";
|
|
|
95
137
|
export interface TabCoordinatorOptions<T> {
|
|
96
138
|
/**
|
|
97
139
|
* The name of the coordinator: one name for each collection, the same in each tab. The lock and
|
|
98
|
-
* the channel get it. Give a name for each tenant too.
|
|
140
|
+
* the channel get it. Give a name for each tenant too. For many collections, use a `TabGroup`.
|
|
99
141
|
*/
|
|
100
142
|
name: string;
|
|
101
143
|
|
|
@@ -125,6 +167,13 @@ export interface TabCoordinatorOptions<T> {
|
|
|
125
167
|
*/
|
|
126
168
|
hybrid?: Omit<HybridStorageOptions, "local" | "capture">;
|
|
127
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The snapshot for a new tab (roadmap CLI-66). Only `copy: "tab"` takes it. With it, a new tab
|
|
172
|
+
* asks the leader for the entries of its local copy. Give it to a local copy that starts empty.
|
|
173
|
+
* @default false
|
|
174
|
+
*/
|
|
175
|
+
snapshot?: boolean | TabSnapshotOptions;
|
|
176
|
+
|
|
128
177
|
/**
|
|
129
178
|
* The lock manager. `null` sets the single mode.
|
|
130
179
|
* @default `navigator.locks`, when the browser has it
|
|
@@ -150,7 +199,8 @@ export interface TabCoordinatorOptions<T> {
|
|
|
150
199
|
retry?: TabRetryOptions;
|
|
151
200
|
|
|
152
201
|
/**
|
|
153
|
-
* The clock of the wait of `retry`. A test gives `fakeClock` of
|
|
202
|
+
* The clock of the wait of `retry` and of `snapshot.timeout`. A test gives `fakeClock` of
|
|
203
|
+
* `@mark1russell7/service/testing`.
|
|
154
204
|
* @default the `clock` of `hybrid`, or else the clock of the platform (`realClock` of `@procedures/client`)
|
|
155
205
|
*/
|
|
156
206
|
clock?: Clock;
|
|
@@ -163,8 +213,8 @@ export interface TabCoordinatorOptions<T> {
|
|
|
163
213
|
|
|
164
214
|
/**
|
|
165
215
|
* The function that gets the errors: of the lock, of the channel, of a write to the local copy,
|
|
166
|
-
* of the `HybridStorage`, of a failed setup and of the observers. The default
|
|
167
|
-
* again in a microtask.
|
|
216
|
+
* of the `HybridStorage`, of a failed setup, of a snapshot and of the observers. The default
|
|
217
|
+
* throws the error again in a microtask.
|
|
168
218
|
*/
|
|
169
219
|
onError?: ListenerErrorHandler;
|
|
170
220
|
}
|
|
@@ -181,6 +231,107 @@ export interface TabRetryOptions {
|
|
|
181
231
|
jitter?: number;
|
|
182
232
|
}
|
|
183
233
|
|
|
234
|
+
/** The options of the snapshot for a new tab (roadmap CLI-66). */
|
|
235
|
+
export interface TabSnapshotOptions {
|
|
236
|
+
/**
|
|
237
|
+
* The largest number of entries in one message of the snapshot, 1 or more. The leader uses its
|
|
238
|
+
* own value when it sends a snapshot. It does not limit the bytes: large items give large messages.
|
|
239
|
+
* @default 500
|
|
240
|
+
*/
|
|
241
|
+
chunk?: number;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The longest wait for the next message of the snapshot, for example `seconds(10)`. After it, the
|
|
245
|
+
* tab gives an error to `onError`, and it copies the batches of the other tabs without a snapshot.
|
|
246
|
+
* In the tab that holds the lock, the wait renews itself while the collection loads its queue, at
|
|
247
|
+
* most 5 times.
|
|
248
|
+
* @default 10 seconds
|
|
249
|
+
*/
|
|
250
|
+
timeout?: Duration;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The options of one collection of a `TabGroup` (roadmap CLI-64). */
|
|
254
|
+
export interface TabCollectionOptions<T> {
|
|
255
|
+
/** The local copy of the collection in this tab. It must have `observe()`. */
|
|
256
|
+
local: LocalCopyStorage<T>;
|
|
257
|
+
|
|
258
|
+
/** The kind of the local copy: `"shared"` or `"tab"`, as the option `copy` of a `TabCoordinator`. */
|
|
259
|
+
copy: "shared" | "tab";
|
|
260
|
+
|
|
261
|
+
/** The function that makes the remote of the collection. Only the leader calls it. */
|
|
262
|
+
remote: () => CollectionStorage<T>;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The options of the `HybridStorage` of the collection in the leader. Give a `queueStore` for each
|
|
266
|
+
* collection, the same in each tab.
|
|
267
|
+
*/
|
|
268
|
+
hybrid?: Omit<HybridStorageOptions, "local" | "capture">;
|
|
269
|
+
|
|
270
|
+
/** The snapshot for a new tab (roadmap CLI-66), as the option `snapshot` of a `TabCoordinator`. @default false */
|
|
271
|
+
snapshot?: boolean | TabSnapshotOptions;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The options of a `TabGroup` (roadmap CLI-64). */
|
|
275
|
+
export interface TabGroupOptions {
|
|
276
|
+
/**
|
|
277
|
+
* The name of the group, the same in each tab, for example the name of a tenant. The lock and the
|
|
278
|
+
* channel get it.
|
|
279
|
+
*/
|
|
280
|
+
name: string;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The function that gives the options of a collection with its name. `collection(name)` uses it
|
|
284
|
+
* without options. The leader also uses it for a collection that only another tab opened. It
|
|
285
|
+
* gives `undefined` for a collection without a sync.
|
|
286
|
+
*/
|
|
287
|
+
// The collections of a group have different item types
|
|
288
|
+
open?: (collection: string) => TabCollectionOptions<any> | undefined;
|
|
289
|
+
|
|
290
|
+
/** The lock manager. `null` sets the single mode. @default `navigator.locks`, when the browser has it */
|
|
291
|
+
locks?: TabLockManagerLike | null;
|
|
292
|
+
|
|
293
|
+
/** The function that opens the channel. `null` sets the single mode. @default a `BroadcastChannel`, when the browser has it */
|
|
294
|
+
channel?: TabChannelFactory | null;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The backoff of a new try after a failed setup (roadmap CLI-67). The group and each collection
|
|
298
|
+
* count their failures. A collection whose setup fails in the leader tries again alone.
|
|
299
|
+
*/
|
|
300
|
+
retry?: TabRetryOptions;
|
|
301
|
+
|
|
302
|
+
/** The clock of the waits. @default the clock of the platform (`realClock` of `@procedures/client`) */
|
|
303
|
+
clock?: Clock;
|
|
304
|
+
|
|
305
|
+
/** The random numbers of the jitter of `retry`, from 0 up to 1. @default `Math.random` */
|
|
306
|
+
random?: () => number;
|
|
307
|
+
|
|
308
|
+
/** The function that gets the errors of the group and of its collections. The default throws the error again in a microtask. */
|
|
309
|
+
onError?: ListenerErrorHandler;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One collection of a `TabGroup` in this tab (roadmap CLI-64). It has the methods of a
|
|
314
|
+
* `TabCoordinator` for one collection, except `close()`: `close()` of the group closes it.
|
|
315
|
+
*/
|
|
316
|
+
export interface TabCollection<T> {
|
|
317
|
+
/** The name of the collection in the group. */
|
|
318
|
+
readonly name: string;
|
|
319
|
+
/** The role of this collection in this tab: `leader` or `single` only while its `HybridStorage` runs. */
|
|
320
|
+
readonly role: TabRole;
|
|
321
|
+
/** True when this tab runs the sync of this collection. */
|
|
322
|
+
readonly isLeader: boolean;
|
|
323
|
+
/** The `HybridStorage` of the collection while this tab runs its sync, or else undefined. */
|
|
324
|
+
readonly syncStorage: HybridStorage<T> | undefined;
|
|
325
|
+
/** This method starts to send the change batches of the collection to an observer, as `TabCoordinator.observe()`. */
|
|
326
|
+
observe(observer: StorageObserver<T>): () => void;
|
|
327
|
+
/** This method starts to send the role of the collection to a listener at each change. */
|
|
328
|
+
onRole(listener: (role: TabRole) => void): () => void;
|
|
329
|
+
/** The status of the sync of the collection (roadmap CLI-65), as `TabCoordinator.getStatus()`. */
|
|
330
|
+
getStatus(): SyncStatus | undefined;
|
|
331
|
+
/** This method starts to send the status of the sync of the collection to a listener at each change. */
|
|
332
|
+
onStatus(listener: (status: SyncStatus) => void): () => void;
|
|
333
|
+
}
|
|
334
|
+
|
|
184
335
|
/** The storage of a tab that got the lock, before the tab becomes the leader. */
|
|
185
336
|
interface Candidate<T> {
|
|
186
337
|
readonly hybrid: HybridStorage<T>;
|
|
@@ -217,14 +368,48 @@ interface SentBatch<T> {
|
|
|
217
368
|
readonly changes: StorageChangeBatch<T>;
|
|
218
369
|
}
|
|
219
370
|
|
|
371
|
+
/** The entries of a snapshot, and the number of the last batch of each tab that they hold. */
|
|
372
|
+
type SnapshotEntries<T> = ReadonlyArray<readonly [string, T]>;
|
|
373
|
+
type SnapshotSeqs = ReadonlyArray<readonly [string, number]>;
|
|
374
|
+
|
|
375
|
+
/** A snapshot that a follower waits for (roadmap CLI-66). */
|
|
376
|
+
interface PendingSnapshot<T> {
|
|
377
|
+
/** The number of the last request of this tab */
|
|
378
|
+
readonly request: number;
|
|
379
|
+
/** The tab that sent the first chunk, the number of the chunks and the numbers of the batches */
|
|
380
|
+
from?: string;
|
|
381
|
+
parts?: number;
|
|
382
|
+
seqs?: SnapshotSeqs;
|
|
383
|
+
readonly chunks: Array<SnapshotEntries<T> | undefined>;
|
|
384
|
+
received: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
220
387
|
/**
|
|
221
388
|
* The version of the messages. A tab ignores a message of another version, and a message of a type
|
|
222
|
-
* that it does not know. Thus a new type of message (`status`, roadmap CLI-65
|
|
223
|
-
* a tab of an older release ignores it, and it still copies
|
|
224
|
-
* stops the batches between the tabs of two releases, so only
|
|
389
|
+
* that it does not know. Thus a new type of message (`status`, roadmap CLI-65, and `snapshot`,
|
|
390
|
+
* roadmap CLI-66) keeps the version 1: a tab of an older release ignores it, and it still copies
|
|
391
|
+
* and sends the batches. A new version stops the batches between the tabs of two releases, so only
|
|
392
|
+
* a change of a known message needs it.
|
|
225
393
|
*/
|
|
226
394
|
const PROTOCOL = 1;
|
|
227
395
|
|
|
396
|
+
/** The prefixes of the lock and of the channel: of a `TabCoordinator`, and of a `TabGroup`. */
|
|
397
|
+
const COORDINATOR_PREFIX = "@procedures/store/tab:";
|
|
398
|
+
const GROUP_PREFIX = "@procedures/store/tabs:";
|
|
399
|
+
|
|
400
|
+
/** The defaults of the snapshot (roadmap CLI-66). */
|
|
401
|
+
const SNAPSHOT_CHUNK = 500;
|
|
402
|
+
const SNAPSHOT_TIMEOUT = 10_000;
|
|
403
|
+
/** The largest number of renewals of the wait for a snapshot in the tab that holds the lock. */
|
|
404
|
+
const SNAPSHOT_RENEWALS = 5;
|
|
405
|
+
|
|
406
|
+
/** The fields of each message. A message of a `TabGroup` has `collection`, except `leader` for all the collections. */
|
|
407
|
+
interface MessageBase {
|
|
408
|
+
readonly v: typeof PROTOCOL;
|
|
409
|
+
readonly from: string;
|
|
410
|
+
readonly collection?: string;
|
|
411
|
+
}
|
|
412
|
+
|
|
228
413
|
/**
|
|
229
414
|
* A message of the channel.
|
|
230
415
|
*
|
|
@@ -232,18 +417,36 @@ const PROTOCOL = 1;
|
|
|
232
417
|
* tab increase by one.
|
|
233
418
|
* - `hello`: the tab `from` tells the leader its last number, and the batches that no leader
|
|
234
419
|
* acknowledged. A tab sends it when it starts, and when a new leader starts.
|
|
235
|
-
* - `leader`: the tab `from` is the new leader.
|
|
420
|
+
* - `leader`: the tab `from` is the new leader. In a group, a message with `collection` comes when
|
|
421
|
+
* that collection leads. The message without `collection` comes when the setup of each collection
|
|
422
|
+
* ended: a collection that the leader announced already ignores it.
|
|
236
423
|
* - `ack`: the queue store of the leader holds the batches of the tab `to`, up to `seq`.
|
|
237
424
|
* - `status`: the status of the sync of the leader `from` (roadmap CLI-65). The leader sends it at
|
|
238
425
|
* each change, and in answer to a hello. The numbers of the statuses of a leader increase by one,
|
|
239
426
|
* and the answer to a hello sends the last number again: a tab ignores a number that it has.
|
|
427
|
+
* - `snapshot-request`: the tab `from` asks the leader for the entries of its copy (roadmap CLI-66).
|
|
428
|
+
* A new request of a tab has a larger number.
|
|
429
|
+
* - `snapshot`: one chunk of the answer of the leader to the request `request` of the tab `to`.
|
|
430
|
+
* `seqs` has the number of the last batch of each tab that the entries hold.
|
|
240
431
|
*/
|
|
241
432
|
type TabMessage<T> =
|
|
242
|
-
|
|
|
243
|
-
|
|
|
244
|
-
|
|
|
245
|
-
|
|
|
246
|
-
|
|
|
433
|
+
| (MessageBase & { readonly type: "changes"; readonly seq: number; readonly changes: StorageChangeBatch<T> })
|
|
434
|
+
| (MessageBase & { readonly type: "hello"; readonly seq: number; readonly pending: ReadonlyArray<SentBatch<T>> })
|
|
435
|
+
| (MessageBase & { readonly type: "leader" })
|
|
436
|
+
| (MessageBase & { readonly type: "ack"; readonly to: string; readonly seq: number })
|
|
437
|
+
| (MessageBase & { readonly type: "status"; readonly seq: number; readonly status: PortableStatus })
|
|
438
|
+
| (MessageBase & { readonly type: "snapshot-request"; readonly request: number })
|
|
439
|
+
| (MessageBase & {
|
|
440
|
+
readonly type: "snapshot";
|
|
441
|
+
readonly to: string;
|
|
442
|
+
readonly request: number;
|
|
443
|
+
readonly part: number;
|
|
444
|
+
readonly parts: number;
|
|
445
|
+
readonly seqs: SnapshotSeqs;
|
|
446
|
+
readonly entries: SnapshotEntries<T>;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
type SnapshotMessage<T> = Extract<TabMessage<T>, { readonly type: "snapshot" }>;
|
|
247
450
|
|
|
248
451
|
/**
|
|
249
452
|
* One leader tab runs the sync of a `HybridStorage`, and the other tabs copy its changes.
|
|
@@ -276,31 +479,595 @@ export class TabCoordinator<T> {
|
|
|
276
479
|
/** The name of the lock and of the channel. */
|
|
277
480
|
readonly lockName: string;
|
|
278
481
|
|
|
482
|
+
private readonly group: GroupEngine;
|
|
483
|
+
private readonly collection: CollectionSync<T>;
|
|
484
|
+
|
|
485
|
+
constructor(options: TabCoordinatorOptions<T>) {
|
|
486
|
+
if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabCoordinator needs a name");
|
|
487
|
+
const settings = collectionSettings(options, "TabCoordinator");
|
|
488
|
+
const hybrid = options.hybrid ?? {};
|
|
489
|
+
this.group = new GroupEngine({
|
|
490
|
+
prefix: COORDINATOR_PREFIX,
|
|
491
|
+
name: options.name,
|
|
492
|
+
single: true,
|
|
493
|
+
open: undefined,
|
|
494
|
+
backoff: retryBackoff(options.retry),
|
|
495
|
+
clock: options.clock ?? hybrid.clock ?? realClock,
|
|
496
|
+
random: options.random ?? hybrid.random ?? Math.random,
|
|
497
|
+
onError: options.onError ?? reportListenerError,
|
|
498
|
+
});
|
|
499
|
+
this.id = this.group.id;
|
|
500
|
+
this.lockName = this.group.lockName;
|
|
501
|
+
this.collection = this.group.add(options.name, options, settings);
|
|
502
|
+
this.group.start(options.locks, options.channel);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The role of this tab now. */
|
|
506
|
+
get role(): TabRole {
|
|
507
|
+
return this.collection.role;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** True when this tab runs the sync: the leader, or a tab in the single mode. */
|
|
511
|
+
get isLeader(): boolean {
|
|
512
|
+
return this.collection.isLeader;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The `HybridStorage` of the sync while this tab runs it, or else undefined. `getStatus()` of the
|
|
517
|
+
* coordinator gives the state of the sync in each tab. Write to the local copy, not to this
|
|
518
|
+
* storage: each tab then writes in the same way.
|
|
519
|
+
*/
|
|
520
|
+
get syncStorage(): HybridStorage<T> | undefined {
|
|
521
|
+
return this.collection.syncStorage;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* This method starts to send the change batches to an observer: the batches of the local copy of
|
|
526
|
+
* this tab, and the batches of the other tabs. Each batch comes one time, with the origin of each
|
|
527
|
+
* change. In the leader, the batches of the sync have the origin `syncOrigin` of the
|
|
528
|
+
* `HybridStorage`. A batch of another tab comes after the local copy of this tab has it. The
|
|
529
|
+
* entries of a snapshot come in one batch of sets, with the origin `syncOrigin`. A set of the
|
|
530
|
+
* snapshot can come again in a later batch, with the same value.
|
|
531
|
+
*
|
|
532
|
+
* @returns The function that stops the batches. A second use of it does nothing.
|
|
533
|
+
* @throws Error after `close()`
|
|
534
|
+
*/
|
|
535
|
+
observe(observer: StorageObserver<T>): () => void {
|
|
536
|
+
return this.collection.observe(observer);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* This method starts to send the role of this tab to a listener at each change.
|
|
541
|
+
*
|
|
542
|
+
* @returns The function that stops the roles. A second use of it does nothing.
|
|
543
|
+
* @throws Error after `close()`
|
|
544
|
+
*/
|
|
545
|
+
onRole(listener: (role: TabRole) => void): () => void {
|
|
546
|
+
return this.collection.onRole(listener);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The status of the sync (roadmap CLI-65), or undefined before the first status and after
|
|
551
|
+
* `close()`.
|
|
552
|
+
*
|
|
553
|
+
* - In the leader and in the single mode, it is the status of the `HybridStorage` of this tab.
|
|
554
|
+
* - In a follower, it is the last status that the leader sent. A new tab gets it in the answer to
|
|
555
|
+
* its hello. After a change of the leader, a follower keeps the last status until the new
|
|
556
|
+
* leader sends its status.
|
|
557
|
+
* - In a follower, `error.error` is a copy of the error of the leader: an `Error` with its `name`,
|
|
558
|
+
* its `message` and its `code`. The copy has no `cause`.
|
|
559
|
+
*/
|
|
560
|
+
getStatus(): SyncStatus | undefined {
|
|
561
|
+
return this.collection.getStatus();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* This method starts to send the status of the sync to a listener at each change (roadmap
|
|
566
|
+
* CLI-65): in the leader, each status of its `HybridStorage`, and in a follower, each status that
|
|
567
|
+
* the leader sends. The listener gets each status one time. When the tab becomes the leader, the
|
|
568
|
+
* listener gets the status of its own `HybridStorage`.
|
|
569
|
+
*
|
|
570
|
+
* @returns The function that stops the statuses. A second use of it does nothing.
|
|
571
|
+
* @throws Error after `close()`
|
|
572
|
+
*/
|
|
573
|
+
onStatus(listener: (status: SyncStatus) => void): () => void {
|
|
574
|
+
return this.collection.onStatus(listener);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* This method closes the coordinator.
|
|
579
|
+
*
|
|
580
|
+
* - A follower stops its lock request, or its wait before a new try. It does not become the leader.
|
|
581
|
+
* - A tab that loads its queue to become the leader closes its `HybridStorage`, and releases the lock.
|
|
582
|
+
* - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
|
|
583
|
+
* operations that the remote did not take. Then the leader releases the lock, and the next
|
|
584
|
+
* tab becomes the leader.
|
|
585
|
+
* - The coordinator closes its channel. It does not close the local copy: the application owns it.
|
|
586
|
+
* - After it, the coordinator sends and copies nothing. A second `close()` gives the promise of
|
|
587
|
+
* the first one.
|
|
588
|
+
*/
|
|
589
|
+
close(): Promise<void> {
|
|
590
|
+
return this.group.close();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* One leader tab runs the sync of many collections, with one lock and one channel (roadmap CLI-64).
|
|
596
|
+
* The other tabs copy the changes of each collection.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```typescript
|
|
600
|
+
* import { ApiStorage, IndexedDbQueueStore, TabGroup } from "@procedures/store/browser";
|
|
601
|
+
*
|
|
602
|
+
* const tenant = new TabGroup({
|
|
603
|
+
* name: tenantId,
|
|
604
|
+
* open: (collection) => ({
|
|
605
|
+
* local: localCopyOf(collection),
|
|
606
|
+
* copy: "tab",
|
|
607
|
+
* remote: () => new ApiStorage(client, { collection }),
|
|
608
|
+
* hybrid: { queueStore: new IndexedDbQueueStore(`${tenantId}-${collection}-queue`) },
|
|
609
|
+
* snapshot: true,
|
|
610
|
+
* }),
|
|
611
|
+
* });
|
|
612
|
+
*
|
|
613
|
+
* const notes = tenant.collection<Note>("notes");
|
|
614
|
+
* notes.observe((batch) => render(batch));
|
|
615
|
+
* notes.onStatus((status) => showSync("notes", status));
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
export class TabGroup {
|
|
619
|
+
/** The id of this tab: a new random id for each group. */
|
|
620
|
+
readonly id: string;
|
|
621
|
+
/** The name of the lock and of the channel. */
|
|
622
|
+
readonly lockName: string;
|
|
623
|
+
|
|
624
|
+
private readonly group: GroupEngine;
|
|
625
|
+
|
|
626
|
+
constructor(options: TabGroupOptions) {
|
|
627
|
+
if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabGroup needs a name");
|
|
628
|
+
if (options.open !== undefined && typeof options.open !== "function") throw new TypeError("The option open of a TabGroup must be a function");
|
|
629
|
+
this.group = new GroupEngine({
|
|
630
|
+
prefix: GROUP_PREFIX,
|
|
631
|
+
name: options.name,
|
|
632
|
+
single: false,
|
|
633
|
+
open: options.open,
|
|
634
|
+
backoff: retryBackoff(options.retry),
|
|
635
|
+
clock: options.clock ?? realClock,
|
|
636
|
+
random: options.random ?? Math.random,
|
|
637
|
+
onError: options.onError ?? reportListenerError,
|
|
638
|
+
});
|
|
639
|
+
this.id = this.group.id;
|
|
640
|
+
this.lockName = this.group.lockName;
|
|
641
|
+
this.group.start(options.locks, options.channel);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** The role of this tab in the group. A collection has its own role too. */
|
|
645
|
+
get role(): TabRole {
|
|
646
|
+
return this.group.role;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** True when this tab holds the lock of the group, or runs in the single mode. */
|
|
650
|
+
get isLeader(): boolean {
|
|
651
|
+
return this.group.role === "leader" || this.group.role === "single";
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The names of the collections of the group in this tab. */
|
|
655
|
+
get collections(): string[] {
|
|
656
|
+
return [...this.group.members.keys()];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* This method gives a collection of the group. It adds the collection at its first use. When the
|
|
661
|
+
* group has the collection, the method gives it and ignores `options`.
|
|
662
|
+
*
|
|
663
|
+
* @param options The options of a new collection. The default is the result of `open(name)`.
|
|
664
|
+
* @throws TypeError for a new collection without options, or with options of a wrong shape
|
|
665
|
+
* @throws Error after `close()`
|
|
666
|
+
*/
|
|
667
|
+
collection<T>(name: string, options?: TabCollectionOptions<T>): TabCollection<T> {
|
|
668
|
+
return this.group.collection(name, options);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* This method starts to send the role of this tab in the group to a listener at each change.
|
|
673
|
+
*
|
|
674
|
+
* @returns The function that stops the roles. A second use of it does nothing.
|
|
675
|
+
* @throws Error after `close()`
|
|
676
|
+
*/
|
|
677
|
+
onRole(listener: (role: TabRole) => void): () => void {
|
|
678
|
+
if (this.group.closed) throw new Error("The TabGroup is closed");
|
|
679
|
+
return this.group.roleListeners.add(listener);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* This method closes the group and each of its collections, as `TabCoordinator.close()` does.
|
|
684
|
+
* The leader closes each `HybridStorage`, and then it releases the lock.
|
|
685
|
+
*/
|
|
686
|
+
close(): Promise<void> {
|
|
687
|
+
return this.group.close();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** The settings of a collection that the options give. */
|
|
692
|
+
interface CollectionSettings {
|
|
693
|
+
/** The longest number of entries in a chunk, and the wait for a chunk. `want` is true with the option `snapshot`. */
|
|
694
|
+
readonly snapshot: { readonly want: boolean; readonly chunk: number; readonly timeout: number };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The options of the engine of a group. */
|
|
698
|
+
interface EngineOptions {
|
|
699
|
+
readonly prefix: string;
|
|
700
|
+
readonly name: string;
|
|
701
|
+
/** True for a `TabCoordinator`: one collection, and messages without the field `collection` */
|
|
702
|
+
readonly single: boolean;
|
|
703
|
+
readonly open: ((collection: string) => TabCollectionOptions<any> | undefined) | undefined;
|
|
704
|
+
readonly backoff: Backoff;
|
|
705
|
+
readonly clock: Clock;
|
|
706
|
+
readonly random: () => number;
|
|
707
|
+
readonly onError: ListenerErrorHandler;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The lock, the channel and the collections of a tab. A `TabCoordinator` is a group with one
|
|
712
|
+
* collection, whose messages have no field `collection`: thus it talks with a tab of an older release.
|
|
713
|
+
*/
|
|
714
|
+
class GroupEngine {
|
|
715
|
+
readonly id: string;
|
|
716
|
+
readonly lockName: string;
|
|
717
|
+
/** True for a `TabCoordinator` */
|
|
718
|
+
readonly legacy: boolean;
|
|
719
|
+
readonly backoff: Backoff;
|
|
720
|
+
readonly clock: Clock;
|
|
721
|
+
readonly random: () => number;
|
|
722
|
+
readonly onError: ListenerErrorHandler;
|
|
723
|
+
readonly roleListeners: StorageListeners<TabRole>;
|
|
724
|
+
/** The collections of this tab, by name */
|
|
725
|
+
readonly members = new Map<string, CollectionSync<any>>();
|
|
726
|
+
/** An object for each time that this tab holds the lock. A collection leads only with the lease of now. */
|
|
727
|
+
lease: object | undefined;
|
|
728
|
+
/** True in the single mode */
|
|
729
|
+
alone = false;
|
|
730
|
+
closed = false;
|
|
731
|
+
|
|
732
|
+
private readonly open: EngineOptions["open"];
|
|
733
|
+
/** The names for which `open` gave no options */
|
|
734
|
+
private readonly refused = new Set<string>();
|
|
735
|
+
private currentRole: TabRole = "follower";
|
|
736
|
+
private started = false;
|
|
737
|
+
/** The lock manager, or undefined in the single mode */
|
|
738
|
+
private locks: TabLockManagerLike | undefined;
|
|
739
|
+
private channel: TabChannelLike | undefined;
|
|
740
|
+
/** The number of the failed setups of the group in a row: each setup where no collection could lead */
|
|
741
|
+
private failures = 0;
|
|
742
|
+
/** The stop of the wait before the next try */
|
|
743
|
+
private cancelRetry: Cancel | undefined;
|
|
744
|
+
private closing: Promise<void> | undefined;
|
|
745
|
+
/** The abort of the lock request that waits */
|
|
746
|
+
private abortRequest: (() => void) | undefined;
|
|
747
|
+
/** The release of the lock */
|
|
748
|
+
private releaseLock: (() => void) | undefined;
|
|
749
|
+
|
|
750
|
+
constructor(options: EngineOptions) {
|
|
751
|
+
this.id = randomId();
|
|
752
|
+
this.lockName = `${options.prefix}${options.name}`;
|
|
753
|
+
this.legacy = options.single;
|
|
754
|
+
this.open = options.open;
|
|
755
|
+
this.backoff = options.backoff;
|
|
756
|
+
this.clock = options.clock;
|
|
757
|
+
this.random = options.random;
|
|
758
|
+
this.onError = options.onError;
|
|
759
|
+
this.roleListeners = new StorageListeners<TabRole>(this.onError);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
get role(): TabRole {
|
|
763
|
+
return this.currentRole;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** True while the tab has a channel. */
|
|
767
|
+
get hasChannel(): boolean {
|
|
768
|
+
return this.channel !== undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The name of the coordinator or of the group, for the messages of the errors. */
|
|
772
|
+
get kind(): string {
|
|
773
|
+
return this.legacy ? "TabCoordinator" : "TabGroup";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** This method opens the channel and asks for the lock, or starts the single mode. */
|
|
777
|
+
start(locksOption: TabLockManagerLike | null | undefined, channelOption: TabChannelFactory | null | undefined): void {
|
|
778
|
+
this.started = true;
|
|
779
|
+
const locks = locksOption === undefined ? globalLocks() : (locksOption ?? undefined);
|
|
780
|
+
const openChannel = channelOption === undefined ? globalChannelFactory() : (channelOption ?? undefined);
|
|
781
|
+
if (!locks || !openChannel) {
|
|
782
|
+
this.startSingle();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
this.channel = openChannel(this.lockName);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
this.onError(error);
|
|
789
|
+
this.startSingle();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.locks = locks;
|
|
793
|
+
this.channel.onmessage = (event: { data?: unknown } | undefined) => this.receive(event?.data);
|
|
794
|
+
for (const member of this.members.values()) member.begin();
|
|
795
|
+
this.elect(locks);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** This method gives a collection of a `TabGroup`, and adds it at its first use. */
|
|
799
|
+
collection<T>(name: string, options: TabCollectionOptions<T> | undefined): CollectionSync<T> {
|
|
800
|
+
if (this.closed) throw new Error("The TabGroup is closed");
|
|
801
|
+
if (typeof name !== "string" || name === "") throw new TypeError("A collection of a TabGroup needs a name");
|
|
802
|
+
const known = this.members.get(name);
|
|
803
|
+
if (known) return known as CollectionSync<T>;
|
|
804
|
+
const resolved = options ?? (this.open?.(name) as TabCollectionOptions<T> | undefined);
|
|
805
|
+
if (!resolved) throw new TypeError(`The TabGroup has no options for the collection ${JSON.stringify(name)}: give options or the function open`);
|
|
806
|
+
return this.add(name, resolved, collectionSettings(resolved, "A collection of a TabGroup"));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** This method adds a collection. After the start, the collection says hello, and it leads when the tab holds the lock. */
|
|
810
|
+
add<T>(name: string, options: TabCollectionOptions<T>, settings: CollectionSettings): CollectionSync<T> {
|
|
811
|
+
const member = new CollectionSync<T>(this, name, options, settings);
|
|
812
|
+
this.members.set(name, member);
|
|
813
|
+
this.refused.delete(name);
|
|
814
|
+
if (!this.started) return member;
|
|
815
|
+
if (this.alone) {
|
|
816
|
+
member.startSingle();
|
|
817
|
+
return member;
|
|
818
|
+
}
|
|
819
|
+
member.begin();
|
|
820
|
+
if (this.lease) member.startLead(this.lease, true);
|
|
821
|
+
return member;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** @returns False when the message did not go out: no channel, or a value that the channel cannot clone */
|
|
825
|
+
post(collection: string | undefined, message: TabMessage<unknown>): boolean {
|
|
826
|
+
const channel = this.channel;
|
|
827
|
+
if (!channel) return false;
|
|
828
|
+
try {
|
|
829
|
+
channel.postMessage(this.legacy || collection === undefined ? message : { ...message, collection });
|
|
830
|
+
return true;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
this.onError(error);
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
close(): Promise<void> {
|
|
838
|
+
if (!this.closing) this.closing = this.shutDown();
|
|
839
|
+
return this.closing;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private async shutDown(): Promise<void> {
|
|
843
|
+
this.closed = true;
|
|
844
|
+
this.abortRequest?.();
|
|
845
|
+
this.abortRequest = undefined;
|
|
846
|
+
this.cancelRetry?.();
|
|
847
|
+
this.cancelRetry = undefined;
|
|
848
|
+
this.lease = undefined;
|
|
849
|
+
this.setRole("closed");
|
|
850
|
+
const members = [...this.members.values()];
|
|
851
|
+
try {
|
|
852
|
+
// The leader holds the lock until each queue reached the remote or its queue store
|
|
853
|
+
await Promise.all(members.map((member) => member.shutDown()));
|
|
854
|
+
} finally {
|
|
855
|
+
this.releaseLock?.();
|
|
856
|
+
this.releaseLock = undefined;
|
|
857
|
+
// The acknowledgements of the close of the storages went out before
|
|
858
|
+
try {
|
|
859
|
+
this.channel?.close();
|
|
860
|
+
} catch {
|
|
861
|
+
// The channel is closed already
|
|
862
|
+
}
|
|
863
|
+
this.channel = undefined;
|
|
864
|
+
for (const member of members) member.clearListeners();
|
|
865
|
+
this.roleListeners.clear();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
//
|
|
870
|
+
// ═══ The election ═══
|
|
871
|
+
//
|
|
872
|
+
|
|
873
|
+
/** This method asks for the lock. The tab becomes the leader when it gets the lock. */
|
|
874
|
+
private elect(locks: TabLockManagerLike): void {
|
|
875
|
+
const Controller = globalAbortController();
|
|
876
|
+
const controller = Controller ? new Controller() : undefined;
|
|
877
|
+
this.abortRequest = controller ? () => controller.abort() : undefined;
|
|
878
|
+
const held = new Promise<void>((resolve) => (this.releaseLock = resolve));
|
|
879
|
+
let request: Promise<unknown>;
|
|
880
|
+
try {
|
|
881
|
+
request = locks.request(this.lockName, controller ? { signal: controller.signal } : {}, async () => {
|
|
882
|
+
// A lock after close(): the tab releases it at once
|
|
883
|
+
if (this.closed) return;
|
|
884
|
+
this.abortRequest = undefined;
|
|
885
|
+
if (await this.lead()) {
|
|
886
|
+
// close() ends the hold. A group whose collections all failed after the start ends it too.
|
|
887
|
+
await held;
|
|
888
|
+
if (this.closed) return;
|
|
889
|
+
}
|
|
890
|
+
// A failed setup releases the lock: another tab tries, and this tab tries again after the backoff
|
|
891
|
+
this.retryLater();
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
request = Promise.reject(error);
|
|
895
|
+
}
|
|
896
|
+
request.then(undefined, (error: unknown) => {
|
|
897
|
+
// The abort of close(), or a tab that leads already
|
|
898
|
+
if (this.closed || this.lease) return;
|
|
899
|
+
// The lock manager refused the request: this tab runs the sync alone
|
|
900
|
+
this.onError(error);
|
|
901
|
+
this.stopChannel();
|
|
902
|
+
this.locks = undefined;
|
|
903
|
+
this.startSingle();
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* This method runs in a tab that got the lock. Each collection makes its `HybridStorage` and
|
|
909
|
+
* loads its queue, and it leads at the end of its load. When no collection can lead, the tab
|
|
910
|
+
* releases the lock. Else the collections whose setup failed try again alone after their backoff.
|
|
911
|
+
*
|
|
912
|
+
* @returns False when the tab must release the lock: no collection could lead, or `close()`
|
|
913
|
+
*/
|
|
914
|
+
private async lead(): Promise<boolean> {
|
|
915
|
+
const lease = {};
|
|
916
|
+
this.lease = lease;
|
|
917
|
+
const members = [...this.members.values()];
|
|
918
|
+
// Each collection tells the other tabs at once when it leads: a slow collection does not stop the batches of the others
|
|
919
|
+
const results = await Promise.all(members.map((member) => member.takeLead(lease, true)));
|
|
920
|
+
if (this.lease !== lease) return false;
|
|
921
|
+
// A group without collections keeps the lock: its first collection leads, or releases the lock when its setup fails
|
|
922
|
+
if (!this.canLead()) {
|
|
923
|
+
this.stepDown();
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
this.failures = 0;
|
|
927
|
+
this.setRole("leader");
|
|
928
|
+
// Each follower says hello for each collection that it has and that the leader did not announce: the leader makes the collections that it does not have
|
|
929
|
+
if (!this.legacy) this.post(undefined, { v: PROTOCOL, type: "leader", from: this.id });
|
|
930
|
+
members.forEach((member, at) => {
|
|
931
|
+
if (!results[at]) member.retryInLeader();
|
|
932
|
+
});
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* This method runs after a failed setup of a collection in a tab that holds the lock, and keeps
|
|
938
|
+
* it after `lead()`. When no collection of the group leads or loads its queue, the tab releases
|
|
939
|
+
* the lock and asks for it again after the backoff of the group. Else the collection tries again
|
|
940
|
+
* alone after its own backoff.
|
|
941
|
+
*/
|
|
942
|
+
afterFailedSetup(member: CollectionSync<any>, lease: object): void {
|
|
943
|
+
if (this.closed || this.lease !== lease) return;
|
|
944
|
+
if (this.canLead()) {
|
|
945
|
+
member.retryInLeader();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
this.stepDown();
|
|
949
|
+
this.setRole("follower");
|
|
950
|
+
// The lock callback then asks for the lock again after the backoff
|
|
951
|
+
const release = this.releaseLock;
|
|
952
|
+
this.releaseLock = undefined;
|
|
953
|
+
release?.();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** True when the group has no collections yet, or when one of its collections leads or loads its queue. */
|
|
957
|
+
private canLead(): boolean {
|
|
958
|
+
const members = [...this.members.values()];
|
|
959
|
+
return members.length === 0 || members.some((member) => member.leads || member.settingUp);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** This method ends the lease of the lock. A collection that waits for its next try stops its wait: the next lease tries it. */
|
|
963
|
+
private stepDown(): void {
|
|
964
|
+
this.lease = undefined;
|
|
965
|
+
for (const member of this.members.values()) member.stopRetry();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** This method starts the single mode: each collection runs its sync alone. */
|
|
969
|
+
private startSingle(): void {
|
|
970
|
+
this.alone = true;
|
|
971
|
+
this.setRole("single");
|
|
972
|
+
for (const member of [...this.members.values()]) member.startSingle();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** This method asks for the lock again after the backoff (roadmap CLI-67). */
|
|
976
|
+
private retryLater(): void {
|
|
977
|
+
if (this.closed) return;
|
|
978
|
+
this.failures++;
|
|
979
|
+
const wait = backoffDelay(this.backoff, this.failures, this.random);
|
|
980
|
+
const cancel = this.clock.after(wait, () => {
|
|
981
|
+
this.cancelRetry = undefined;
|
|
982
|
+
if (this.closed || !this.locks) return;
|
|
983
|
+
this.elect(this.locks);
|
|
984
|
+
});
|
|
985
|
+
// The wait must not keep a Node process alive
|
|
986
|
+
cancel.unref?.();
|
|
987
|
+
this.cancelRetry = cancel;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private setRole(role: TabRole): void {
|
|
991
|
+
if (this.currentRole === role) return;
|
|
992
|
+
this.currentRole = role;
|
|
993
|
+
this.roleListeners.emit(role);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private stopChannel(): void {
|
|
997
|
+
const channel = this.channel;
|
|
998
|
+
this.channel = undefined;
|
|
999
|
+
if (!channel) return;
|
|
1000
|
+
channel.onmessage = null;
|
|
1001
|
+
try {
|
|
1002
|
+
channel.close();
|
|
1003
|
+
} catch {
|
|
1004
|
+
// The channel is closed already
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
//
|
|
1009
|
+
// ═══ The messages of the other tabs ═══
|
|
1010
|
+
//
|
|
1011
|
+
|
|
1012
|
+
private receive(data: unknown): void {
|
|
1013
|
+
if (this.closed || !isTabMessage<unknown>(data) || data.from === this.id) return;
|
|
1014
|
+
if (this.legacy) {
|
|
1015
|
+
for (const member of this.members.values()) member.receive(data);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const name = data.collection;
|
|
1019
|
+
if (name === undefined) {
|
|
1020
|
+
// A new leader of the group: each collection of this tab that the leader did not announce says hello
|
|
1021
|
+
if (data.type === "leader") for (const member of [...this.members.values()]) member.onLeader(data.from, false);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const member = this.members.get(name) ?? this.adopt(name, data.type);
|
|
1025
|
+
member?.receive(data);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The leader makes a collection that another tab has, with the function `open`. Thus the leader
|
|
1030
|
+
* runs the sync of each collection of each tab.
|
|
1031
|
+
*/
|
|
1032
|
+
private adopt(name: string, type: TabMessage<unknown>["type"]): CollectionSync<unknown> | undefined {
|
|
1033
|
+
if ((type !== "hello" && type !== "changes") || this.currentRole !== "leader" || !this.open || this.refused.has(name)) return undefined;
|
|
1034
|
+
try {
|
|
1035
|
+
const options = this.open(name);
|
|
1036
|
+
if (options) return this.add(name, options, collectionSettings(options, "A collection of a TabGroup"));
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.onError(error);
|
|
1039
|
+
}
|
|
1040
|
+
this.refused.add(name);
|
|
1041
|
+
return undefined;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* The sync of one collection in a tab: the leader runs its `HybridStorage`, and a follower copies
|
|
1047
|
+
* the batches of the other tabs. The group gives the lock and the channel.
|
|
1048
|
+
*/
|
|
1049
|
+
class CollectionSync<T> implements TabCollection<T> {
|
|
1050
|
+
readonly name: string;
|
|
1051
|
+
|
|
1052
|
+
private readonly group: GroupEngine;
|
|
279
1053
|
private readonly local: LocalCopyStorage<T>;
|
|
280
1054
|
private readonly shared: boolean;
|
|
281
1055
|
private readonly makeRemote: () => CollectionStorage<T>;
|
|
282
1056
|
private readonly hybridOptions: Omit<HybridStorageOptions, "local" | "capture">;
|
|
1057
|
+
private readonly settings: CollectionSettings;
|
|
283
1058
|
/** The origin of the `HybridStorage` and of the writes of the coordinator to the local copy. */
|
|
284
1059
|
private readonly tag: string;
|
|
285
1060
|
private readonly writeOptions: StorageWriteOptions;
|
|
286
|
-
private readonly onError: ListenerErrorHandler;
|
|
287
1061
|
private readonly observers: StorageListeners<StorageChangeBatch<T>>;
|
|
288
1062
|
private readonly roleListeners: StorageListeners<TabRole>;
|
|
289
1063
|
private readonly statusListeners: StorageListeners<SyncStatus>;
|
|
290
1064
|
/** The observers that the `HybridStorage` of the leader adds to its local copy: the capture */
|
|
291
1065
|
private readonly captures: StorageListeners<StorageChangeBatch<T>>;
|
|
292
|
-
/** The backoff of a new try after a failed setup, its clock and its random numbers */
|
|
293
|
-
private readonly backoff: Backoff;
|
|
294
|
-
private readonly clock: Clock;
|
|
295
|
-
private readonly random: () => number;
|
|
296
1066
|
private currentRole: TabRole = "follower";
|
|
297
|
-
/** The lock manager, or undefined in the single mode */
|
|
298
|
-
private locks: TabLockManagerLike | undefined;
|
|
299
|
-
private channel: TabChannelLike | undefined;
|
|
300
1067
|
private hybrid: HybridStorage<T> | undefined;
|
|
301
1068
|
/** The storage of this tab while it loads its queue, before the tab leads */
|
|
302
1069
|
private candidate: Candidate<T> | undefined;
|
|
303
|
-
/** The number of the failed setups in a row */
|
|
1070
|
+
/** The number of the failed setups of this collection in a row, when the group leads or is single */
|
|
304
1071
|
private failures = 0;
|
|
305
1072
|
/** The stop of the wait before the next try */
|
|
306
1073
|
private cancelRetry: Cancel | undefined;
|
|
@@ -321,160 +1088,105 @@ export class TabCoordinator<T> {
|
|
|
321
1088
|
private outbox: SentBatch<T>[] = [];
|
|
322
1089
|
/** The number of the last batch of each other tab that this tab copied */
|
|
323
1090
|
private readonly seen = new Map<string, number>();
|
|
1091
|
+
/** The number of the last batch of each other tab that the local copy of this tab holds (roadmap CLI-66) */
|
|
1092
|
+
private readonly applied = new Map<string, number>();
|
|
324
1093
|
/** In the leader: the number of the last batch of each tab that the leader captured. A tab without a hello is not here. */
|
|
325
1094
|
private readonly captured = new Map<string, number>();
|
|
326
1095
|
/** The batches of the other tabs: they reach the local copy and the observers in their order */
|
|
327
1096
|
private incoming: Promise<void> = Promise.resolve();
|
|
328
1097
|
private closed = false;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
private
|
|
332
|
-
/** The
|
|
333
|
-
private
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.
|
|
346
|
-
this.
|
|
347
|
-
this.
|
|
348
|
-
this.
|
|
349
|
-
this.
|
|
350
|
-
this.
|
|
351
|
-
this.
|
|
352
|
-
this.
|
|
353
|
-
this.
|
|
354
|
-
this.
|
|
355
|
-
this.
|
|
356
|
-
this.
|
|
357
|
-
this.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
this.stopTap = this.local.observe!((batch) => this.onFollowerBatch(batch));
|
|
361
|
-
const locks = options.locks === undefined ? globalLocks() : (options.locks ?? undefined);
|
|
362
|
-
const openChannel = options.channel === undefined ? globalChannelFactory() : (options.channel ?? undefined);
|
|
363
|
-
if (!locks || !openChannel) {
|
|
364
|
-
this.startSingle();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
try {
|
|
368
|
-
this.channel = openChannel(this.lockName);
|
|
369
|
-
} catch (error) {
|
|
370
|
-
this.onError(error);
|
|
371
|
-
this.startSingle();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
this.locks = locks;
|
|
375
|
-
this.channel.onmessage = (event: { data?: unknown } | undefined) => this.receive(event?.data);
|
|
376
|
-
this.hello();
|
|
377
|
-
this.elect(locks);
|
|
1098
|
+
/** The snapshot that this tab waits for, and the number of its last request (roadmap CLI-66) */
|
|
1099
|
+
private snapshot: PendingSnapshot<T> | undefined;
|
|
1100
|
+
private requests = 0;
|
|
1101
|
+
/** The batches of the other tabs that wait for the snapshot, in their order */
|
|
1102
|
+
private held: Array<{ readonly from: string; readonly batch: SentBatch<T> }> = [];
|
|
1103
|
+
/** The own writes while the snapshot waits: the number of the last own batch of each id, and of the last clear */
|
|
1104
|
+
private readonly ownWrites = new Map<string, number>();
|
|
1105
|
+
private ownClear = 0;
|
|
1106
|
+
/** The stop of the wait for the next chunk */
|
|
1107
|
+
private cancelSnapshotWait: Cancel | undefined;
|
|
1108
|
+
/** The last tab that told this tab that it leads this collection: the `leader` message of its group does not start a second hello */
|
|
1109
|
+
private announcedBy: string | undefined;
|
|
1110
|
+
/** The error of the last failed setup of the sync */
|
|
1111
|
+
private setupError: unknown;
|
|
1112
|
+
|
|
1113
|
+
constructor(group: GroupEngine, name: string, options: TabCollectionOptions<T>, settings: CollectionSettings) {
|
|
1114
|
+
this.group = group;
|
|
1115
|
+
this.name = name;
|
|
1116
|
+
this.local = options.local;
|
|
1117
|
+
this.shared = options.copy === "shared";
|
|
1118
|
+
this.makeRemote = options.remote;
|
|
1119
|
+
this.hybridOptions = options.hybrid ?? {};
|
|
1120
|
+
this.settings = settings;
|
|
1121
|
+
this.tag = this.hybridOptions.origin ?? `tab-${group.id}`;
|
|
1122
|
+
this.writeOptions = { origin: this.tag };
|
|
1123
|
+
this.observers = new StorageListeners<StorageChangeBatch<T>>(group.onError);
|
|
1124
|
+
this.roleListeners = new StorageListeners<TabRole>(group.onError);
|
|
1125
|
+
this.statusListeners = new StorageListeners<SyncStatus>(group.onError);
|
|
1126
|
+
this.captures = new StorageListeners<StorageChangeBatch<T>>(group.onError);
|
|
1127
|
+
// The observer of a follower. A tab that leads stops it. A single tab whose setup failed keeps its batches with it.
|
|
1128
|
+
this.stopTap = this.local.observe!((batch) => this.onFollowerBatch(batch));
|
|
378
1129
|
}
|
|
379
1130
|
|
|
380
|
-
/** The role of this tab now. */
|
|
381
1131
|
get role(): TabRole {
|
|
382
1132
|
return this.currentRole;
|
|
383
1133
|
}
|
|
384
1134
|
|
|
385
|
-
/** True when this tab runs the sync: the leader, or a tab in the single mode. */
|
|
386
1135
|
get isLeader(): boolean {
|
|
387
1136
|
return this.currentRole === "leader" || this.currentRole === "single";
|
|
388
1137
|
}
|
|
389
1138
|
|
|
390
|
-
/**
|
|
391
|
-
* The `HybridStorage` of the sync while this tab runs it, or else undefined. `getStatus()` of the
|
|
392
|
-
* coordinator gives the state of the sync in each tab. Write to the local copy, not to this
|
|
393
|
-
* storage: each tab then writes in the same way.
|
|
394
|
-
*/
|
|
395
1139
|
get syncStorage(): HybridStorage<T> | undefined {
|
|
396
1140
|
return this.hybrid;
|
|
397
1141
|
}
|
|
398
1142
|
|
|
399
|
-
/**
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1143
|
+
/** True while the `HybridStorage` of this tab runs the sync of the collection. */
|
|
1144
|
+
get leads(): boolean {
|
|
1145
|
+
return this.hybrid !== undefined;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** True while the `HybridStorage` of this tab loads its queue, before the collection leads. */
|
|
1149
|
+
get settingUp(): boolean {
|
|
1150
|
+
return this.candidate !== undefined;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
408
1153
|
observe(observer: StorageObserver<T>): () => void {
|
|
409
|
-
if (this.closed) throw new Error(
|
|
1154
|
+
if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
|
|
410
1155
|
return this.observers.add(observer);
|
|
411
1156
|
}
|
|
412
1157
|
|
|
413
|
-
/**
|
|
414
|
-
* This method starts to send the role of this tab to a listener at each change.
|
|
415
|
-
*
|
|
416
|
-
* @returns The function that stops the roles. A second use of it does nothing.
|
|
417
|
-
* @throws Error after `close()`
|
|
418
|
-
*/
|
|
419
1158
|
onRole(listener: (role: TabRole) => void): () => void {
|
|
420
|
-
if (this.closed) throw new Error(
|
|
1159
|
+
if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
|
|
421
1160
|
return this.roleListeners.add(listener);
|
|
422
1161
|
}
|
|
423
1162
|
|
|
424
|
-
/**
|
|
425
|
-
* The status of the sync (roadmap CLI-65), or undefined before the first status and after
|
|
426
|
-
* `close()`.
|
|
427
|
-
*
|
|
428
|
-
* - In the leader and in the single mode, it is the status of the `HybridStorage` of this tab.
|
|
429
|
-
* - In a follower, it is the last status that the leader sent. A new tab gets it in the answer to
|
|
430
|
-
* its hello. After a change of the leader, a follower keeps the last status until the new
|
|
431
|
-
* leader sends its status.
|
|
432
|
-
* - In a follower, `error.error` is a copy of the error of the leader: an `Error` with its `name`,
|
|
433
|
-
* its `message` and its `code`. The copy has no `cause`.
|
|
434
|
-
*/
|
|
435
1163
|
getStatus(): SyncStatus | undefined {
|
|
436
1164
|
return this.status;
|
|
437
1165
|
}
|
|
438
1166
|
|
|
439
|
-
/**
|
|
440
|
-
* This method starts to send the status of the sync to a listener at each change (roadmap
|
|
441
|
-
* CLI-65): in the leader, each status of its `HybridStorage`, and in a follower, each status that
|
|
442
|
-
* the leader sends. The listener gets each status one time. When the tab becomes the leader, the
|
|
443
|
-
* listener gets the status of its own `HybridStorage`.
|
|
444
|
-
*
|
|
445
|
-
* @returns The function that stops the statuses. A second use of it does nothing.
|
|
446
|
-
* @throws Error after `close()`
|
|
447
|
-
*/
|
|
448
1167
|
onStatus(listener: (status: SyncStatus) => void): () => void {
|
|
449
|
-
if (this.closed) throw new Error(
|
|
1168
|
+
if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
|
|
450
1169
|
return this.statusListeners.add(listener);
|
|
451
1170
|
}
|
|
452
1171
|
|
|
453
|
-
/**
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
* - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
|
|
459
|
-
* operations that the remote did not take. Then the leader releases the lock, and the next
|
|
460
|
-
* tab becomes the leader.
|
|
461
|
-
* - The coordinator closes its channel. It does not close the local copy: the application owns it.
|
|
462
|
-
* - After it, the coordinator sends and copies nothing. A second `close()` gives the promise of
|
|
463
|
-
* the first one.
|
|
464
|
-
*/
|
|
465
|
-
close(): Promise<void> {
|
|
466
|
-
if (!this.closing) this.closing = this.shutDown();
|
|
467
|
-
return this.closing;
|
|
1172
|
+
/** This method starts the collection on the channel: it says hello, and it asks for a snapshot. */
|
|
1173
|
+
begin(): void {
|
|
1174
|
+
if (this.settings.snapshot.want) this.snapshot = { request: 0, chunks: [], received: 0 };
|
|
1175
|
+
this.hello();
|
|
1176
|
+
this.requestSnapshot();
|
|
468
1177
|
}
|
|
469
1178
|
|
|
470
|
-
|
|
1179
|
+
/** This method closes the storage of the collection. The group then releases the lock and closes the channel. */
|
|
1180
|
+
async shutDown(): Promise<void> {
|
|
471
1181
|
this.closed = true;
|
|
472
1182
|
this.stopTap?.();
|
|
473
1183
|
this.stopTap = undefined;
|
|
474
|
-
this.abortRequest?.();
|
|
475
|
-
this.abortRequest = undefined;
|
|
476
1184
|
this.cancelRetry?.();
|
|
477
1185
|
this.cancelRetry = undefined;
|
|
1186
|
+
this.cancelSnapshotWait?.();
|
|
1187
|
+
this.cancelSnapshotWait = undefined;
|
|
1188
|
+
this.snapshot = undefined;
|
|
1189
|
+
this.held = [];
|
|
478
1190
|
this.stopStatus?.();
|
|
479
1191
|
this.stopStatus = undefined;
|
|
480
1192
|
this.setRole("closed");
|
|
@@ -485,74 +1197,63 @@ export class TabCoordinator<T> {
|
|
|
485
1197
|
this.hybrid = undefined;
|
|
486
1198
|
this.status = undefined;
|
|
487
1199
|
try {
|
|
488
|
-
// The leader holds the lock until its queue reached the remote or its queue store
|
|
489
1200
|
if (hybrid) await hybrid.close();
|
|
490
1201
|
} catch (error) {
|
|
491
|
-
this.onError(error);
|
|
492
|
-
} finally {
|
|
493
|
-
this.releaseLock?.();
|
|
494
|
-
this.releaseLock = undefined;
|
|
495
|
-
// The acknowledgements of the close of the HybridStorage went out before
|
|
496
|
-
try {
|
|
497
|
-
this.channel?.close();
|
|
498
|
-
} catch {
|
|
499
|
-
// The channel is closed already
|
|
500
|
-
}
|
|
501
|
-
this.channel = undefined;
|
|
502
|
-
this.observers.clear();
|
|
503
|
-
this.roleListeners.clear();
|
|
504
|
-
this.statusListeners.clear();
|
|
505
|
-
this.captures.clear();
|
|
1202
|
+
this.group.onError(error);
|
|
506
1203
|
}
|
|
507
1204
|
}
|
|
508
1205
|
|
|
1206
|
+
clearListeners(): void {
|
|
1207
|
+
this.observers.clear();
|
|
1208
|
+
this.roleListeners.clear();
|
|
1209
|
+
this.statusListeners.clear();
|
|
1210
|
+
this.captures.clear();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
509
1213
|
//
|
|
510
|
-
// ═══ The
|
|
1214
|
+
// ═══ The setup of the sync ═══
|
|
511
1215
|
//
|
|
512
1216
|
|
|
513
|
-
/**
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
request = locks.request(this.lockName, controller ? { signal: controller.signal } : {}, async () => {
|
|
522
|
-
// A lock after close(): the tab releases it at once
|
|
523
|
-
if (this.closed) return;
|
|
524
|
-
this.abortRequest = undefined;
|
|
525
|
-
if (await this.takeLead()) {
|
|
526
|
-
await held;
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
// A failed setup releases the lock: another tab tries, and this tab tries again after the backoff
|
|
530
|
-
this.retryLater();
|
|
531
|
-
});
|
|
532
|
-
} catch (error) {
|
|
533
|
-
request = Promise.reject(error);
|
|
534
|
-
}
|
|
535
|
-
request.then(undefined, (error: unknown) => {
|
|
536
|
-
// The abort of close(), or a tab that leads already
|
|
537
|
-
if (this.closed || this.hybrid) return;
|
|
538
|
-
// The lock manager refused the request: this tab runs the sync alone
|
|
539
|
-
this.onError(error);
|
|
540
|
-
this.stopChannel();
|
|
541
|
-
this.locks = undefined;
|
|
542
|
-
this.startSingle();
|
|
1217
|
+
/**
|
|
1218
|
+
* This method sets up the sync in a tab that holds the lock. After a failure, the collection tries
|
|
1219
|
+
* again after its backoff. When no collection of the group can lead, the tab releases the lock.
|
|
1220
|
+
*/
|
|
1221
|
+
startLead(lease: object, announce: boolean): void {
|
|
1222
|
+
void this.takeLead(lease, announce).then((led) => {
|
|
1223
|
+
if (!led && !this.closed && this.group.lease === lease) this.group.afterFailedSetup(this, lease);
|
|
543
1224
|
});
|
|
544
1225
|
}
|
|
545
1226
|
|
|
1227
|
+
/** This method stops the wait before the next try of the setup. */
|
|
1228
|
+
stopRetry(): void {
|
|
1229
|
+
this.cancelRetry?.();
|
|
1230
|
+
this.cancelRetry = undefined;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* This method runs after a failed setup in a tab that keeps the lock. No other tab leads the
|
|
1235
|
+
* collection, so no snapshot comes: the batches that waited reach the local copy now.
|
|
1236
|
+
*/
|
|
1237
|
+
retryInLeader(): void {
|
|
1238
|
+
this.endSnapshot();
|
|
1239
|
+
this.retryLater();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
546
1242
|
/**
|
|
547
1243
|
* This method makes the `HybridStorage` of a tab that got the lock, and waits for the load of its
|
|
548
|
-
* queue. Then the
|
|
549
|
-
*
|
|
1244
|
+
* queue. Then the collection leads. Until then, it is a follower: its own batches go to the other
|
|
1245
|
+
* tabs and to its outbox, and the capture of the storage ignores the local copy.
|
|
550
1246
|
*
|
|
551
|
-
* @
|
|
1247
|
+
* @param announce True: the collection tells the other tabs that it leads. Else the group tells it.
|
|
1248
|
+
* @returns False when the setup failed (the error went to `onError`), after `close()`, or when the tab lost the lease
|
|
552
1249
|
*/
|
|
553
|
-
|
|
1250
|
+
async takeLead(lease: object, announce: boolean): Promise<boolean> {
|
|
1251
|
+
if (this.hybrid) return true;
|
|
554
1252
|
const candidate = this.makeCandidate();
|
|
555
|
-
if (!candidate)
|
|
1253
|
+
if (!candidate) {
|
|
1254
|
+
this.reportFailure(lease);
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
556
1257
|
this.candidate = candidate;
|
|
557
1258
|
let failed = false;
|
|
558
1259
|
let failure: unknown;
|
|
@@ -566,27 +1267,50 @@ export class TabCoordinator<T> {
|
|
|
566
1267
|
// close() took the storage
|
|
567
1268
|
if (this.candidate !== candidate) return false;
|
|
568
1269
|
this.candidate = undefined;
|
|
569
|
-
if (!failed) {
|
|
570
|
-
this.promote(candidate, "leader");
|
|
1270
|
+
if (!failed && this.group.lease === lease) {
|
|
1271
|
+
this.promote(candidate, "leader", announce);
|
|
571
1272
|
return true;
|
|
572
1273
|
}
|
|
573
|
-
|
|
1274
|
+
if (failed) {
|
|
1275
|
+
this.setupFailed(failure);
|
|
1276
|
+
this.reportFailure(lease);
|
|
1277
|
+
}
|
|
574
1278
|
candidate.stop();
|
|
575
1279
|
try {
|
|
576
1280
|
await candidate.hybrid.close();
|
|
577
1281
|
} catch (error) {
|
|
578
|
-
this.onError(error);
|
|
1282
|
+
this.group.onError(error);
|
|
579
1283
|
}
|
|
580
1284
|
return false;
|
|
581
1285
|
}
|
|
582
1286
|
|
|
583
|
-
/** This method makes the `HybridStorage` of the single mode. The
|
|
584
|
-
|
|
1287
|
+
/** This method makes the `HybridStorage` of the single mode. The collection tries again after the backoff when it fails. */
|
|
1288
|
+
startSingle(): void {
|
|
1289
|
+
if (this.closed || this.hybrid) return;
|
|
1290
|
+
// No other tab sends a snapshot
|
|
1291
|
+
this.endSnapshot();
|
|
585
1292
|
const candidate = this.makeCandidate();
|
|
586
|
-
if (candidate) this.promote(candidate, "single");
|
|
1293
|
+
if (candidate) this.promote(candidate, "single", false);
|
|
587
1294
|
else this.retryLater();
|
|
588
1295
|
}
|
|
589
1296
|
|
|
1297
|
+
/** This method tries the setup again after the backoff of the collection (roadmap CLI-67). */
|
|
1298
|
+
retryLater(): void {
|
|
1299
|
+
if (this.closed) return;
|
|
1300
|
+
this.cancelRetry?.();
|
|
1301
|
+
this.failures++;
|
|
1302
|
+
const wait = backoffDelay(this.group.backoff, this.failures, this.group.random);
|
|
1303
|
+
const cancel = this.group.clock.after(wait, () => {
|
|
1304
|
+
this.cancelRetry = undefined;
|
|
1305
|
+
if (this.closed || this.hybrid) return;
|
|
1306
|
+
if (this.group.alone) this.startSingle();
|
|
1307
|
+
else if (this.group.lease) this.startLead(this.group.lease, true);
|
|
1308
|
+
});
|
|
1309
|
+
// The wait must not keep a Node process alive
|
|
1310
|
+
cancel.unref?.();
|
|
1311
|
+
this.cancelRetry = cancel;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
590
1314
|
/**
|
|
591
1315
|
* This method makes the `HybridStorage` of the sync, with its observer. Its capture ignores the
|
|
592
1316
|
* local copy until the tab leads.
|
|
@@ -599,7 +1323,7 @@ export class TabCoordinator<T> {
|
|
|
599
1323
|
try {
|
|
600
1324
|
remote = this.makeRemote();
|
|
601
1325
|
} catch (error) {
|
|
602
|
-
this.
|
|
1326
|
+
this.setupFailed(error);
|
|
603
1327
|
return undefined;
|
|
604
1328
|
}
|
|
605
1329
|
const gate = { open: false };
|
|
@@ -612,11 +1336,11 @@ export class TabCoordinator<T> {
|
|
|
612
1336
|
origin: this.tag,
|
|
613
1337
|
});
|
|
614
1338
|
} catch (error) {
|
|
615
|
-
this.
|
|
1339
|
+
this.setupFailed(error);
|
|
616
1340
|
// The remote of a storage that did not start stays open otherwise
|
|
617
1341
|
Promise.resolve()
|
|
618
1342
|
.then(() => remote.close())
|
|
619
|
-
.catch((closeError: unknown) => this.onError(closeError));
|
|
1343
|
+
.catch((closeError: unknown) => this.group.onError(closeError));
|
|
620
1344
|
return undefined;
|
|
621
1345
|
}
|
|
622
1346
|
// The observer starts at once: the other tabs get the changes of the load and of the first sync
|
|
@@ -624,13 +1348,47 @@ export class TabCoordinator<T> {
|
|
|
624
1348
|
return { hybrid, gate, stop };
|
|
625
1349
|
}
|
|
626
1350
|
|
|
1351
|
+
/** This method gives the error of a failed setup to `onError`, and keeps it for the status of the failure. */
|
|
1352
|
+
private setupFailed(error: unknown): void {
|
|
1353
|
+
this.setupError = error;
|
|
1354
|
+
this.group.onError(error);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
627
1357
|
/**
|
|
628
|
-
*
|
|
629
|
-
*
|
|
1358
|
+
* In a group, the tab that holds the lock tells the other tabs that the setup of the collection
|
|
1359
|
+
* failed (roadmap CLI-65). Its status has the error, with the operation `load`, and the other
|
|
1360
|
+
* values of the last status. Thus a follower does not keep the status of an old leader. A
|
|
1361
|
+
* `TabCoordinator` releases the lock after a failed setup, and sends no status.
|
|
630
1362
|
*/
|
|
631
|
-
private
|
|
1363
|
+
private reportFailure(lease: object): void {
|
|
1364
|
+
if (this.group.legacy || this.closed || this.group.lease !== lease) return;
|
|
1365
|
+
const last = this.status;
|
|
1366
|
+
const status: SyncStatus = {
|
|
1367
|
+
connected: false,
|
|
1368
|
+
pendingChanges: last?.pendingChanges ?? 0,
|
|
1369
|
+
syncing: false,
|
|
1370
|
+
...(last?.endpoint !== undefined && { endpoint: last.endpoint }),
|
|
1371
|
+
...(last?.lastSync !== undefined && { lastSync: last.lastSync }),
|
|
1372
|
+
...(last?.mode !== undefined && { mode: last.mode }),
|
|
1373
|
+
error: { operation: "load", error: this.setupError },
|
|
1374
|
+
};
|
|
1375
|
+
this.status = status;
|
|
1376
|
+
// The status is the status of this tab now: its number follows the last number
|
|
1377
|
+
this.statusFrom = undefined;
|
|
1378
|
+
this.statusSeq++;
|
|
1379
|
+
this.postStatus();
|
|
1380
|
+
this.statusListeners.emit(status);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* This method makes this tab the leader of the collection, or the single tab, with the storage of
|
|
1385
|
+
* a candidate. The capture of the storage gets the batches of this tab that no leader acknowledged.
|
|
1386
|
+
*/
|
|
1387
|
+
private promote(candidate: Candidate<T>, role: "leader" | "single", announce: boolean): void {
|
|
632
1388
|
const { hybrid, gate, stop } = candidate;
|
|
633
1389
|
this.hybrid = hybrid;
|
|
1390
|
+
this.cancelRetry?.();
|
|
1391
|
+
this.cancelRetry = undefined;
|
|
634
1392
|
this.failures = 0;
|
|
635
1393
|
// The capture starts and the observer of the follower stops in one step: no change of the local copy goes between them
|
|
636
1394
|
gate.open = true;
|
|
@@ -638,8 +1396,10 @@ export class TabCoordinator<T> {
|
|
|
638
1396
|
this.stopTap = stop;
|
|
639
1397
|
stopFollower?.();
|
|
640
1398
|
for (const batch of this.outbox.splice(0)) this.capture(batch.changes);
|
|
1399
|
+
// The copy of this tab is now the copy that the other tabs get: a snapshot that waits ends without entries
|
|
1400
|
+
this.endSnapshot();
|
|
641
1401
|
this.setRole(role);
|
|
642
|
-
if (
|
|
1402
|
+
if (announce) this.post({ v: PROTOCOL, type: "leader", from: this.group.id });
|
|
643
1403
|
// The status of this tab is now the status of its storage, also when it has the values of the last status of the old leader. The followers get it.
|
|
644
1404
|
this.status = undefined;
|
|
645
1405
|
this.statusFrom = undefined;
|
|
@@ -647,22 +1407,6 @@ export class TabCoordinator<T> {
|
|
|
647
1407
|
this.onOwnStatus(hybrid, hybrid.getStatus());
|
|
648
1408
|
}
|
|
649
1409
|
|
|
650
|
-
/** This method asks for the lock again, or tries the single mode again, after the backoff (roadmap CLI-67). */
|
|
651
|
-
private retryLater(): void {
|
|
652
|
-
if (this.closed) return;
|
|
653
|
-
this.failures++;
|
|
654
|
-
const wait = backoffDelay(this.backoff, this.failures, this.random);
|
|
655
|
-
const cancel = this.clock.after(wait, () => {
|
|
656
|
-
this.cancelRetry = undefined;
|
|
657
|
-
if (this.closed) return;
|
|
658
|
-
if (this.locks) this.elect(this.locks);
|
|
659
|
-
else this.startSingle();
|
|
660
|
-
});
|
|
661
|
-
// The wait must not keep a Node process alive
|
|
662
|
-
cancel.unref?.();
|
|
663
|
-
this.cancelRetry = cancel;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
1410
|
/**
|
|
667
1411
|
* The local copy for the `HybridStorage` of the leader. Its `observe()` gives the changes of the
|
|
668
1412
|
* local copy and the batches of the other tabs, so the capture queues both. It gives the changes
|
|
@@ -729,7 +1473,7 @@ export class TabCoordinator<T> {
|
|
|
729
1473
|
/** This method sends the last status of the leader, with its number. */
|
|
730
1474
|
private postStatus(): void {
|
|
731
1475
|
if (this.status === undefined) return;
|
|
732
|
-
this.post({ v: PROTOCOL, type: "status", from: this.id, seq: this.statusSeq, status: portableStatus(this.status) });
|
|
1476
|
+
this.post({ v: PROTOCOL, type: "status", from: this.group.id, seq: this.statusSeq, status: portableStatus(this.status) });
|
|
733
1477
|
}
|
|
734
1478
|
|
|
735
1479
|
/** A status of the leader. A tab that runs the sync keeps its own status. */
|
|
@@ -772,67 +1516,70 @@ export class TabCoordinator<T> {
|
|
|
772
1516
|
*/
|
|
773
1517
|
private share(changes: StorageChangeBatch<T>, keep: boolean): void {
|
|
774
1518
|
const seq = ++this.seq;
|
|
1519
|
+
if (this.snapshot) this.noteOwn(changes, seq);
|
|
775
1520
|
this.observers.emit(changes);
|
|
776
|
-
const sent = this.post({ v: PROTOCOL, type: "changes", from: this.id, seq, changes });
|
|
777
|
-
if (keep && (sent || !this.
|
|
1521
|
+
const sent = this.post({ v: PROTOCOL, type: "changes", from: this.group.id, seq, changes });
|
|
1522
|
+
if (keep && (sent || !this.group.hasChannel)) this.outbox.push({ seq, changes });
|
|
778
1523
|
}
|
|
779
1524
|
|
|
780
1525
|
/** This method tells the leader the batches of this tab that no leader acknowledged. */
|
|
781
1526
|
private hello(): void {
|
|
782
|
-
this.post({ v: PROTOCOL, type: "hello", from: this.id, seq: this.seq, pending: this.outbox });
|
|
1527
|
+
this.post({ v: PROTOCOL, type: "hello", from: this.group.id, seq: this.seq, pending: this.outbox });
|
|
783
1528
|
}
|
|
784
1529
|
|
|
785
|
-
/** @returns False when the message did not go out
|
|
1530
|
+
/** @returns False when the message did not go out */
|
|
786
1531
|
private post(message: TabMessage<T>): boolean {
|
|
787
|
-
|
|
788
|
-
if (!channel) return false;
|
|
789
|
-
try {
|
|
790
|
-
channel.postMessage(message);
|
|
791
|
-
return true;
|
|
792
|
-
} catch (error) {
|
|
793
|
-
this.onError(error);
|
|
794
|
-
return false;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
private stopChannel(): void {
|
|
799
|
-
const channel = this.channel;
|
|
800
|
-
this.channel = undefined;
|
|
801
|
-
if (!channel) return;
|
|
802
|
-
channel.onmessage = null;
|
|
803
|
-
try {
|
|
804
|
-
channel.close();
|
|
805
|
-
} catch {
|
|
806
|
-
// The channel is closed already
|
|
807
|
-
}
|
|
1532
|
+
return this.group.post(this.name, message);
|
|
808
1533
|
}
|
|
809
1534
|
|
|
810
1535
|
//
|
|
811
1536
|
// ═══ The batches of the other tabs ═══
|
|
812
1537
|
//
|
|
813
1538
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1539
|
+
/** A message of another tab for this collection. */
|
|
1540
|
+
receive(data: TabMessage<unknown>): void {
|
|
1541
|
+
if (this.closed) return;
|
|
1542
|
+
const message = data as TabMessage<T>;
|
|
1543
|
+
switch (message.type) {
|
|
817
1544
|
case "changes":
|
|
818
|
-
this.onChanges(
|
|
1545
|
+
this.onChanges(message.from, { seq: message.seq, changes: message.changes });
|
|
819
1546
|
break;
|
|
820
1547
|
case "hello":
|
|
821
|
-
this.onHello(
|
|
1548
|
+
this.onHello(message.from, message.seq, message.pending);
|
|
822
1549
|
break;
|
|
823
1550
|
case "leader":
|
|
824
|
-
|
|
825
|
-
if (this.currentRole === "follower") this.hello();
|
|
1551
|
+
this.onLeader(message.from, true);
|
|
826
1552
|
break;
|
|
827
1553
|
case "ack":
|
|
828
|
-
if (
|
|
1554
|
+
if (message.to === this.group.id) this.outbox = this.outbox.filter((batch) => batch.seq > message.seq);
|
|
829
1555
|
break;
|
|
830
1556
|
case "status":
|
|
831
|
-
this.onLeaderStatus(
|
|
1557
|
+
this.onLeaderStatus(message.from, message.seq, message.status);
|
|
1558
|
+
break;
|
|
1559
|
+
case "snapshot-request":
|
|
1560
|
+
this.sendSnapshot(message.from, message.request);
|
|
1561
|
+
break;
|
|
1562
|
+
case "snapshot":
|
|
1563
|
+
this.onSnapshot(message);
|
|
832
1564
|
break;
|
|
833
1565
|
}
|
|
834
1566
|
}
|
|
835
1567
|
|
|
1568
|
+
/**
|
|
1569
|
+
* A new leader. A follower tells it the batches that no leader acknowledged, and asks it for the
|
|
1570
|
+
* snapshot that waits.
|
|
1571
|
+
*
|
|
1572
|
+
* @param own True when the tab `from` leads this collection. False for the message of a group for
|
|
1573
|
+
* all its collections: a collection that the tab `from` announced already ignores it.
|
|
1574
|
+
*/
|
|
1575
|
+
onLeader(from: string, own: boolean): void {
|
|
1576
|
+
if (this.closed || this.currentRole !== "follower") return;
|
|
1577
|
+
if (own) this.announcedBy = from;
|
|
1578
|
+
else if (this.announcedBy === from) return;
|
|
1579
|
+
this.hello();
|
|
1580
|
+
this.requestSnapshot();
|
|
1581
|
+
}
|
|
1582
|
+
|
|
836
1583
|
/**
|
|
837
1584
|
* A batch of another tab. The leader captures it when the tab said hello to it. Each tab copies
|
|
838
1585
|
* it one time.
|
|
@@ -860,24 +1607,35 @@ export class TabCoordinator<T> {
|
|
|
860
1607
|
if (pending.length > 0) this.acknowledge(from, seq);
|
|
861
1608
|
// A new tab gets the status now. The other tabs have its number already.
|
|
862
1609
|
this.postStatus();
|
|
1610
|
+
} else if (!this.group.legacy && !this.hybrid && this.group.lease && this.status !== undefined && this.statusFrom === undefined) {
|
|
1611
|
+
// The status of a failed setup in the tab that holds the lock (roadmap CLI-65)
|
|
1612
|
+
this.postStatus();
|
|
863
1613
|
}
|
|
864
1614
|
for (const batch of pending) this.copy(from, batch);
|
|
865
1615
|
}
|
|
866
1616
|
|
|
867
1617
|
/**
|
|
868
1618
|
* This method copies a batch of another tab that this tab did not see: to the local copy of a
|
|
869
|
-
* tab, and then to the observers. The batches go in their order.
|
|
1619
|
+
* tab, and then to the observers. The batches go in their order. While a snapshot waits, the
|
|
1620
|
+
* batch waits for it.
|
|
870
1621
|
*/
|
|
871
1622
|
private copy(from: string, batch: SentBatch<T>): void {
|
|
872
1623
|
if (batch.seq <= (this.seen.get(from) ?? 0)) return;
|
|
873
1624
|
this.seen.set(from, batch.seq);
|
|
1625
|
+
if (this.snapshot) this.held.push({ from, batch });
|
|
1626
|
+
else this.apply(from, batch);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** This method writes a batch of another tab to the local copy, and then gives it to the observers. */
|
|
1630
|
+
private apply(from: string, batch: SentBatch<T>): void {
|
|
874
1631
|
this.incoming = this.incoming
|
|
875
1632
|
.then(async () => {
|
|
876
1633
|
if (this.closed) return;
|
|
877
1634
|
if (!this.shared) await this.write(batch.changes);
|
|
1635
|
+
this.applied.set(from, Math.max(this.applied.get(from) ?? 0, batch.seq));
|
|
878
1636
|
if (!this.closed) this.observers.emit(batch.changes);
|
|
879
1637
|
})
|
|
880
|
-
.catch((error: unknown) => this.onError(error));
|
|
1638
|
+
.catch((error: unknown) => this.group.onError(error));
|
|
881
1639
|
}
|
|
882
1640
|
|
|
883
1641
|
/**
|
|
@@ -909,10 +1667,170 @@ export class TabCoordinator<T> {
|
|
|
909
1667
|
const hybrid = this.hybrid;
|
|
910
1668
|
if (!hybrid) return;
|
|
911
1669
|
hybrid.whenQueued().then(
|
|
912
|
-
() => void this.post({ v: PROTOCOL, type: "ack", from: this.id, to, seq }),
|
|
913
|
-
(error: unknown) => this.onError(error),
|
|
1670
|
+
() => void this.post({ v: PROTOCOL, type: "ack", from: this.group.id, to, seq }),
|
|
1671
|
+
(error: unknown) => this.group.onError(error),
|
|
914
1672
|
);
|
|
915
1673
|
}
|
|
1674
|
+
|
|
1675
|
+
//
|
|
1676
|
+
// ═══ The snapshot for a new tab (roadmap CLI-66) ═══
|
|
1677
|
+
//
|
|
1678
|
+
|
|
1679
|
+
/** This method asks the leader for a snapshot, while the snapshot waits. A new request replaces the last one. */
|
|
1680
|
+
private requestSnapshot(): void {
|
|
1681
|
+
if (!this.snapshot) return;
|
|
1682
|
+
const request = ++this.requests;
|
|
1683
|
+
this.snapshot = { request, chunks: [], received: 0 };
|
|
1684
|
+
this.post({ v: PROTOCOL, type: "snapshot-request", from: this.group.id, request });
|
|
1685
|
+
this.waitSnapshot();
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* This method starts the wait for the next chunk again. At its end, the tab continues without the
|
|
1690
|
+
* snapshot. In the tab that holds the lock, the wait renews itself while the collection loads its
|
|
1691
|
+
* queue, at most `SNAPSHOT_RENEWALS` times: a load that hangs does not hold the batches forever.
|
|
1692
|
+
*
|
|
1693
|
+
* @param renewals The number of the renewals of the wait in the tab that holds the lock
|
|
1694
|
+
*/
|
|
1695
|
+
private waitSnapshot(renewals = 0): void {
|
|
1696
|
+
this.cancelSnapshotWait?.();
|
|
1697
|
+
const cancel = this.group.clock.after(this.settings.snapshot.timeout, () => {
|
|
1698
|
+
this.cancelSnapshotWait = undefined;
|
|
1699
|
+
if (this.closed || !this.snapshot) return;
|
|
1700
|
+
if (this.group.lease) {
|
|
1701
|
+
// This tab holds the lock and loads its queue: the snapshot ends when the collection leads
|
|
1702
|
+
if (renewals < SNAPSHOT_RENEWALS) {
|
|
1703
|
+
this.waitSnapshot(renewals + 1);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
this.group.onError(new Error(`The collection ${JSON.stringify(this.name)} did not lead in time: it continues without the snapshot`));
|
|
1707
|
+
} else {
|
|
1708
|
+
this.group.onError(new Error(`No leader sent the snapshot of the collection ${JSON.stringify(this.name)} in time`));
|
|
1709
|
+
}
|
|
1710
|
+
this.endSnapshot();
|
|
1711
|
+
});
|
|
1712
|
+
// The wait must not keep a Node process alive
|
|
1713
|
+
cancel.unref?.();
|
|
1714
|
+
this.cancelSnapshotWait = cancel;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* The leader sends the entries of its local copy to the tab `to`, in chunks. The numbers come
|
|
1719
|
+
* before the read of the entries: thus the entries hold each batch up to the numbers. A batch
|
|
1720
|
+
* after them can be in the entries too, and the new tab writes it again with the same values.
|
|
1721
|
+
*/
|
|
1722
|
+
private sendSnapshot(to: string, request: number): void {
|
|
1723
|
+
if (!this.hybrid || this.currentRole !== "leader") return;
|
|
1724
|
+
const chunk = this.settings.snapshot.chunk;
|
|
1725
|
+
// The batches of the other tabs that this tab got before the request reach the local copy first
|
|
1726
|
+
this.incoming = this.incoming
|
|
1727
|
+
.then(async () => {
|
|
1728
|
+
if (this.closed || this.currentRole !== "leader") return;
|
|
1729
|
+
const seqs: Array<[string, number]> = [[this.group.id, this.seq], ...this.applied];
|
|
1730
|
+
const entries = await this.local.getEntries();
|
|
1731
|
+
if (this.closed) return;
|
|
1732
|
+
const parts = Math.max(1, Math.ceil(entries.length / chunk));
|
|
1733
|
+
for (let part = 0; part < parts; part++) {
|
|
1734
|
+
const slice = entries.slice(part * chunk, (part + 1) * chunk);
|
|
1735
|
+
if (!this.post({ v: PROTOCOL, type: "snapshot", from: this.group.id, to, request, part, parts, seqs, entries: slice })) return;
|
|
1736
|
+
}
|
|
1737
|
+
})
|
|
1738
|
+
.catch((error: unknown) => this.group.onError(error));
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** A chunk of a snapshot. The tab keeps the chunks of its last request from one leader, and applies them after the last one. */
|
|
1742
|
+
private onSnapshot(message: SnapshotMessage<T>): void {
|
|
1743
|
+
const pending = this.snapshot;
|
|
1744
|
+
if (!pending || message.to !== this.group.id || message.request !== pending.request) return;
|
|
1745
|
+
if (pending.from === undefined) {
|
|
1746
|
+
pending.from = message.from;
|
|
1747
|
+
pending.parts = message.parts;
|
|
1748
|
+
pending.seqs = message.seqs;
|
|
1749
|
+
} else if (message.from !== pending.from || message.parts !== pending.parts) {
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (pending.chunks[message.part] !== undefined) return;
|
|
1753
|
+
pending.chunks[message.part] = message.entries;
|
|
1754
|
+
pending.received++;
|
|
1755
|
+
if (pending.received < message.parts) this.waitSnapshot();
|
|
1756
|
+
else this.applySnapshot(pending);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* This method writes the entries of a snapshot to the local copy, and then the batches that
|
|
1761
|
+
* waited. It skips the batches that the snapshot holds, and the entries of the ids that this tab
|
|
1762
|
+
* wrote after the snapshot.
|
|
1763
|
+
*/
|
|
1764
|
+
private applySnapshot(pending: PendingSnapshot<T>): void {
|
|
1765
|
+
this.snapshot = undefined;
|
|
1766
|
+
this.cancelSnapshotWait?.();
|
|
1767
|
+
this.cancelSnapshotWait = undefined;
|
|
1768
|
+
const seqs = new Map<string, number>(pending.seqs ?? []);
|
|
1769
|
+
const mine = seqs.get(this.group.id) ?? 0;
|
|
1770
|
+
const entries: Array<[string, T]> = [];
|
|
1771
|
+
if (this.ownClear <= mine) {
|
|
1772
|
+
for (const chunk of pending.chunks) {
|
|
1773
|
+
for (const [id, value] of chunk ?? []) if ((this.ownWrites.get(id) ?? 0) <= mine) entries.push([id, value]);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
this.ownWrites.clear();
|
|
1777
|
+
this.ownClear = 0;
|
|
1778
|
+
seqs.delete(this.group.id);
|
|
1779
|
+
// A batch that the snapshot holds and that comes later is not new
|
|
1780
|
+
for (const [from, seq] of seqs) if (seq > (this.seen.get(from) ?? 0)) this.seen.set(from, seq);
|
|
1781
|
+
const origin = this.hybridOptions.syncOrigin ?? "sync";
|
|
1782
|
+
this.incoming = this.incoming
|
|
1783
|
+
.then(async () => {
|
|
1784
|
+
if (this.closed) return;
|
|
1785
|
+
if (entries.length > 0) await this.local.setBatch(entries, this.writeOptions);
|
|
1786
|
+
for (const [from, seq] of seqs) this.applied.set(from, Math.max(this.applied.get(from) ?? 0, seq));
|
|
1787
|
+
if (!this.closed && entries.length > 0) this.observers.emit(entries.map(([id, value]): StorageChangeEvent<T> => ({ type: "set", id, value, origin })));
|
|
1788
|
+
})
|
|
1789
|
+
.catch((error: unknown) => this.group.onError(error));
|
|
1790
|
+
for (const { from, batch } of this.held.splice(0)) if (batch.seq > (seqs.get(from) ?? 0)) this.apply(from, batch);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/** This method ends a snapshot that waits, without entries: the batches that waited reach the local copy. */
|
|
1794
|
+
private endSnapshot(): void {
|
|
1795
|
+
if (!this.snapshot) return;
|
|
1796
|
+
this.snapshot = undefined;
|
|
1797
|
+
this.cancelSnapshotWait?.();
|
|
1798
|
+
this.cancelSnapshotWait = undefined;
|
|
1799
|
+
this.ownWrites.clear();
|
|
1800
|
+
this.ownClear = 0;
|
|
1801
|
+
for (const { from, batch } of this.held.splice(0)) this.apply(from, batch);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** This method records the ids of an own batch while the snapshot waits. */
|
|
1805
|
+
private noteOwn(changes: StorageChangeBatch<T>, seq: number): void {
|
|
1806
|
+
for (const change of changes) {
|
|
1807
|
+
if (change.type === "clear") this.ownClear = seq;
|
|
1808
|
+
else this.ownWrites.set(change.id, seq);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* The settings of the options of a collection.
|
|
1815
|
+
*
|
|
1816
|
+
* @throws TypeError for a local copy without `observe()`, an unknown `copy`, no `remote`, or a snapshot of a shared copy
|
|
1817
|
+
* @throws RangeError for a `snapshot.chunk` that is not a whole number of 1 or more
|
|
1818
|
+
*/
|
|
1819
|
+
function collectionSettings<T>(options: TabCollectionOptions<T>, owner: string): CollectionSettings {
|
|
1820
|
+
if (typeof options?.local?.observe !== "function") throw new TypeError(`${owner} needs a local copy with observe()`);
|
|
1821
|
+
if (options.copy !== "shared" && options.copy !== "tab") {
|
|
1822
|
+
throw new TypeError(`Unknown copy ${JSON.stringify(options.copy)}: use shared or tab`);
|
|
1823
|
+
}
|
|
1824
|
+
if (typeof options.remote !== "function") throw new TypeError(`${owner} needs the function remote`);
|
|
1825
|
+
const snapshot = options.snapshot ?? false;
|
|
1826
|
+
const want = snapshot !== false;
|
|
1827
|
+
if (want && options.copy !== "tab") throw new TypeError('The option snapshot needs copy: "tab": the tabs of a shared copy have the entries');
|
|
1828
|
+
const details = typeof snapshot === "object" && snapshot !== null ? snapshot : {};
|
|
1829
|
+
const chunk = details.chunk ?? SNAPSHOT_CHUNK;
|
|
1830
|
+
if (!(Number.isSafeInteger(chunk) && chunk >= 1)) throw new RangeError(`snapshot.chunk must be a whole number of 1 or more, not ${chunk}`);
|
|
1831
|
+
const timeout = optionalMs(details.timeout, "snapshot.timeout") ?? SNAPSHOT_TIMEOUT;
|
|
1832
|
+
if (!(timeout > 0)) throw new RangeError(`snapshot.timeout must be a time of more than zero, not ${timeout} ms`);
|
|
1833
|
+
return { snapshot: { want, chunk, timeout } };
|
|
916
1834
|
}
|
|
917
1835
|
|
|
918
1836
|
/**
|
|
@@ -1068,6 +1986,11 @@ function isBatch(value: unknown): value is StorageChangeEvent<unknown>[] {
|
|
|
1068
1986
|
});
|
|
1069
1987
|
}
|
|
1070
1988
|
|
|
1989
|
+
/** True when a value is a list of pairs whose first value is a string, and whose second value passes `second`. */
|
|
1990
|
+
function isPairs(value: unknown, second: (item: unknown) => boolean): boolean {
|
|
1991
|
+
return Array.isArray(value) && value.every((pair: unknown) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && second(pair[1]));
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1071
1994
|
/**
|
|
1072
1995
|
* True when a value is a message of the channel, of this version. A message of a type that this
|
|
1073
1996
|
* release does not know is not a message: a tab of an older release ignores a new type.
|
|
@@ -1079,12 +2002,19 @@ function isTabMessage<T>(value: unknown): value is TabMessage<T> {
|
|
|
1079
2002
|
type?: unknown;
|
|
1080
2003
|
from?: unknown;
|
|
1081
2004
|
to?: unknown;
|
|
2005
|
+
collection?: unknown;
|
|
1082
2006
|
seq?: unknown;
|
|
1083
2007
|
changes?: unknown;
|
|
1084
2008
|
pending?: unknown;
|
|
1085
2009
|
status?: unknown;
|
|
2010
|
+
request?: unknown;
|
|
2011
|
+
part?: unknown;
|
|
2012
|
+
parts?: unknown;
|
|
2013
|
+
seqs?: unknown;
|
|
2014
|
+
entries?: unknown;
|
|
1086
2015
|
};
|
|
1087
2016
|
if (message.v !== PROTOCOL || typeof message.from !== "string") return false;
|
|
2017
|
+
if (message.collection !== undefined && typeof message.collection !== "string") return false;
|
|
1088
2018
|
switch (message.type) {
|
|
1089
2019
|
case "changes":
|
|
1090
2020
|
return isSeq(message.seq) && isBatch(message.changes);
|
|
@@ -1104,6 +2034,19 @@ function isTabMessage<T>(value: unknown): value is TabMessage<T> {
|
|
|
1104
2034
|
return typeof message.to === "string" && isSeq(message.seq);
|
|
1105
2035
|
case "status":
|
|
1106
2036
|
return isSeq(message.seq) && isPortableStatus(message.status);
|
|
2037
|
+
case "snapshot-request":
|
|
2038
|
+
return isSeq(message.request);
|
|
2039
|
+
case "snapshot":
|
|
2040
|
+
return (
|
|
2041
|
+
typeof message.to === "string" &&
|
|
2042
|
+
isSeq(message.request) &&
|
|
2043
|
+
isSeq(message.parts) &&
|
|
2044
|
+
message.parts >= 1 &&
|
|
2045
|
+
isSeq(message.part) &&
|
|
2046
|
+
message.part < message.parts &&
|
|
2047
|
+
isPairs(message.seqs, isSeq) &&
|
|
2048
|
+
isPairs(message.entries, () => true)
|
|
2049
|
+
);
|
|
1107
2050
|
default:
|
|
1108
2051
|
return false;
|
|
1109
2052
|
}
|