@spooky-sync/core 0.0.1-canary.208 → 0.0.1-canary.209
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.d.ts +69 -6
- package/dist/index.js +206 -57
- package/dist/sqlite-worker.js +22 -7
- package/dist/types.d.ts +10 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/modules/data/data.pending-ids.test.ts +74 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/index.ts +151 -61
- package/src/modules/devtools/index.ts +65 -18
- package/src/modules/devtools/notify-throttle.test.ts +6 -1
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/sync/sync.live-removal.test.ts +1 -0
- package/src/modules/sync/sync.ts +10 -0
- package/src/services/database/database.ts +14 -5
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/local.ts +6 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +27 -1
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-transport.ts +5 -2
- package/src/services/database/sqlite-worker.ts +11 -11
- package/src/services/stream-processor/index.ts +8 -0
- package/src/types.ts +10 -0
- package/src/utils/index.ts +8 -2
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { a as renderOrderSql, i as projectedDataSql, l as resolveRelations, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as stableKey } from "./sqlite-open.js";
|
|
2
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
|
|
4
|
+
//#region src/services/database/sqlite-lock-verify.ts
|
|
5
|
+
const LOCK_VERIFY_TIMEOUT_MS = 2e3;
|
|
6
|
+
async function verifyLockStillHeld$1(locks, name, fence, timeoutMs = LOCK_VERIFY_TIMEOUT_MS, warn = () => {}) {
|
|
7
|
+
if (!locks || !name || typeof locks.query !== "function") return;
|
|
8
|
+
let timer;
|
|
9
|
+
try {
|
|
10
|
+
const state = await Promise.race([locks.query(), new Promise((resolve) => {
|
|
11
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
12
|
+
})]);
|
|
13
|
+
if (state === null) {
|
|
14
|
+
warn(`lock verification did not answer within ${timeoutMs}ms; trusting the steal callback`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!(state.held ?? []).some((l) => l.name === name)) await fence("lock missing after suspected freeze");
|
|
18
|
+
} catch {} finally {
|
|
19
|
+
if (timer) clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
4
24
|
//#region src/services/database/sqlite-select.ts
|
|
5
25
|
function ensureTable(db, table) {
|
|
6
26
|
if (db.knownTables.has(table)) return;
|
|
@@ -230,13 +250,8 @@ function noteTick() {
|
|
|
230
250
|
});
|
|
231
251
|
}
|
|
232
252
|
setInterval(noteTick, 1e3);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const name = currentLock?.name;
|
|
236
|
-
if (!locks || !name || typeof locks.query !== "function") return;
|
|
237
|
-
try {
|
|
238
|
-
if (!((await locks.query()).held ?? []).some((l) => l.name === name)) await fence("lock missing after suspected freeze");
|
|
239
|
-
} catch {}
|
|
253
|
+
function verifyLockStillHeld() {
|
|
254
|
+
return verifyLockStillHeld$1(workerLocks(), currentLock?.name, (reason) => fence(reason), void 0, (msg) => console.warn(`[sqlite-worker] ${msg}`));
|
|
240
255
|
}
|
|
241
256
|
async function open(dbName, useOpfs, systemTables = [], workerLockName, openOptions) {
|
|
242
257
|
if (workerLockName) {
|
package/dist/types.d.ts
CHANGED
|
@@ -504,6 +504,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
504
504
|
* failure the queue retries. `0` disables. Defaults to `60_000`.
|
|
505
505
|
*/
|
|
506
506
|
queryTimeoutMs?: number;
|
|
507
|
+
/**
|
|
508
|
+
* Deadline (ms) for every LOCAL store operation (a SQLite worker round trip,
|
|
509
|
+
* or a query on the in-process surrealdb engine). Local ops are serialized
|
|
510
|
+
* too, and one that never answered - a worker starved behind a long select,
|
|
511
|
+
* a lock verification with no clock - left every `db.create`/`db.update`
|
|
512
|
+
* promise pending for the tab's lifetime. On expiry the call rejects with
|
|
513
|
+
* `LocalOpTimeoutError` (the op itself keeps running in the engine and is
|
|
514
|
+
* not retried). `0` disables. Defaults to `30_000`.
|
|
515
|
+
*/
|
|
516
|
+
localOpTimeoutMs?: number;
|
|
507
517
|
};
|
|
508
518
|
/** The schema definition. */
|
|
509
519
|
schema: S;
|
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.209",
|
|
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.209",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.209",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type {
|
|
|
23
23
|
BlobUrlLease,
|
|
24
24
|
} from './services/blobs/index';
|
|
25
25
|
export { semverGt } from './utils/semver';
|
|
26
|
+
export { LocalOpTimeoutError, DEFAULT_LOCAL_OP_TIMEOUT_MS } from './services/database/errors';
|
|
26
27
|
export { fileToUint8Array, textToHtml } from './utils/index';
|
|
27
28
|
export {
|
|
28
29
|
blurhashSidecarPath,
|
|
@@ -123,3 +123,77 @@ describe('DataModule pending-id cache', () => {
|
|
|
123
123
|
expect([...deletes]).toEqual(['game:b']);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('DataModule pending-id cache: generations', () => {
|
|
128
|
+
beforeEach(() => vi.useFakeTimers());
|
|
129
|
+
afterEach(() => vi.useRealTimers());
|
|
130
|
+
|
|
131
|
+
// The outbox read is a round trip down the local op queue, so it can be
|
|
132
|
+
// queued BEFORE a create's outbox row commits and resolve AFTER
|
|
133
|
+
// `invalidatePendingIds()` ran for it. Cached as-is, that pre-create set
|
|
134
|
+
// hid the new row from every materialization that joined the read - the one
|
|
135
|
+
// stream update a CREATE gets, so the row was invisible until reload.
|
|
136
|
+
function makeDeferredModule() {
|
|
137
|
+
const pendingReads: Array<(rows: any[]) => void> = [];
|
|
138
|
+
const state = { reads: 0 };
|
|
139
|
+
const local = {
|
|
140
|
+
query: vi.fn(
|
|
141
|
+
() =>
|
|
142
|
+
new Promise<any[]>((resolve) => {
|
|
143
|
+
state.reads++;
|
|
144
|
+
pendingReads.push((rows) => resolve([rows]));
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
const dm = new DataModule({} as any, local as any, { tables: [] } as any, makeLogger(), 100);
|
|
149
|
+
return { dm, state, pendingReads };
|
|
150
|
+
}
|
|
151
|
+
// Let the awaiting loop observe a resolved read and issue the next one.
|
|
152
|
+
const settle = async () => {
|
|
153
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
it('does not cache a read that was in flight when the outbox changed', async () => {
|
|
157
|
+
const { dm, state, pendingReads } = makeDeferredModule();
|
|
158
|
+
const first = dm.getPendingRecordIds();
|
|
159
|
+
expect(state.reads).toBe(1);
|
|
160
|
+
// The create commits and invalidates while the read is still out.
|
|
161
|
+
(dm as any).invalidatePendingIds();
|
|
162
|
+
// The stale read answers with the pre-create outbox...
|
|
163
|
+
pendingReads[0]!([]);
|
|
164
|
+
await settle();
|
|
165
|
+
// ...so the joiner re-read under the new generation and gets the fresh one.
|
|
166
|
+
expect(state.reads).toBe(2);
|
|
167
|
+
pendingReads[1]!([pending('new', 'create')]);
|
|
168
|
+
expect([...(await first).writes]).toEqual(['game:new']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('a stale in-flight result never repopulates the cache', async () => {
|
|
172
|
+
const { dm, state, pendingReads } = makeDeferredModule();
|
|
173
|
+
const first = dm.getPendingRecordIds();
|
|
174
|
+
(dm as any).invalidatePendingIds();
|
|
175
|
+
pendingReads[0]!([]);
|
|
176
|
+
await settle();
|
|
177
|
+
pendingReads[1]!([pending('new', 'create')]);
|
|
178
|
+
await first;
|
|
179
|
+
// A fresh call must be served from the cache the CURRENT generation's
|
|
180
|
+
// read wrote, i.e. see the new row without another read.
|
|
181
|
+
const again = await dm.getPendingRecordIds();
|
|
182
|
+
expect(state.reads).toBe(2);
|
|
183
|
+
expect([...again.writes]).toEqual(['game:new']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('bounds the re-read when the outbox changes on every tick', async () => {
|
|
187
|
+
const { dm, state, pendingReads } = makeDeferredModule();
|
|
188
|
+
const call = dm.getPendingRecordIds();
|
|
189
|
+
for (let i = 0; i < 5; i++) {
|
|
190
|
+
(dm as any).invalidatePendingIds();
|
|
191
|
+
pendingReads[i]?.([pending(`r${i}`)]);
|
|
192
|
+
await settle();
|
|
193
|
+
}
|
|
194
|
+
const result = await call;
|
|
195
|
+
// Three reads at most, and the caller still gets an answer.
|
|
196
|
+
expect(state.reads).toBeLessThanOrEqual(3);
|
|
197
|
+
expect(result.writes.size).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
5
|
+
import type { QueryState, RecordVersionArray } from '../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A row this client wrote itself reaches server membership at the very
|
|
9
|
+
* version the local CREATE memoized, so the sync engine rightly fetches
|
|
10
|
+
* nothing when the membership lands - and then nothing re-materialized the
|
|
11
|
+
* query: `remoteArray` gained the id and no subscriber heard about it until a
|
|
12
|
+
* reload rebuilt the view from membership. `scheduleRematerialize` closes that
|
|
13
|
+
* gap through the same per-query debounce a real stream update uses.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const noop = () => {};
|
|
17
|
+
function makeLogger(): any {
|
|
18
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
19
|
+
logger.child = () => logger;
|
|
20
|
+
return logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const schema = { tables: [{ name: 'message', columns: {} }] } as any;
|
|
24
|
+
const plan: QueryPlan = { table: 'message', where: [['conversation', '=', 'conversation:c1']] } as any;
|
|
25
|
+
|
|
26
|
+
function makeQueryState(hash: string, remoteArray: RecordVersionArray, localArray: RecordVersionArray): QueryState {
|
|
27
|
+
return {
|
|
28
|
+
config: {
|
|
29
|
+
id: new RecordId('_00_query', hash),
|
|
30
|
+
surql: 'SELECT * FROM message WHERE conversation = $conversation;',
|
|
31
|
+
plan,
|
|
32
|
+
params: { conversation: 'conversation:c1' },
|
|
33
|
+
localArray,
|
|
34
|
+
remoteArray,
|
|
35
|
+
membershipKnown: true,
|
|
36
|
+
ttl: '10m',
|
|
37
|
+
lastActiveAt: new Date(),
|
|
38
|
+
tableName: 'message',
|
|
39
|
+
},
|
|
40
|
+
records: [],
|
|
41
|
+
ttlTimer: null,
|
|
42
|
+
ttlDurationMs: 0,
|
|
43
|
+
updateCount: 0,
|
|
44
|
+
lastUpdatedAt: null,
|
|
45
|
+
materializationSamples: [],
|
|
46
|
+
lastIngestLatencyMs: null,
|
|
47
|
+
errorCount: 0,
|
|
48
|
+
status: 'idle',
|
|
49
|
+
phaseSamples: {},
|
|
50
|
+
phaseLast: {},
|
|
51
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
52
|
+
} as QueryState;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setup(remoteArray: RecordVersionArray, localArray: RecordVersionArray) {
|
|
56
|
+
const body = { id: new RecordId('message', 'm1'), text: 'hi' };
|
|
57
|
+
const local: any = {
|
|
58
|
+
epoch: 1,
|
|
59
|
+
select: vi.fn(async (p: QueryPlan) => {
|
|
60
|
+
const ids = ((p as any).ids as RecordId[] | undefined) ?? [];
|
|
61
|
+
return ids.some((id) => id.toString() === 'message:m1') ? [body] : [];
|
|
62
|
+
}),
|
|
63
|
+
query: vi.fn(async (sql: string) => {
|
|
64
|
+
if (sql.includes('_00_pending_mutations')) return [[]];
|
|
65
|
+
return [[]];
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
const dm = new DataModule({} as any, local, schema, makeLogger(), 10);
|
|
69
|
+
const hash = 'q1';
|
|
70
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash, remoteArray, localArray));
|
|
71
|
+
const subscriber = vi.fn();
|
|
72
|
+
(dm as any).subscriptions.set(hash, new Set([subscriber]));
|
|
73
|
+
return { dm, local, hash, subscriber };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('DataModule.scheduleRematerialize', () => {
|
|
77
|
+
afterEach(() => vi.useRealTimers());
|
|
78
|
+
|
|
79
|
+
it('notifies subscribers with the row once membership holds it', async () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const { dm, local, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
|
|
82
|
+
dm.scheduleRematerialize(hash);
|
|
83
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
84
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(subscriber.mock.calls[0]![0].map((r: any) => r.id.toString())).toEqual(['message:m1']);
|
|
86
|
+
// A synthetic update describes no ingest: nothing is persisted to _00_query.
|
|
87
|
+
expect(local.query.mock.calls.some(([sql]: [string]) => sql.includes('localArray'))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('is a no-op behind a pending real stream update', async () => {
|
|
91
|
+
vi.useFakeTimers();
|
|
92
|
+
const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
|
|
93
|
+
await dm.onStreamUpdate({ queryHash: hash, localArray: [['message:m1', 1]], op: 'CREATE' });
|
|
94
|
+
dm.scheduleRematerialize(hash);
|
|
95
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
96
|
+
// Exactly one notify: the real update's, not two.
|
|
97
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does not notify when the records are unchanged', async () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
|
|
103
|
+
dm.scheduleRematerialize(hash);
|
|
104
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
105
|
+
dm.scheduleRematerialize(hash);
|
|
106
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
107
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ignores an unknown query', () => {
|
|
111
|
+
const { dm } = setup([], []);
|
|
112
|
+
expect(() => dm.scheduleRematerialize('nope')).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -428,6 +428,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
428
428
|
return;
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
this.queueStreamUpdate(update);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
|
|
435
|
+
private queueStreamUpdate(update: StreamUpdate): void {
|
|
436
|
+
const { queryHash } = update;
|
|
431
437
|
// Clear existing timer if any
|
|
432
438
|
if (this.debounceTimers.has(queryHash)) {
|
|
433
439
|
// oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
|
|
@@ -445,6 +451,31 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
445
451
|
this.debounceTimers.set(queryHash, timer);
|
|
446
452
|
}
|
|
447
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Re-materialize + notify a query whose MEMBERSHIP changed without any row
|
|
456
|
+
* needing to be fetched, i.e. without the SSP stream update that normally
|
|
457
|
+
* carries the notify. That is every row this client wrote itself: the local
|
|
458
|
+
* CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
|
|
459
|
+
* sync engine rightly fetches nothing - and then nobody told the subscribers
|
|
460
|
+
* that `remoteArray` now holds the id. The row appeared on reload only.
|
|
461
|
+
*
|
|
462
|
+
* Routed through the same per-query debounce as a real stream update, so it
|
|
463
|
+
* cannot race one: a pending real update already materializes against the
|
|
464
|
+
* current `remoteArray` and wins. The synthetic update re-uses the circuit's
|
|
465
|
+
* last `localArray` and skips the persist/metrics that describe an ingest.
|
|
466
|
+
*/
|
|
467
|
+
scheduleRematerialize(queryHash: string): void {
|
|
468
|
+
if (this.debounceTimers.has(queryHash)) return;
|
|
469
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
470
|
+
if (!queryState) return;
|
|
471
|
+
this.queueStreamUpdate({
|
|
472
|
+
queryHash,
|
|
473
|
+
localArray: queryState.config.localArray ?? [],
|
|
474
|
+
op: 'UPDATE',
|
|
475
|
+
synthetic: true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
448
479
|
/**
|
|
449
480
|
* Process a query's pending (debounced) stream update NOW instead of on the
|
|
450
481
|
* trailing edge. Called by the sync engine before it flips a query back to
|
|
@@ -614,11 +645,24 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
614
645
|
private pendingIdsAt = 0;
|
|
615
646
|
private pendingIdsInflight: Promise<{ writes: Set<string>; deletes: Set<string> }> | null = null;
|
|
616
647
|
private static readonly PENDING_IDS_TTL_MS = 250;
|
|
648
|
+
// Bumped by every invalidation. A read carries the generation it started
|
|
649
|
+
// under, and a result from an older generation is neither cached nor
|
|
650
|
+
// returned to a caller that asked after the invalidation: it was taken
|
|
651
|
+
// before the outbox row it needs existed. Without this, a materialization
|
|
652
|
+
// that joined a read already in flight when `create()` invalidated saw the
|
|
653
|
+
// pre-create set, found "no change", and skipped its notify - the one
|
|
654
|
+
// stream update a CREATE gets, so the row stayed invisible until reload.
|
|
655
|
+
private pendingIdsGen = 0;
|
|
656
|
+
private static readonly PENDING_IDS_MAX_REREADS = 3;
|
|
617
657
|
|
|
618
658
|
/** Drop the cached outbox ids. Cheap; call it on anything that could change
|
|
619
659
|
* `_00_pending_mutations`. */
|
|
620
660
|
private invalidatePendingIds(): void {
|
|
621
661
|
this.pendingIds = null;
|
|
662
|
+
this.pendingIdsGen++;
|
|
663
|
+
// The in-flight read (if any) predates this invalidation; whoever joins
|
|
664
|
+
// next starts a fresh one. Its own joiners re-read via the generation check.
|
|
665
|
+
this.pendingIdsInflight = null;
|
|
622
666
|
}
|
|
623
667
|
|
|
624
668
|
/**
|
|
@@ -770,7 +814,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
770
814
|
// normal re-query path.
|
|
771
815
|
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
772
816
|
if (epoch !== this.local.epoch) return;
|
|
773
|
-
queryState.config.localArray = localArray;
|
|
817
|
+
if (!update.synthetic) queryState.config.localArray = localArray;
|
|
774
818
|
|
|
775
819
|
const prevJson = JSON.stringify(queryState.records);
|
|
776
820
|
const newJson = JSON.stringify(newRecords);
|
|
@@ -785,30 +829,34 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
785
829
|
queryState.lastUpdatedAt = Date.now();
|
|
786
830
|
}
|
|
787
831
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
'
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
832
|
+
// A synthetic re-materialize did not come from an ingest: nothing about
|
|
833
|
+
// the circuit's view changed, so there is nothing to persist for it.
|
|
834
|
+
if (!update.synthetic) {
|
|
835
|
+
await this.local.query(
|
|
836
|
+
surql.seal(
|
|
837
|
+
surql.updateSet('id', [
|
|
838
|
+
'localArray',
|
|
839
|
+
'rowCount',
|
|
840
|
+
'updateCount',
|
|
841
|
+
'lastIngestLatency',
|
|
842
|
+
'materializationP55',
|
|
843
|
+
'materializationP90',
|
|
844
|
+
'materializationP99',
|
|
845
|
+
])
|
|
846
|
+
),
|
|
847
|
+
{
|
|
848
|
+
id: queryState.config.id,
|
|
849
|
+
localArray,
|
|
850
|
+
rowCount: localArray.length,
|
|
851
|
+
updateCount: queryState.updateCount,
|
|
852
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
853
|
+
materializationP55: percentiles.p55,
|
|
854
|
+
materializationP90: percentiles.p90,
|
|
855
|
+
materializationP99: percentiles.p99,
|
|
856
|
+
},
|
|
857
|
+
{ epoch }
|
|
858
|
+
);
|
|
859
|
+
}
|
|
812
860
|
|
|
813
861
|
if (!recordsChanged) {
|
|
814
862
|
this.logger.debug(
|
|
@@ -1249,18 +1297,32 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1249
1297
|
}
|
|
1250
1298
|
// Single-flight: one ingest fans out to many queries materializing at once,
|
|
1251
1299
|
// and without this they all queue their own identical read behind each other.
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1300
|
+
// Bounded re-read: a result from a generation older than the one current
|
|
1301
|
+
// by the time it lands was taken before some outbox row existed, so it is
|
|
1302
|
+
// re-issued rather than returned. Bounded, because a write on every tick
|
|
1303
|
+
// must not spin this forever - the last read is returned then.
|
|
1304
|
+
let fresh: { writes: Set<string>; deletes: Set<string> } | null = null;
|
|
1305
|
+
for (let attempt = 0; attempt < DataModule.PENDING_IDS_MAX_REREADS; attempt++) {
|
|
1306
|
+
const gen = this.pendingIdsGen;
|
|
1307
|
+
if (!this.pendingIdsInflight) {
|
|
1308
|
+
const read = this.readPendingRecordIds(gen).finally(() => {
|
|
1309
|
+
// Only clear the slot this read still owns; an invalidation in the
|
|
1310
|
+
// meantime has replaced it (with null) for a newer read to fill.
|
|
1311
|
+
if (this.pendingIdsInflight === read) this.pendingIdsInflight = null;
|
|
1312
|
+
});
|
|
1313
|
+
this.pendingIdsInflight = read;
|
|
1314
|
+
}
|
|
1315
|
+
fresh = await this.pendingIdsInflight;
|
|
1316
|
+
if (gen === this.pendingIdsGen) break;
|
|
1256
1317
|
}
|
|
1257
|
-
|
|
1258
|
-
return { writes: new Set(fresh
|
|
1318
|
+
// oxlint-disable-next-line no-non-null-assertion -- the loop runs at least once
|
|
1319
|
+
return { writes: new Set(fresh!.writes), deletes: new Set(fresh!.deletes) };
|
|
1259
1320
|
}
|
|
1260
1321
|
|
|
1261
1322
|
/** The uncached read. Also the reload path after an invalidation, so the ids
|
|
1262
|
-
* still survive a reload exactly as before.
|
|
1263
|
-
|
|
1323
|
+
* still survive a reload exactly as before. `gen` is the generation the read
|
|
1324
|
+
* was issued under; the result is cached only if it is still current. */
|
|
1325
|
+
private async readPendingRecordIds(gen: number): Promise<{
|
|
1264
1326
|
writes: Set<string>;
|
|
1265
1327
|
deletes: Set<string>;
|
|
1266
1328
|
}> {
|
|
@@ -1285,8 +1347,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1285
1347
|
// not a statement that the outbox is empty.
|
|
1286
1348
|
return { writes, deletes };
|
|
1287
1349
|
}
|
|
1288
|
-
this.
|
|
1289
|
-
|
|
1350
|
+
if (gen === this.pendingIdsGen) {
|
|
1351
|
+
this.pendingIds = { writes, deletes };
|
|
1352
|
+
this.pendingIdsAt = Date.now();
|
|
1353
|
+
}
|
|
1290
1354
|
return { writes, deletes };
|
|
1291
1355
|
}
|
|
1292
1356
|
|
|
@@ -1757,18 +1821,36 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1757
1821
|
})
|
|
1758
1822
|
);
|
|
1759
1823
|
|
|
1824
|
+
// The local tx has committed: the row and its outbox entry exist. The
|
|
1825
|
+
// cached pending-id sets no longer describe the outbox, so drop them HERE,
|
|
1826
|
+
// before the ingest below fans out to every view's materialization (which
|
|
1827
|
+
// is exactly when they are read).
|
|
1828
|
+
this.invalidatePendingIds();
|
|
1829
|
+
|
|
1760
1830
|
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
1761
1831
|
|
|
1762
|
-
// Save to cache (which handles DBSP ingestion)
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1832
|
+
// Save to cache (which handles DBSP ingestion). Best-effort, like
|
|
1833
|
+
// `delete()`: the local write is durable and the outbox row exists, so a
|
|
1834
|
+
// throw from the in-browser ingest must not abort the rest of this method -
|
|
1835
|
+
// it used to, and then the mutation was never handed to sync, the row sat
|
|
1836
|
+
// in the outbox until the next leader promotion re-scanned it, and the
|
|
1837
|
+
// caller's promise rejected after the write had already happened.
|
|
1838
|
+
try {
|
|
1839
|
+
await this.cache.save(
|
|
1840
|
+
{
|
|
1841
|
+
table: tableName,
|
|
1842
|
+
op: 'CREATE',
|
|
1843
|
+
record: parsedRecord,
|
|
1844
|
+
version: 1,
|
|
1845
|
+
},
|
|
1846
|
+
true
|
|
1847
|
+
);
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
this.logger.error(
|
|
1850
|
+
{ err, id, Category: 'sp00ky-client::DataModule::create' },
|
|
1851
|
+
'SSP create-ingest failed; the row is written and queued, but views only see it once membership arrives'
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1772
1854
|
|
|
1773
1855
|
// Emit mutation event for sync
|
|
1774
1856
|
const mutationEvent: CreateEvent = {
|
|
@@ -1780,8 +1862,6 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1780
1862
|
tableName,
|
|
1781
1863
|
};
|
|
1782
1864
|
|
|
1783
|
-
// A new outbox row: the cached pending-id sets no longer describe it.
|
|
1784
|
-
this.invalidatePendingIds();
|
|
1785
1865
|
for (const callback of this.mutationCallbacks) {
|
|
1786
1866
|
callback([mutationEvent]);
|
|
1787
1867
|
}
|
|
@@ -1849,18 +1929,28 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1849
1929
|
}
|
|
1850
1930
|
this.replaceRecordInQueries(updatedFields);
|
|
1851
1931
|
|
|
1932
|
+
// Committed: see create() for why this precedes the ingest.
|
|
1933
|
+
this.invalidatePendingIds();
|
|
1934
|
+
|
|
1852
1935
|
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
1853
1936
|
|
|
1854
|
-
// Save to cache
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1937
|
+
// Save to cache. Best-effort for the same reason as in create().
|
|
1938
|
+
try {
|
|
1939
|
+
await this.cache.save(
|
|
1940
|
+
{
|
|
1941
|
+
table: table,
|
|
1942
|
+
op: 'UPDATE',
|
|
1943
|
+
record: parsedRecord,
|
|
1944
|
+
version: target._00_rv as number,
|
|
1945
|
+
},
|
|
1946
|
+
true
|
|
1947
|
+
);
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
this.logger.error(
|
|
1950
|
+
{ err, id, Category: 'sp00ky-client::DataModule::update' },
|
|
1951
|
+
'SSP update-ingest failed; the row is written and queued'
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1864
1954
|
|
|
1865
1955
|
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
1866
1956
|
|
|
@@ -1875,8 +1965,6 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1875
1965
|
options: pushEventOptions,
|
|
1876
1966
|
};
|
|
1877
1967
|
|
|
1878
|
-
// A new outbox row: the cached pending-id sets no longer describe it.
|
|
1879
|
-
this.invalidatePendingIds();
|
|
1880
1968
|
for (const callback of this.mutationCallbacks) {
|
|
1881
1969
|
callback([mutationEvent]);
|
|
1882
1970
|
}
|
|
@@ -1912,6 +2000,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1912
2000
|
|
|
1913
2001
|
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
1914
2002
|
|
|
2003
|
+
// Committed: the outbox row exists, drop the cached pending-id sets before
|
|
2004
|
+
// the re-materialize below reads them (see create()).
|
|
2005
|
+
this.invalidatePendingIds();
|
|
2006
|
+
|
|
1915
2007
|
// The local DELETE has now committed. Everything below must reflect that in
|
|
1916
2008
|
// active live queries — so the deleted row disappears optimistically without
|
|
1917
2009
|
// a reload — even if the optimistic SSP-view ingest below fails. Previously a
|
|
@@ -1940,8 +2032,6 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1940
2032
|
record_id: rid,
|
|
1941
2033
|
};
|
|
1942
2034
|
|
|
1943
|
-
// A new outbox row: the cached pending-id sets no longer describe it.
|
|
1944
|
-
this.invalidatePendingIds();
|
|
1945
2035
|
for (const callback of this.mutationCallbacks) {
|
|
1946
2036
|
callback([mutationEvent]);
|
|
1947
2037
|
}
|