@spooky-sync/core 0.0.1-canary.177 → 0.0.1-canary.178
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
|
|
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++;
|
|
@@ -6892,8 +6928,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
6892
6928
|
|
|
6893
6929
|
//#endregion
|
|
6894
6930
|
//#region src/modules/devtools/index.ts
|
|
6895
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6896
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
6931
|
+
const CORE_VERSION = "0.0.1-canary.178";
|
|
6932
|
+
const WASM_VERSION = "0.0.1-canary.178";
|
|
6897
6933
|
const SURREAL_VERSION = "3.0.3";
|
|
6898
6934
|
var DevToolsService = class DevToolsService {
|
|
6899
6935
|
eventsHistory = [];
|
|
@@ -11324,7 +11360,7 @@ var Sp00kyClient = class {
|
|
|
11324
11360
|
return new TabsCoordinator({
|
|
11325
11361
|
tabId,
|
|
11326
11362
|
fingerprint: computeTabsFingerprint({
|
|
11327
|
-
coreVersion: "0.0.1-canary.
|
|
11363
|
+
coreVersion: "0.0.1-canary.178",
|
|
11328
11364
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11329
11365
|
endpoint: this.config.database.endpoint ?? "",
|
|
11330
11366
|
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.178",
|
|
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.178",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.178",
|
|
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",
|
|
@@ -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
|
}
|