@spooky-sync/core 0.0.1-canary.178 → 0.0.1-canary.179
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -4974,10 +4974,20 @@ function rowToUpEvent(r, logger) {
|
|
|
4974
4974
|
|
|
4975
4975
|
//#endregion
|
|
4976
4976
|
//#region src/modules/sync/queue/queue-down.ts
|
|
4977
|
+
/**
|
|
4978
|
+
* How many times a failing event keeps its place at the head before it is
|
|
4979
|
+
* rotated to the back. A transient failure (the SSP still bootstrapping) clears
|
|
4980
|
+
* well inside this, so ordering is preserved for the common case; a permanently
|
|
4981
|
+
* rejected event (a permission the SSP refuses to lower) stops holding every
|
|
4982
|
+
* other query hostage behind it.
|
|
4983
|
+
*/
|
|
4984
|
+
const MAX_HEAD_RETRIES = 3;
|
|
4977
4985
|
var DownQueue = class {
|
|
4978
4986
|
queue = [];
|
|
4979
4987
|
_events;
|
|
4980
4988
|
logger;
|
|
4989
|
+
/** Consecutive failures per queued event; cleared when it finally succeeds. */
|
|
4990
|
+
failures = /* @__PURE__ */ new WeakMap();
|
|
4981
4991
|
get events() {
|
|
4982
4992
|
return this._events;
|
|
4983
4993
|
}
|
|
@@ -5008,13 +5018,20 @@ var DownQueue = class {
|
|
|
5008
5018
|
const event = this.queue.shift();
|
|
5009
5019
|
if (event) try {
|
|
5010
5020
|
await fn(event);
|
|
5021
|
+
this.failures.delete(event);
|
|
5011
5022
|
} catch (error) {
|
|
5023
|
+
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
5024
|
+
this.failures.set(event, attempts);
|
|
5025
|
+
const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
|
|
5026
|
+
if (starvingOthers) this.queue.push(event);
|
|
5027
|
+
else this.queue.unshift(event);
|
|
5012
5028
|
this.logger.error({
|
|
5013
5029
|
error,
|
|
5014
5030
|
event,
|
|
5031
|
+
attempts,
|
|
5032
|
+
rotated: starvingOthers,
|
|
5015
5033
|
Category: "sp00ky-client::DownQueue::next"
|
|
5016
5034
|
}, "Failed to process query");
|
|
5017
|
-
this.queue.unshift(event);
|
|
5018
5035
|
throw error;
|
|
5019
5036
|
}
|
|
5020
5037
|
}
|
|
@@ -5378,11 +5395,18 @@ var SyncEngine = class {
|
|
|
5378
5395
|
* SyncScheduler manages when to sync: queue management and orchestration.
|
|
5379
5396
|
* Decides the order and timing of sync operations.
|
|
5380
5397
|
*/
|
|
5398
|
+
/** Backoff for re-draining a queue that halted on an error. */
|
|
5399
|
+
const RETRY_BASE_MS = 500;
|
|
5400
|
+
const RETRY_MAX_MS = 15e3;
|
|
5381
5401
|
var SyncScheduler = class {
|
|
5382
5402
|
isSyncingUp = false;
|
|
5383
5403
|
isSyncingDown = false;
|
|
5384
5404
|
paused = false;
|
|
5385
5405
|
pauseWaiters = [];
|
|
5406
|
+
upRetryTimer;
|
|
5407
|
+
downRetryTimer;
|
|
5408
|
+
upRetryAttempt = 0;
|
|
5409
|
+
downRetryAttempt = 0;
|
|
5386
5410
|
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
|
|
5387
5411
|
this.upQueue = upQueue;
|
|
5388
5412
|
this.downQueue = downQueue;
|
|
@@ -5418,12 +5442,16 @@ var SyncScheduler = class {
|
|
|
5418
5442
|
*/
|
|
5419
5443
|
pause() {
|
|
5420
5444
|
this.paused = true;
|
|
5445
|
+
this.clearRetryTimers();
|
|
5421
5446
|
if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
|
|
5422
5447
|
return new Promise((resolve) => this.pauseWaiters.push(resolve));
|
|
5423
5448
|
}
|
|
5424
5449
|
resume() {
|
|
5425
5450
|
this.paused = false;
|
|
5451
|
+
this.upRetryAttempt = 0;
|
|
5452
|
+
this.downRetryAttempt = 0;
|
|
5426
5453
|
this.syncUp();
|
|
5454
|
+
this.syncDown();
|
|
5427
5455
|
}
|
|
5428
5456
|
maybeResolvePause() {
|
|
5429
5457
|
if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
|
|
@@ -5431,6 +5459,46 @@ var SyncScheduler = class {
|
|
|
5431
5459
|
this.pauseWaiters = [];
|
|
5432
5460
|
for (const resolve of waiters) resolve();
|
|
5433
5461
|
}
|
|
5462
|
+
/** Exponential backoff, capped. Attempt 0 is the first retry. */
|
|
5463
|
+
retryDelay(attempt) {
|
|
5464
|
+
return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
|
|
5465
|
+
}
|
|
5466
|
+
scheduleUpRetry() {
|
|
5467
|
+
if (this.paused || this.upRetryTimer || this.upQueue.size === 0) return;
|
|
5468
|
+
const delay = this.retryDelay(this.upRetryAttempt++);
|
|
5469
|
+
this.upRetryTimer = setTimeout(() => {
|
|
5470
|
+
this.upRetryTimer = void 0;
|
|
5471
|
+
this.syncUp();
|
|
5472
|
+
}, delay);
|
|
5473
|
+
}
|
|
5474
|
+
/**
|
|
5475
|
+
* Re-arm the down pass. With no argument this is failure backoff and the
|
|
5476
|
+
* streak grows; with an explicit delay it is a yield (the up-queue holds the
|
|
5477
|
+
* floor), which is not a failure and must not push the backoff out.
|
|
5478
|
+
*/
|
|
5479
|
+
scheduleDownRetry(delayMs) {
|
|
5480
|
+
if (this.paused || this.downRetryTimer || this.downQueue.size === 0) return;
|
|
5481
|
+
const delay = delayMs ?? this.retryDelay(this.downRetryAttempt++);
|
|
5482
|
+
this.downRetryTimer = setTimeout(() => {
|
|
5483
|
+
this.downRetryTimer = void 0;
|
|
5484
|
+
this.syncDown();
|
|
5485
|
+
}, delay);
|
|
5486
|
+
}
|
|
5487
|
+
clearRetryTimers() {
|
|
5488
|
+
if (this.upRetryTimer) {
|
|
5489
|
+
clearTimeout(this.upRetryTimer);
|
|
5490
|
+
this.upRetryTimer = void 0;
|
|
5491
|
+
}
|
|
5492
|
+
if (this.downRetryTimer) {
|
|
5493
|
+
clearTimeout(this.downRetryTimer);
|
|
5494
|
+
this.downRetryTimer = void 0;
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
/** Stop all pending retries. Call when tearing the client down. */
|
|
5498
|
+
dispose() {
|
|
5499
|
+
this.paused = true;
|
|
5500
|
+
this.clearRetryTimers();
|
|
5501
|
+
}
|
|
5434
5502
|
/**
|
|
5435
5503
|
* Process upload queue
|
|
5436
5504
|
*/
|
|
@@ -5444,8 +5512,10 @@ var SyncScheduler = class {
|
|
|
5444
5512
|
processedAny = true;
|
|
5445
5513
|
}
|
|
5446
5514
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5515
|
+
this.upRetryAttempt = 0;
|
|
5447
5516
|
} catch (error) {
|
|
5448
5517
|
this.onSyncOutcome?.(false, error);
|
|
5518
|
+
this.scheduleUpRetry();
|
|
5449
5519
|
this.logger.debug({
|
|
5450
5520
|
error,
|
|
5451
5521
|
Category: "sp00ky-client::SyncScheduler::syncUp"
|
|
@@ -5461,7 +5531,10 @@ var SyncScheduler = class {
|
|
|
5461
5531
|
*/
|
|
5462
5532
|
async syncDown() {
|
|
5463
5533
|
if (this.isSyncingDown || this.paused) return;
|
|
5464
|
-
if (this.upQueue.size > 0)
|
|
5534
|
+
if (this.upQueue.size > 0) {
|
|
5535
|
+
this.scheduleDownRetry(RETRY_BASE_MS);
|
|
5536
|
+
return;
|
|
5537
|
+
}
|
|
5465
5538
|
this.isSyncingDown = true;
|
|
5466
5539
|
let processedAny = false;
|
|
5467
5540
|
try {
|
|
@@ -5471,8 +5544,10 @@ var SyncScheduler = class {
|
|
|
5471
5544
|
processedAny = true;
|
|
5472
5545
|
}
|
|
5473
5546
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5547
|
+
this.downRetryAttempt = 0;
|
|
5474
5548
|
} catch (error) {
|
|
5475
5549
|
this.onSyncOutcome?.(false, error);
|
|
5550
|
+
this.scheduleDownRetry();
|
|
5476
5551
|
this.logger.debug({
|
|
5477
5552
|
error,
|
|
5478
5553
|
Category: "sp00ky-client::SyncScheduler::syncDown"
|
|
@@ -6928,8 +7003,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
6928
7003
|
|
|
6929
7004
|
//#endregion
|
|
6930
7005
|
//#region src/modules/devtools/index.ts
|
|
6931
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6932
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7006
|
+
const CORE_VERSION = "0.0.1-canary.179";
|
|
7007
|
+
const WASM_VERSION = "0.0.1-canary.179";
|
|
6933
7008
|
const SURREAL_VERSION = "3.0.3";
|
|
6934
7009
|
var DevToolsService = class DevToolsService {
|
|
6935
7010
|
eventsHistory = [];
|
|
@@ -11360,7 +11435,7 @@ var Sp00kyClient = class {
|
|
|
11360
11435
|
return new TabsCoordinator({
|
|
11361
11436
|
tabId,
|
|
11362
11437
|
fingerprint: computeTabsFingerprint({
|
|
11363
|
-
coreVersion: "0.0.1-canary.
|
|
11438
|
+
coreVersion: "0.0.1-canary.179",
|
|
11364
11439
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11365
11440
|
endpoint: this.config.database.endpoint ?? "",
|
|
11366
11441
|
namespace: this.config.database.namespace,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.179",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.179",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.179",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"fast-json-patch": "^3.1.1",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DownQueue } from './queue-down';
|
|
3
|
+
import type { DownEvent } from './queue-down';
|
|
4
|
+
import type { LocalStore } from '../../../services/database/index';
|
|
5
|
+
|
|
6
|
+
const silentLogger = {
|
|
7
|
+
child: () => silentLogger,
|
|
8
|
+
debug: () => {},
|
|
9
|
+
info: () => {},
|
|
10
|
+
warn: () => {},
|
|
11
|
+
error: () => {},
|
|
12
|
+
} as any;
|
|
13
|
+
|
|
14
|
+
const register = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
|
|
15
|
+
|
|
16
|
+
const hashOf = (e: DownEvent) => e.payload.hash;
|
|
17
|
+
|
|
18
|
+
function makeQueue() {
|
|
19
|
+
return new DownQueue({} as LocalStore, silentLogger);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('DownQueue.next failure handling', () => {
|
|
23
|
+
it('re-heads a failing event so a transient failure keeps its order', async () => {
|
|
24
|
+
const q = makeQueue();
|
|
25
|
+
q.push(register('a'));
|
|
26
|
+
q.push(register('b'));
|
|
27
|
+
|
|
28
|
+
const seen: string[] = [];
|
|
29
|
+
await expect(
|
|
30
|
+
q.next(async (e) => {
|
|
31
|
+
seen.push(hashOf(e));
|
|
32
|
+
throw new Error('503');
|
|
33
|
+
})
|
|
34
|
+
).rejects.toThrow('503');
|
|
35
|
+
|
|
36
|
+
expect(seen).toEqual(['a']);
|
|
37
|
+
// 'a' is back at the head, ahead of 'b'.
|
|
38
|
+
await expect(q.next(async (e) => { seen.push(hashOf(e)); throw new Error('503'); })).rejects.toThrow();
|
|
39
|
+
expect(seen).toEqual(['a', 'a']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rotates a persistently failing event to the back so others can proceed', async () => {
|
|
43
|
+
const q = makeQueue();
|
|
44
|
+
q.push(register('poison'));
|
|
45
|
+
q.push(register('healthy'));
|
|
46
|
+
|
|
47
|
+
const seen: string[] = [];
|
|
48
|
+
const failPoison = async (e: DownEvent) => {
|
|
49
|
+
seen.push(hashOf(e));
|
|
50
|
+
if (hashOf(e) === 'poison') throw new Error('400 rejected');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Three attempts keep the head; the third rotates it behind 'healthy'.
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
await expect(q.next(failPoison)).rejects.toThrow();
|
|
56
|
+
}
|
|
57
|
+
expect(seen).toEqual(['poison', 'poison', 'poison']);
|
|
58
|
+
|
|
59
|
+
// 'healthy' is no longer starved.
|
|
60
|
+
await q.next(failPoison);
|
|
61
|
+
expect(seen).toEqual(['poison', 'poison', 'poison', 'healthy']);
|
|
62
|
+
expect(q.size).toBe(1); // only the poison event remains
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not rotate when nothing else is waiting', async () => {
|
|
66
|
+
const q = makeQueue();
|
|
67
|
+
q.push(register('only'));
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < 5; i++) {
|
|
70
|
+
await expect(
|
|
71
|
+
q.next(async () => {
|
|
72
|
+
throw new Error('boom');
|
|
73
|
+
})
|
|
74
|
+
).rejects.toThrow();
|
|
75
|
+
}
|
|
76
|
+
expect(q.size).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('clears the failure count once an event succeeds', async () => {
|
|
80
|
+
const q = makeQueue();
|
|
81
|
+
const event = register('a');
|
|
82
|
+
q.push(event);
|
|
83
|
+
q.push(register('b'));
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
q.next(async () => {
|
|
87
|
+
throw new Error('boom');
|
|
88
|
+
})
|
|
89
|
+
).rejects.toThrow();
|
|
90
|
+
// Succeeds on the retry, so its streak resets rather than carrying over.
|
|
91
|
+
await q.next(async () => {});
|
|
92
|
+
expect(q.size).toBe(1);
|
|
93
|
+
|
|
94
|
+
q.push(event);
|
|
95
|
+
// A fresh streak: it keeps the head again rather than rotating immediately.
|
|
96
|
+
await expect(
|
|
97
|
+
q.next(async () => {
|
|
98
|
+
throw new Error('boom');
|
|
99
|
+
})
|
|
100
|
+
).rejects.toThrow();
|
|
101
|
+
const drained: string[] = [];
|
|
102
|
+
await q.next(async (e) => {
|
|
103
|
+
drained.push(hashOf(e));
|
|
104
|
+
});
|
|
105
|
+
expect(drained).toEqual(['b']);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -37,10 +37,21 @@ export type CleanupEvent = {
|
|
|
37
37
|
|
|
38
38
|
export type DownEvent = RegisterEvent | SyncEvent | HeartbeatEvent | CleanupEvent;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* How many times a failing event keeps its place at the head before it is
|
|
42
|
+
* rotated to the back. A transient failure (the SSP still bootstrapping) clears
|
|
43
|
+
* well inside this, so ordering is preserved for the common case; a permanently
|
|
44
|
+
* rejected event (a permission the SSP refuses to lower) stops holding every
|
|
45
|
+
* other query hostage behind it.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_HEAD_RETRIES = 3;
|
|
48
|
+
|
|
40
49
|
export class DownQueue {
|
|
41
50
|
private queue: DownEvent[] = [];
|
|
42
51
|
private _events: SyncQueueEventSystem;
|
|
43
52
|
private logger: Logger;
|
|
53
|
+
/** Consecutive failures per queued event; cleared when it finally succeeds. */
|
|
54
|
+
private failures = new WeakMap<DownEvent, number>();
|
|
44
55
|
|
|
45
56
|
get events(): SyncQueueEventSystem {
|
|
46
57
|
return this._events;
|
|
@@ -83,12 +94,23 @@ export class DownQueue {
|
|
|
83
94
|
if (event) {
|
|
84
95
|
try {
|
|
85
96
|
await fn(event);
|
|
97
|
+
this.failures.delete(event);
|
|
86
98
|
} catch (error) {
|
|
99
|
+
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
100
|
+
this.failures.set(event, attempts);
|
|
101
|
+
// Re-head so a transient failure keeps its ordering, but give up the
|
|
102
|
+
// head once it looks permanent and there is other work waiting — one
|
|
103
|
+
// unregisterable query must not stall every other query's registration.
|
|
104
|
+
const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
|
|
105
|
+
if (starvingOthers) {
|
|
106
|
+
this.queue.push(event);
|
|
107
|
+
} else {
|
|
108
|
+
this.queue.unshift(event);
|
|
109
|
+
}
|
|
87
110
|
this.logger.error(
|
|
88
|
-
{ error, event, Category: 'sp00ky-client::DownQueue::next' },
|
|
111
|
+
{ error, event, attempts, rotated: starvingOthers, Category: 'sp00ky-client::DownQueue::next' },
|
|
89
112
|
'Failed to process query'
|
|
90
113
|
);
|
|
91
|
-
this.queue.unshift(event);
|
|
92
114
|
throw error;
|
|
93
115
|
}
|
|
94
116
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { SyncScheduler } from './scheduler';
|
|
3
|
+
import type { UpQueue, DownQueue, DownEvent, UpEvent } from './queue/index';
|
|
4
|
+
|
|
5
|
+
// A queue whose drain throws re-queues the failing item at the HEAD and stops
|
|
6
|
+
// the pass (see DownQueue.next). Nothing used to re-arm it: the queues only
|
|
7
|
+
// moved on a fresh enqueue, so one transient failure — canonically the SSP
|
|
8
|
+
// answering 503 NOT_READY for the whole of its bootstrap window — parked every
|
|
9
|
+
// pending `register` forever, and the `useQuery` waiting on it never left its
|
|
10
|
+
// loading state. These cover the backoff that makes that self-heal.
|
|
11
|
+
|
|
12
|
+
const silentLogger = {
|
|
13
|
+
child: () => silentLogger,
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
} as any;
|
|
19
|
+
|
|
20
|
+
/** A queue that mirrors the real ones: a throwing handler re-heads the item. */
|
|
21
|
+
function makeQueue<E>(items: E[]) {
|
|
22
|
+
return {
|
|
23
|
+
queue: [...items],
|
|
24
|
+
get size() {
|
|
25
|
+
return this.queue.length;
|
|
26
|
+
},
|
|
27
|
+
events: { subscribe: () => {} },
|
|
28
|
+
loadFromDatabase: async () => {},
|
|
29
|
+
clear() {
|
|
30
|
+
this.queue = [];
|
|
31
|
+
},
|
|
32
|
+
async next(fn: (event: E) => Promise<void>) {
|
|
33
|
+
const event = this.queue.shift();
|
|
34
|
+
if (!event) return;
|
|
35
|
+
try {
|
|
36
|
+
await fn(event);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
this.queue.unshift(event);
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const downEvent = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
|
|
46
|
+
const upEvent = (n: number) => ({ type: 'delete', mutation_id: n, record_id: n }) as unknown as UpEvent;
|
|
47
|
+
|
|
48
|
+
describe('SyncScheduler retry', () => {
|
|
49
|
+
beforeEach(() => vi.useFakeTimers());
|
|
50
|
+
afterEach(() => vi.useRealTimers());
|
|
51
|
+
|
|
52
|
+
it('re-drains a failed down event instead of parking it forever', async () => {
|
|
53
|
+
const downQueue = makeQueue([downEvent('q1')]);
|
|
54
|
+
const upQueue = makeQueue<UpEvent>([]);
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
const scheduler = new SyncScheduler(
|
|
57
|
+
upQueue as unknown as UpQueue,
|
|
58
|
+
downQueue as unknown as DownQueue,
|
|
59
|
+
async () => {},
|
|
60
|
+
async () => {
|
|
61
|
+
attempts++;
|
|
62
|
+
// Fail the way a bootstrapping SSP does, then succeed.
|
|
63
|
+
if (attempts < 3) throw new Error('503 NOT_READY');
|
|
64
|
+
},
|
|
65
|
+
silentLogger
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await scheduler.syncDown();
|
|
69
|
+
expect(attempts).toBe(1);
|
|
70
|
+
expect(downQueue.size).toBe(1); // re-headed, not dropped
|
|
71
|
+
|
|
72
|
+
// Backoff: 500ms, then 1000ms.
|
|
73
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
74
|
+
expect(attempts).toBe(2);
|
|
75
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
76
|
+
expect(attempts).toBe(3);
|
|
77
|
+
|
|
78
|
+
expect(downQueue.size).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('stops retrying once the queue drains', async () => {
|
|
82
|
+
const downQueue = makeQueue([downEvent('q1')]);
|
|
83
|
+
const upQueue = makeQueue<UpEvent>([]);
|
|
84
|
+
let attempts = 0;
|
|
85
|
+
const scheduler = new SyncScheduler(
|
|
86
|
+
upQueue as unknown as UpQueue,
|
|
87
|
+
downQueue as unknown as DownQueue,
|
|
88
|
+
async () => {},
|
|
89
|
+
async () => {
|
|
90
|
+
attempts++;
|
|
91
|
+
throw new Error('boom');
|
|
92
|
+
},
|
|
93
|
+
silentLogger
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await scheduler.syncDown();
|
|
97
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
98
|
+
expect(attempts).toBe(2);
|
|
99
|
+
|
|
100
|
+
// Drain it out from under the scheduler; the next retry finds nothing and
|
|
101
|
+
// schedules no further work.
|
|
102
|
+
downQueue.clear();
|
|
103
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
104
|
+
expect(attempts).toBe(2);
|
|
105
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
106
|
+
expect(attempts).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('comes back for the down queue while the up queue holds the floor', async () => {
|
|
110
|
+
const downQueue = makeQueue([downEvent('q1')]);
|
|
111
|
+
const upQueue = makeQueue<UpEvent>([upEvent(1)]);
|
|
112
|
+
let downAttempts = 0;
|
|
113
|
+
const scheduler = new SyncScheduler(
|
|
114
|
+
upQueue as unknown as UpQueue,
|
|
115
|
+
downQueue as unknown as DownQueue,
|
|
116
|
+
async () => {},
|
|
117
|
+
async () => {
|
|
118
|
+
downAttempts++;
|
|
119
|
+
},
|
|
120
|
+
silentLogger
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Yields to the non-empty up queue.
|
|
124
|
+
await scheduler.syncDown();
|
|
125
|
+
expect(downAttempts).toBe(0);
|
|
126
|
+
|
|
127
|
+
// Once the up queue empties, the re-armed pass picks the down event up
|
|
128
|
+
// without needing a fresh enqueue.
|
|
129
|
+
upQueue.clear();
|
|
130
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
131
|
+
expect(downAttempts).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('pause cancels pending retries', async () => {
|
|
135
|
+
const downQueue = makeQueue([downEvent('q1')]);
|
|
136
|
+
const upQueue = makeQueue<UpEvent>([]);
|
|
137
|
+
let attempts = 0;
|
|
138
|
+
const scheduler = new SyncScheduler(
|
|
139
|
+
upQueue as unknown as UpQueue,
|
|
140
|
+
downQueue as unknown as DownQueue,
|
|
141
|
+
async () => {},
|
|
142
|
+
async () => {
|
|
143
|
+
attempts++;
|
|
144
|
+
throw new Error('boom');
|
|
145
|
+
},
|
|
146
|
+
silentLogger
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await scheduler.syncDown();
|
|
150
|
+
expect(attempts).toBe(1);
|
|
151
|
+
|
|
152
|
+
await scheduler.pause();
|
|
153
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
154
|
+
expect(attempts).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -6,11 +6,25 @@ import { SyncQueueEventTypes } from './events/index';
|
|
|
6
6
|
* SyncScheduler manages when to sync: queue management and orchestration.
|
|
7
7
|
* Decides the order and timing of sync operations.
|
|
8
8
|
*/
|
|
9
|
+
/** Backoff for re-draining a queue that halted on an error. */
|
|
10
|
+
const RETRY_BASE_MS = 500;
|
|
11
|
+
const RETRY_MAX_MS = 15_000;
|
|
12
|
+
|
|
9
13
|
export class SyncScheduler {
|
|
10
14
|
private isSyncingUp: boolean = false;
|
|
11
15
|
private isSyncingDown: boolean = false;
|
|
12
16
|
private paused: boolean = false;
|
|
13
17
|
private pauseWaiters: Array<() => void> = [];
|
|
18
|
+
// A failed drain re-queues its item at the HEAD (see DownQueue.next) and
|
|
19
|
+
// stops the pass. Without a timer nothing ever drains it again: the queues
|
|
20
|
+
// only move on a fresh enqueue, so a transient failure — canonically the
|
|
21
|
+
// SSP answering 503 NOT_READY for the whole of its bootstrap window — left
|
|
22
|
+
// every pending `register` parked forever and its `useQuery` loading forever.
|
|
23
|
+
// Retry on a backoff so that heals itself instead of needing a reload.
|
|
24
|
+
private upRetryTimer?: ReturnType<typeof setTimeout>;
|
|
25
|
+
private downRetryTimer?: ReturnType<typeof setTimeout>;
|
|
26
|
+
private upRetryAttempt = 0;
|
|
27
|
+
private downRetryAttempt = 0;
|
|
14
28
|
|
|
15
29
|
constructor(
|
|
16
30
|
private upQueue: UpQueue,
|
|
@@ -62,13 +76,18 @@ export class SyncScheduler {
|
|
|
62
76
|
*/
|
|
63
77
|
pause(): Promise<void> {
|
|
64
78
|
this.paused = true;
|
|
79
|
+
this.clearRetryTimers();
|
|
65
80
|
if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
|
|
66
81
|
return new Promise<void>((resolve) => this.pauseWaiters.push(resolve));
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
resume(): void {
|
|
70
85
|
this.paused = false;
|
|
86
|
+
// A resume is a fresh start, not a continuation of the failing streak.
|
|
87
|
+
this.upRetryAttempt = 0;
|
|
88
|
+
this.downRetryAttempt = 0;
|
|
71
89
|
void this.syncUp();
|
|
90
|
+
void this.syncDown();
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
private maybeResolvePause() {
|
|
@@ -78,6 +97,51 @@ export class SyncScheduler {
|
|
|
78
97
|
for (const resolve of waiters) resolve();
|
|
79
98
|
}
|
|
80
99
|
|
|
100
|
+
/** Exponential backoff, capped. Attempt 0 is the first retry. */
|
|
101
|
+
private retryDelay(attempt: number): number {
|
|
102
|
+
return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private scheduleUpRetry() {
|
|
106
|
+
if (this.paused || this.upRetryTimer || this.upQueue.size === 0) return;
|
|
107
|
+
const delay = this.retryDelay(this.upRetryAttempt++);
|
|
108
|
+
this.upRetryTimer = setTimeout(() => {
|
|
109
|
+
this.upRetryTimer = undefined;
|
|
110
|
+
void this.syncUp();
|
|
111
|
+
}, delay);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Re-arm the down pass. With no argument this is failure backoff and the
|
|
116
|
+
* streak grows; with an explicit delay it is a yield (the up-queue holds the
|
|
117
|
+
* floor), which is not a failure and must not push the backoff out.
|
|
118
|
+
*/
|
|
119
|
+
private scheduleDownRetry(delayMs?: number) {
|
|
120
|
+
if (this.paused || this.downRetryTimer || this.downQueue.size === 0) return;
|
|
121
|
+
const delay = delayMs ?? this.retryDelay(this.downRetryAttempt++);
|
|
122
|
+
this.downRetryTimer = setTimeout(() => {
|
|
123
|
+
this.downRetryTimer = undefined;
|
|
124
|
+
void this.syncDown();
|
|
125
|
+
}, delay);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private clearRetryTimers() {
|
|
129
|
+
if (this.upRetryTimer) {
|
|
130
|
+
clearTimeout(this.upRetryTimer);
|
|
131
|
+
this.upRetryTimer = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (this.downRetryTimer) {
|
|
134
|
+
clearTimeout(this.downRetryTimer);
|
|
135
|
+
this.downRetryTimer = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stop all pending retries. Call when tearing the client down. */
|
|
140
|
+
dispose(): void {
|
|
141
|
+
this.paused = true;
|
|
142
|
+
this.clearRetryTimers();
|
|
143
|
+
}
|
|
144
|
+
|
|
81
145
|
/**
|
|
82
146
|
* Process upload queue
|
|
83
147
|
*/
|
|
@@ -91,8 +155,10 @@ export class SyncScheduler {
|
|
|
91
155
|
processedAny = true;
|
|
92
156
|
}
|
|
93
157
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
158
|
+
this.upRetryAttempt = 0;
|
|
94
159
|
} catch (error) {
|
|
95
160
|
this.onSyncOutcome?.(false, error);
|
|
161
|
+
this.scheduleUpRetry();
|
|
96
162
|
// syncUp runs fire-and-forget — it's wired to the MutationEnqueued event
|
|
97
163
|
// (broadcast synchronously, return value dropped) and is also kicked off
|
|
98
164
|
// via `void this.syncDown()` below. A rejection escaping here therefore
|
|
@@ -116,7 +182,15 @@ export class SyncScheduler {
|
|
|
116
182
|
*/
|
|
117
183
|
async syncDown() {
|
|
118
184
|
if (this.isSyncingDown || this.paused) return;
|
|
119
|
-
|
|
185
|
+
// Down-sync yields to a non-empty up-queue so a register never races ahead
|
|
186
|
+
// of the mutation it should observe. That yield used to be permanent: if
|
|
187
|
+
// the up-queue never drained, nothing re-armed the down pass. Come back on
|
|
188
|
+
// the backoff instead, so a wedged push delays reads rather than killing
|
|
189
|
+
// them.
|
|
190
|
+
if (this.upQueue.size > 0) {
|
|
191
|
+
this.scheduleDownRetry(RETRY_BASE_MS);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
120
194
|
|
|
121
195
|
this.isSyncingDown = true;
|
|
122
196
|
let processedAny = false;
|
|
@@ -127,8 +201,10 @@ export class SyncScheduler {
|
|
|
127
201
|
processedAny = true;
|
|
128
202
|
}
|
|
129
203
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
204
|
+
this.downRetryAttempt = 0;
|
|
130
205
|
} catch (error) {
|
|
131
206
|
this.onSyncOutcome?.(false, error);
|
|
207
|
+
this.scheduleDownRetry();
|
|
132
208
|
// Same fire-and-forget story as syncUp: this is the QueryItemEnqueued
|
|
133
209
|
// subscriber (and is also called via `void this.syncDown()`), so a thrown
|
|
134
210
|
// error here becomes an unhandled rejection. The canonical case is a
|