@spooky-sync/core 0.0.1-canary.177 → 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 +130 -19
- package/package.json +3 -3
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +24 -2
- package/src/modules/sync/scheduler.retry.test.ts +156 -0
- package/src/modules/sync/scheduler.ts +77 -1
- package/src/services/database/sqlite-cache-engine.test.ts +94 -0
- package/src/services/database/sqlite-cache-engine.ts +62 -4
- package/src/services/database/sqlite-transport.ts +15 -13
package/dist/index.js
CHANGED
|
@@ -1998,16 +1998,16 @@ var BaseTransport = class {
|
|
|
1998
1998
|
}
|
|
1999
1999
|
});
|
|
2000
2000
|
}
|
|
2001
|
-
failAll(reason) {
|
|
2001
|
+
failAll(reason, err) {
|
|
2002
2002
|
if (this.pending.size === 0) return;
|
|
2003
|
-
const
|
|
2004
|
-
for (const [, p] of this.pending) p.reject(
|
|
2003
|
+
const e = err ?? this.makeError(reason);
|
|
2004
|
+
for (const [, p] of this.pending) p.reject(e);
|
|
2005
2005
|
this.pending.clear();
|
|
2006
2006
|
}
|
|
2007
|
-
close(reason = "closed") {
|
|
2007
|
+
close(reason = "closed", err) {
|
|
2008
2008
|
if (this.closed) return;
|
|
2009
2009
|
this.closed = true;
|
|
2010
|
-
this.failAll(reason);
|
|
2010
|
+
this.failAll(reason, err);
|
|
2011
2011
|
}
|
|
2012
2012
|
};
|
|
2013
2013
|
var WorkerSqliteTransport = class extends BaseTransport {
|
|
@@ -2071,9 +2071,9 @@ var WorkerSqliteTransport = class extends BaseTransport {
|
|
|
2071
2071
|
shutdown() {
|
|
2072
2072
|
return this.call("shutdown").then(() => void 0);
|
|
2073
2073
|
}
|
|
2074
|
-
close(reason = "closed") {
|
|
2074
|
+
close(reason = "closed", err) {
|
|
2075
2075
|
if (this.closed) return;
|
|
2076
|
-
super.close(reason);
|
|
2076
|
+
super.close(reason, err);
|
|
2077
2077
|
this.worker.terminate();
|
|
2078
2078
|
}
|
|
2079
2079
|
};
|
|
@@ -2092,10 +2092,10 @@ var PortSqliteTransport = class extends BaseTransport {
|
|
|
2092
2092
|
markDead(reason) {
|
|
2093
2093
|
this.dead(reason);
|
|
2094
2094
|
}
|
|
2095
|
-
dead(reason) {
|
|
2095
|
+
dead(reason, err) {
|
|
2096
2096
|
if (this.closed) return;
|
|
2097
2097
|
this.closed = true;
|
|
2098
|
-
this.failAll(reason);
|
|
2098
|
+
this.failAll(reason, err);
|
|
2099
2099
|
try {
|
|
2100
2100
|
this.port.close();
|
|
2101
2101
|
} catch {}
|
|
@@ -2107,8 +2107,8 @@ var PortSqliteTransport = class extends BaseTransport {
|
|
|
2107
2107
|
makeError(reason) {
|
|
2108
2108
|
return new BrokerPortClosedError(reason);
|
|
2109
2109
|
}
|
|
2110
|
-
close(reason = "closed") {
|
|
2111
|
-
this.dead(reason);
|
|
2110
|
+
close(reason = "closed", err) {
|
|
2111
|
+
this.dead(reason, err);
|
|
2112
2112
|
}
|
|
2113
2113
|
};
|
|
2114
2114
|
|
|
@@ -2304,6 +2304,28 @@ var SqliteCacheEngine = class {
|
|
|
2304
2304
|
this.opQueue = result.then(() => void 0, () => void 0);
|
|
2305
2305
|
return result;
|
|
2306
2306
|
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Ops dispatched to the worker and not yet answered. Role transitions run on
|
|
2309
|
+
* their own chain (see {@link transitionChain}), so they can start while the
|
|
2310
|
+
* opQueue still has an op at the worker; tearing the transport down under it
|
|
2311
|
+
* would reject that op — and its caller may be a query whose only fetch this
|
|
2312
|
+
* was. {@link drainInFlight} lets a deliberate teardown wait them out.
|
|
2313
|
+
*/
|
|
2314
|
+
inFlightCalls = /* @__PURE__ */ new Set();
|
|
2315
|
+
/** Wait for dispatched ops to answer before a deliberate transport teardown.
|
|
2316
|
+
* Bounded: a wedged worker must not block the role change forever. */
|
|
2317
|
+
async drainInFlight(timeoutMs = 2e3) {
|
|
2318
|
+
if (this.inFlightCalls.size === 0) return;
|
|
2319
|
+
const settled = Promise.all([...this.inFlightCalls].map((p) => p.catch(() => void 0)));
|
|
2320
|
+
let timer;
|
|
2321
|
+
try {
|
|
2322
|
+
await Promise.race([settled, new Promise((resolve) => {
|
|
2323
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
2324
|
+
})]);
|
|
2325
|
+
} finally {
|
|
2326
|
+
if (timer) clearTimeout(timer);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2307
2329
|
rawCall(type, payload) {
|
|
2308
2330
|
if (!this.transport) throw new Error("SqliteCacheEngine: not connected");
|
|
2309
2331
|
const s = getStats();
|
|
@@ -2317,7 +2339,17 @@ var SqliteCacheEngine = class {
|
|
|
2317
2339
|
s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
|
|
2318
2340
|
if (this.transport.kind === "port") s.proxiedOps = (s.proxiedOps ?? 0) + 1;
|
|
2319
2341
|
const sentAt = performance.now();
|
|
2342
|
+
let settle;
|
|
2343
|
+
const tracked = new Promise((res) => {
|
|
2344
|
+
settle = res;
|
|
2345
|
+
});
|
|
2346
|
+
this.inFlightCalls.add(tracked);
|
|
2347
|
+
const finish = () => {
|
|
2348
|
+
this.inFlightCalls.delete(tracked);
|
|
2349
|
+
settle();
|
|
2350
|
+
};
|
|
2320
2351
|
return this.transport.call(type, payload).then((v) => {
|
|
2352
|
+
finish();
|
|
2321
2353
|
s.inFlight--;
|
|
2322
2354
|
const wt = v?.wt;
|
|
2323
2355
|
if (typeof wt === "number") {
|
|
@@ -2326,6 +2358,7 @@ var SqliteCacheEngine = class {
|
|
|
2326
2358
|
}
|
|
2327
2359
|
return v;
|
|
2328
2360
|
}, (e) => {
|
|
2361
|
+
finish();
|
|
2329
2362
|
s.inFlight--;
|
|
2330
2363
|
throw e;
|
|
2331
2364
|
});
|
|
@@ -2471,12 +2504,13 @@ var SqliteCacheEngine = class {
|
|
|
2471
2504
|
this.closeRoleGate();
|
|
2472
2505
|
return this.storageHealthValue;
|
|
2473
2506
|
}
|
|
2507
|
+
if (this.transport) await this.drainInFlight();
|
|
2474
2508
|
if (this.hadStore) this.storeEpoch++;
|
|
2475
2509
|
if (this.transport) {
|
|
2476
2510
|
try {
|
|
2477
2511
|
if (this.transport.kind === "worker") await this.rawCall("close");
|
|
2478
2512
|
} catch {}
|
|
2479
|
-
this.transport.close("adopting ownership");
|
|
2513
|
+
this.transport.close("adopting ownership", new BrokerPortClosedError("adopting ownership"));
|
|
2480
2514
|
this.transport = null;
|
|
2481
2515
|
}
|
|
2482
2516
|
await this.openInternal(bucketId, {
|
|
@@ -2499,8 +2533,9 @@ var SqliteCacheEngine = class {
|
|
|
2499
2533
|
async adoptAttached(dbPort, snapshot, onPortDead) {
|
|
2500
2534
|
this.roleLabel = "follower";
|
|
2501
2535
|
return this.chainTransition(async () => {
|
|
2536
|
+
if (this.transport) await this.drainInFlight();
|
|
2502
2537
|
if (this.hadStore) this.storeEpoch++;
|
|
2503
|
-
this.transport?.close("adopting leader port");
|
|
2538
|
+
this.transport?.close("adopting leader port", new BrokerPortClosedError("adopting leader port"));
|
|
2504
2539
|
this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
|
|
2505
2540
|
this.bucketId = snapshot.bucketId;
|
|
2506
2541
|
this.knownTables.clear();
|
|
@@ -2523,10 +2558,11 @@ var SqliteCacheEngine = class {
|
|
|
2523
2558
|
async releaseOwnership() {
|
|
2524
2559
|
await this.chainTransition(async () => {
|
|
2525
2560
|
if (this.transport) {
|
|
2561
|
+
await this.drainInFlight();
|
|
2526
2562
|
try {
|
|
2527
2563
|
if (this.transport.kind === "worker") await this.transport.shutdown();
|
|
2528
2564
|
} catch {}
|
|
2529
|
-
this.transport.close("ownership released");
|
|
2565
|
+
this.transport.close("ownership released", new BrokerPortClosedError("ownership released"));
|
|
2530
2566
|
this.transport = null;
|
|
2531
2567
|
}
|
|
2532
2568
|
this.storeEpoch++;
|
|
@@ -4938,10 +4974,20 @@ function rowToUpEvent(r, logger) {
|
|
|
4938
4974
|
|
|
4939
4975
|
//#endregion
|
|
4940
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;
|
|
4941
4985
|
var DownQueue = class {
|
|
4942
4986
|
queue = [];
|
|
4943
4987
|
_events;
|
|
4944
4988
|
logger;
|
|
4989
|
+
/** Consecutive failures per queued event; cleared when it finally succeeds. */
|
|
4990
|
+
failures = /* @__PURE__ */ new WeakMap();
|
|
4945
4991
|
get events() {
|
|
4946
4992
|
return this._events;
|
|
4947
4993
|
}
|
|
@@ -4972,13 +5018,20 @@ var DownQueue = class {
|
|
|
4972
5018
|
const event = this.queue.shift();
|
|
4973
5019
|
if (event) try {
|
|
4974
5020
|
await fn(event);
|
|
5021
|
+
this.failures.delete(event);
|
|
4975
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);
|
|
4976
5028
|
this.logger.error({
|
|
4977
5029
|
error,
|
|
4978
5030
|
event,
|
|
5031
|
+
attempts,
|
|
5032
|
+
rotated: starvingOthers,
|
|
4979
5033
|
Category: "sp00ky-client::DownQueue::next"
|
|
4980
5034
|
}, "Failed to process query");
|
|
4981
|
-
this.queue.unshift(event);
|
|
4982
5035
|
throw error;
|
|
4983
5036
|
}
|
|
4984
5037
|
}
|
|
@@ -5342,11 +5395,18 @@ var SyncEngine = class {
|
|
|
5342
5395
|
* SyncScheduler manages when to sync: queue management and orchestration.
|
|
5343
5396
|
* Decides the order and timing of sync operations.
|
|
5344
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;
|
|
5345
5401
|
var SyncScheduler = class {
|
|
5346
5402
|
isSyncingUp = false;
|
|
5347
5403
|
isSyncingDown = false;
|
|
5348
5404
|
paused = false;
|
|
5349
5405
|
pauseWaiters = [];
|
|
5406
|
+
upRetryTimer;
|
|
5407
|
+
downRetryTimer;
|
|
5408
|
+
upRetryAttempt = 0;
|
|
5409
|
+
downRetryAttempt = 0;
|
|
5350
5410
|
constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
|
|
5351
5411
|
this.upQueue = upQueue;
|
|
5352
5412
|
this.downQueue = downQueue;
|
|
@@ -5382,12 +5442,16 @@ var SyncScheduler = class {
|
|
|
5382
5442
|
*/
|
|
5383
5443
|
pause() {
|
|
5384
5444
|
this.paused = true;
|
|
5445
|
+
this.clearRetryTimers();
|
|
5385
5446
|
if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
|
|
5386
5447
|
return new Promise((resolve) => this.pauseWaiters.push(resolve));
|
|
5387
5448
|
}
|
|
5388
5449
|
resume() {
|
|
5389
5450
|
this.paused = false;
|
|
5451
|
+
this.upRetryAttempt = 0;
|
|
5452
|
+
this.downRetryAttempt = 0;
|
|
5390
5453
|
this.syncUp();
|
|
5454
|
+
this.syncDown();
|
|
5391
5455
|
}
|
|
5392
5456
|
maybeResolvePause() {
|
|
5393
5457
|
if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
|
|
@@ -5395,6 +5459,46 @@ var SyncScheduler = class {
|
|
|
5395
5459
|
this.pauseWaiters = [];
|
|
5396
5460
|
for (const resolve of waiters) resolve();
|
|
5397
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
|
+
}
|
|
5398
5502
|
/**
|
|
5399
5503
|
* Process upload queue
|
|
5400
5504
|
*/
|
|
@@ -5408,8 +5512,10 @@ var SyncScheduler = class {
|
|
|
5408
5512
|
processedAny = true;
|
|
5409
5513
|
}
|
|
5410
5514
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5515
|
+
this.upRetryAttempt = 0;
|
|
5411
5516
|
} catch (error) {
|
|
5412
5517
|
this.onSyncOutcome?.(false, error);
|
|
5518
|
+
this.scheduleUpRetry();
|
|
5413
5519
|
this.logger.debug({
|
|
5414
5520
|
error,
|
|
5415
5521
|
Category: "sp00ky-client::SyncScheduler::syncUp"
|
|
@@ -5425,7 +5531,10 @@ var SyncScheduler = class {
|
|
|
5425
5531
|
*/
|
|
5426
5532
|
async syncDown() {
|
|
5427
5533
|
if (this.isSyncingDown || this.paused) return;
|
|
5428
|
-
if (this.upQueue.size > 0)
|
|
5534
|
+
if (this.upQueue.size > 0) {
|
|
5535
|
+
this.scheduleDownRetry(RETRY_BASE_MS);
|
|
5536
|
+
return;
|
|
5537
|
+
}
|
|
5429
5538
|
this.isSyncingDown = true;
|
|
5430
5539
|
let processedAny = false;
|
|
5431
5540
|
try {
|
|
@@ -5435,8 +5544,10 @@ var SyncScheduler = class {
|
|
|
5435
5544
|
processedAny = true;
|
|
5436
5545
|
}
|
|
5437
5546
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5547
|
+
this.downRetryAttempt = 0;
|
|
5438
5548
|
} catch (error) {
|
|
5439
5549
|
this.onSyncOutcome?.(false, error);
|
|
5550
|
+
this.scheduleDownRetry();
|
|
5440
5551
|
this.logger.debug({
|
|
5441
5552
|
error,
|
|
5442
5553
|
Category: "sp00ky-client::SyncScheduler::syncDown"
|
|
@@ -6892,8 +7003,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
6892
7003
|
|
|
6893
7004
|
//#endregion
|
|
6894
7005
|
//#region src/modules/devtools/index.ts
|
|
6895
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6896
|
-
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";
|
|
6897
7008
|
const SURREAL_VERSION = "3.0.3";
|
|
6898
7009
|
var DevToolsService = class DevToolsService {
|
|
6899
7010
|
eventsHistory = [];
|
|
@@ -11324,7 +11435,7 @@ var Sp00kyClient = class {
|
|
|
11324
11435
|
return new TabsCoordinator({
|
|
11325
11436
|
tabId,
|
|
11326
11437
|
fingerprint: computeTabsFingerprint({
|
|
11327
|
-
coreVersion: "0.0.1-canary.
|
|
11438
|
+
coreVersion: "0.0.1-canary.179",
|
|
11328
11439
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11329
11440
|
endpoint: this.config.database.endpoint ?? "",
|
|
11330
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
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
3
|
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
4
|
import { stubTransport } from './sqlite-transport.fixture';
|
|
5
|
+
import { BrokerPortClosedError } from './sqlite-transport';
|
|
5
6
|
import { translateSurql } from './surql-translate';
|
|
6
7
|
import type { SqlOp } from './surql-translate';
|
|
7
8
|
import { surql } from '../../utils/surql';
|
|
@@ -330,6 +331,99 @@ describe('SqliteCacheEngine role modes', () => {
|
|
|
330
331
|
expect(engine.storageHealth.role).toBe('leader');
|
|
331
332
|
});
|
|
332
333
|
|
|
334
|
+
/**
|
|
335
|
+
* A transport with the REAL pending-map semantics (the shared fixture never
|
|
336
|
+
* rejects on close, which would make the drain test vacuous): `exec` parks
|
|
337
|
+
* until `flush()`, and `close` rejects whatever is still parked.
|
|
338
|
+
*/
|
|
339
|
+
function deferredTransport() {
|
|
340
|
+
const parked: Array<{ resolve: (v: any) => void; reject: (e: unknown) => void }> = [];
|
|
341
|
+
let closed = false;
|
|
342
|
+
const transport: any = {
|
|
343
|
+
kind: 'worker',
|
|
344
|
+
get connected() {
|
|
345
|
+
return !closed;
|
|
346
|
+
},
|
|
347
|
+
call(type: string) {
|
|
348
|
+
if (closed) return Promise.reject(new Error('SQLite worker crashed: transport closed'));
|
|
349
|
+
if (type === 'open') return Promise.resolve({ persisted: true });
|
|
350
|
+
if (type !== 'exec') return Promise.resolve({});
|
|
351
|
+
return new Promise((resolve, reject) => parked.push({ resolve, reject }));
|
|
352
|
+
},
|
|
353
|
+
shutdown: () => Promise.resolve(),
|
|
354
|
+
failAll() {},
|
|
355
|
+
close(reason = 'closed', err?: Error) {
|
|
356
|
+
if (closed) return;
|
|
357
|
+
closed = true;
|
|
358
|
+
const e = err ?? new Error(`SQLite worker crashed: ${reason}`);
|
|
359
|
+
for (const p of parked.splice(0)) p.reject(e);
|
|
360
|
+
},
|
|
361
|
+
flush() {
|
|
362
|
+
for (const p of parked.splice(0)) p.resolve({ rows: [] });
|
|
363
|
+
},
|
|
364
|
+
get parkedCount() {
|
|
365
|
+
return parked.length;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
return transport;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Regression (WhitePawn first-login): the bucket switch runs
|
|
372
|
+
// moveToBucket → teardownLeader → releaseOwnership, which lives on the
|
|
373
|
+
// transition chain, NOT the opQueue — so a query's fetch can be sitting at
|
|
374
|
+
// the worker when ownership is released. Tearing the transport down under it
|
|
375
|
+
// rejected that fetch with "SQLite worker crashed: ownership released", which
|
|
376
|
+
// nothing retries (and nobody caught: an unhandled rejection in the console).
|
|
377
|
+
it('releaseOwnership waits for in-flight ops instead of killing them', async () => {
|
|
378
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
379
|
+
shared: true,
|
|
380
|
+
});
|
|
381
|
+
const transport = deferredTransport();
|
|
382
|
+
(engine as any).createTransport = () => transport;
|
|
383
|
+
await engine.adoptOwner('anon', {
|
|
384
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
385
|
+
allowMemoryFallback: false,
|
|
386
|
+
resumeHeld: false,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const read = engine.getById('_00_query', 'h1');
|
|
390
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
391
|
+
expect(transport.parkedCount).toBe(1);
|
|
392
|
+
|
|
393
|
+
const released = engine.releaseOwnership();
|
|
394
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
395
|
+
// Still parked: the teardown is waiting on it rather than failing it.
|
|
396
|
+
expect(transport.parkedCount).toBe(1);
|
|
397
|
+
transport.flush();
|
|
398
|
+
|
|
399
|
+
await released;
|
|
400
|
+
await expect(read).resolves.toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('fails an undrainable op as a retryable transport loss, not a crash', async () => {
|
|
404
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
405
|
+
shared: true,
|
|
406
|
+
});
|
|
407
|
+
const transport = deferredTransport();
|
|
408
|
+
(engine as any).createTransport = () => transport;
|
|
409
|
+
await engine.adoptOwner('anon', {
|
|
410
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
411
|
+
allowMemoryFallback: false,
|
|
412
|
+
resumeHeld: false,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const read = engine.getById('_00_query', 'h1');
|
|
416
|
+
const caught = read.catch((e: unknown) => e);
|
|
417
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
418
|
+
// The op never answers: the drain times out and the teardown proceeds.
|
|
419
|
+
(engine as any).drainInFlight = () => Promise.resolve();
|
|
420
|
+
await engine.releaseOwnership();
|
|
421
|
+
|
|
422
|
+
const err = await caught;
|
|
423
|
+
expect(err).toBeInstanceOf(BrokerPortClosedError);
|
|
424
|
+
expect((err as Error).message).toContain('ownership released');
|
|
425
|
+
});
|
|
426
|
+
|
|
333
427
|
it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
|
|
334
428
|
const { engine } = makeSharedEngine();
|
|
335
429
|
await engine.adoptOwner('anon', {
|
|
@@ -262,6 +262,33 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
262
262
|
return result;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Ops dispatched to the worker and not yet answered. Role transitions run on
|
|
267
|
+
* their own chain (see {@link transitionChain}), so they can start while the
|
|
268
|
+
* opQueue still has an op at the worker; tearing the transport down under it
|
|
269
|
+
* would reject that op — and its caller may be a query whose only fetch this
|
|
270
|
+
* was. {@link drainInFlight} lets a deliberate teardown wait them out.
|
|
271
|
+
*/
|
|
272
|
+
private inFlightCalls = new Set<Promise<unknown>>();
|
|
273
|
+
|
|
274
|
+
/** Wait for dispatched ops to answer before a deliberate transport teardown.
|
|
275
|
+
* Bounded: a wedged worker must not block the role change forever. */
|
|
276
|
+
private async drainInFlight(timeoutMs = 2_000): Promise<void> {
|
|
277
|
+
if (this.inFlightCalls.size === 0) return;
|
|
278
|
+
const settled = Promise.all([...this.inFlightCalls].map((p) => p.catch(() => undefined)));
|
|
279
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
280
|
+
try {
|
|
281
|
+
await Promise.race([
|
|
282
|
+
settled,
|
|
283
|
+
new Promise<void>((resolve) => {
|
|
284
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
285
|
+
}),
|
|
286
|
+
]);
|
|
287
|
+
} finally {
|
|
288
|
+
if (timer) clearTimeout(timer);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
265
292
|
private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
|
|
266
293
|
if (!this.transport) throw new Error('SqliteCacheEngine: not connected');
|
|
267
294
|
// --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
|
|
@@ -276,8 +303,21 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
276
303
|
s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
|
|
277
304
|
if (this.transport.kind === 'port') s.proxiedOps = (s.proxiedOps ?? 0) + 1;
|
|
278
305
|
const sentAt = performance.now();
|
|
279
|
-
|
|
306
|
+
// Track the dispatch, not the returned promise: attaching a handler to
|
|
307
|
+
// `result` would mark it handled and swallow the unhandled-rejection
|
|
308
|
+
// reports that surface a caller which forgot to catch.
|
|
309
|
+
let settle!: () => void;
|
|
310
|
+
const tracked = new Promise<void>((res) => {
|
|
311
|
+
settle = res;
|
|
312
|
+
});
|
|
313
|
+
this.inFlightCalls.add(tracked);
|
|
314
|
+
const finish = () => {
|
|
315
|
+
this.inFlightCalls.delete(tracked);
|
|
316
|
+
settle();
|
|
317
|
+
};
|
|
318
|
+
const result = this.transport.call<T>(type, payload).then(
|
|
280
319
|
(v: T) => {
|
|
320
|
+
finish();
|
|
281
321
|
s.inFlight--;
|
|
282
322
|
// Split the round-trip: `wt` is time inside the worker's handler,
|
|
283
323
|
// the remainder is postMessage + scheduling overhead.
|
|
@@ -289,10 +329,12 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
289
329
|
return v;
|
|
290
330
|
},
|
|
291
331
|
(e: unknown) => {
|
|
332
|
+
finish();
|
|
292
333
|
s.inFlight--;
|
|
293
334
|
throw e;
|
|
294
335
|
}
|
|
295
336
|
);
|
|
337
|
+
return result;
|
|
296
338
|
}
|
|
297
339
|
|
|
298
340
|
/**
|
|
@@ -514,6 +556,7 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
514
556
|
this.closeRoleGate();
|
|
515
557
|
return this.storageHealthValue;
|
|
516
558
|
}
|
|
559
|
+
if (this.transport) await this.drainInFlight();
|
|
517
560
|
if (this.hadStore) this.storeEpoch++;
|
|
518
561
|
if (this.transport) {
|
|
519
562
|
try {
|
|
@@ -521,7 +564,7 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
521
564
|
} catch {
|
|
522
565
|
/* ignore */
|
|
523
566
|
}
|
|
524
|
-
this.transport.close('adopting ownership');
|
|
567
|
+
this.transport.close('adopting ownership', new BrokerPortClosedError('adopting ownership'));
|
|
525
568
|
this.transport = null;
|
|
526
569
|
}
|
|
527
570
|
await this.openInternal(bucketId, {
|
|
@@ -555,8 +598,12 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
555
598
|
): Promise<void> {
|
|
556
599
|
this.roleLabel = 'follower';
|
|
557
600
|
return this.chainTransition(async () => {
|
|
601
|
+
if (this.transport) await this.drainInFlight();
|
|
558
602
|
if (this.hadStore) this.storeEpoch++;
|
|
559
|
-
this.transport?.close(
|
|
603
|
+
this.transport?.close(
|
|
604
|
+
'adopting leader port',
|
|
605
|
+
new BrokerPortClosedError('adopting leader port')
|
|
606
|
+
);
|
|
560
607
|
this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
|
|
561
608
|
this.bucketId = snapshot.bucketId;
|
|
562
609
|
// The shared store exists and is seeded; mirror the owner's bookkeeping.
|
|
@@ -578,6 +625,11 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
578
625
|
async releaseOwnership(): Promise<void> {
|
|
579
626
|
await this.chainTransition(async () => {
|
|
580
627
|
if (this.transport) {
|
|
628
|
+
// Let ops already at the worker answer first. Without this they die
|
|
629
|
+
// with the transport — a bucket switch (moveToBucket → teardownLeader)
|
|
630
|
+
// runs while the opQueue may still have a query's fetch in flight, and
|
|
631
|
+
// that fetch's caller is not necessarily prepared to retry.
|
|
632
|
+
await this.drainInFlight();
|
|
581
633
|
try {
|
|
582
634
|
if (this.transport.kind === 'worker') {
|
|
583
635
|
await (this.transport as WorkerSqliteTransport).shutdown();
|
|
@@ -585,7 +637,13 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
585
637
|
} catch {
|
|
586
638
|
/* worker may already be fenced/dead */
|
|
587
639
|
}
|
|
588
|
-
|
|
640
|
+
// Anything still pending (drain timed out) is a deliberate teardown,
|
|
641
|
+
// not a crash: fail it the way a follower's lost port does, so the
|
|
642
|
+
// error text is honest and callers can treat it as retryable.
|
|
643
|
+
this.transport.close(
|
|
644
|
+
'ownership released',
|
|
645
|
+
new BrokerPortClosedError('ownership released')
|
|
646
|
+
);
|
|
589
647
|
this.transport = null;
|
|
590
648
|
}
|
|
591
649
|
this.storeEpoch++;
|
|
@@ -22,8 +22,10 @@ export interface SqliteTransport {
|
|
|
22
22
|
call<T = unknown>(type: string, payload?: unknown): Promise<T>;
|
|
23
23
|
/** Reject every pending request with `reason`. Safe to call repeatedly. */
|
|
24
24
|
failAll(reason: string): void;
|
|
25
|
-
/** failAll + release the underlying channel. Terminal.
|
|
26
|
-
|
|
25
|
+
/** failAll + release the underlying channel. Terminal. `err` overrides the
|
|
26
|
+
* transport's own error shape — used for a deliberate teardown (role change)
|
|
27
|
+
* so callers see a retryable "transport lost", not "the worker crashed". */
|
|
28
|
+
close(reason?: string, err?: Error): void;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Thrown into pending follower calls when the leader (or its port) goes away.
|
|
@@ -81,17 +83,17 @@ abstract class BaseTransport implements SqliteTransport {
|
|
|
81
83
|
});
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
failAll(reason: string): void {
|
|
86
|
+
failAll(reason: string, err?: Error): void {
|
|
85
87
|
if (this.pending.size === 0) return;
|
|
86
|
-
const
|
|
87
|
-
for (const [, p] of this.pending) p.reject(
|
|
88
|
+
const e = err ?? this.makeError(reason);
|
|
89
|
+
for (const [, p] of this.pending) p.reject(e);
|
|
88
90
|
this.pending.clear();
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
close(reason = 'closed'): void {
|
|
93
|
+
close(reason = 'closed', err?: Error): void {
|
|
92
94
|
if (this.closed) return;
|
|
93
95
|
this.closed = true;
|
|
94
|
-
this.failAll(reason);
|
|
96
|
+
this.failAll(reason, err);
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -166,9 +168,9 @@ export class WorkerSqliteTransport extends BaseTransport {
|
|
|
166
168
|
return this.call('shutdown').then(() => undefined);
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
close(reason = 'closed'): void {
|
|
171
|
+
close(reason = 'closed', err?: Error): void {
|
|
170
172
|
if (this.closed) return;
|
|
171
|
-
super.close(reason);
|
|
173
|
+
super.close(reason, err);
|
|
172
174
|
this.worker.terminate();
|
|
173
175
|
}
|
|
174
176
|
}
|
|
@@ -193,10 +195,10 @@ export class PortSqliteTransport extends BaseTransport {
|
|
|
193
195
|
this.dead(reason);
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
private dead(reason: string): void {
|
|
198
|
+
private dead(reason: string, err?: Error): void {
|
|
197
199
|
if (this.closed) return;
|
|
198
200
|
this.closed = true;
|
|
199
|
-
this.failAll(reason);
|
|
201
|
+
this.failAll(reason, err);
|
|
200
202
|
try {
|
|
201
203
|
this.port.close();
|
|
202
204
|
} catch {
|
|
@@ -213,7 +215,7 @@ export class PortSqliteTransport extends BaseTransport {
|
|
|
213
215
|
return new BrokerPortClosedError(reason);
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
close(reason = 'closed'): void {
|
|
217
|
-
this.dead(reason);
|
|
218
|
+
close(reason = 'closed', err?: Error): void {
|
|
219
|
+
this.dead(reason, err);
|
|
218
220
|
}
|
|
219
221
|
}
|