@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 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 err = this.makeError(reason);
2004
- for (const [, p] of this.pending) p.reject(err);
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) return;
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.177";
6896
- const WASM_VERSION = "0.0.1-canary.177";
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.177",
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.177",
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.177",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.177",
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
- if (this.upQueue.size > 0) return;
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
- return this.transport.call<T>(type, payload).then(
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('adopting leader port');
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
- this.transport.close('ownership released');
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
- close(reason?: string): void;
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 err = this.makeError(reason);
87
- for (const [, p] of this.pending) p.reject(err);
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
  }