@spooky-sync/core 0.0.1-canary.330 → 0.0.1-canary.332
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/AGENTS.md +2 -2
- package/dist/index.d.ts +28 -5
- package/dist/index.js +285 -64
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +16 -0
- package/dist/types.d.ts +42 -11
- package/package.json +3 -3
- package/src/client/bucket-handle.test.ts +78 -0
- package/src/client/bucket-handle.ts +156 -31
- package/src/client/router.ts +1 -1
- package/src/client/services.ts +6 -5
- package/src/client/sp00ky-client.ts +2 -2
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +3 -3
- package/src/modules/crdt/crdt.test.ts +52 -6
- package/src/modules/crdt/index.ts +59 -23
- package/src/query/membership.saga.test.ts +73 -1
- package/src/query/membership.saga.ts +39 -5
- package/src/query/membership.test.ts +33 -0
- package/src/query/membership.ts +33 -0
- package/src/services/database/remote.ts +6 -0
- package/src/sync/live.saga.test.ts +51 -0
- package/src/sync/live.saga.ts +17 -8
- package/src/types.ts +37 -10
- package/src/utils/edges.ts +19 -1
|
@@ -36,6 +36,8 @@ class World {
|
|
|
36
36
|
for (const go of this.held.splice(0)) go();
|
|
37
37
|
}
|
|
38
38
|
private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
|
|
39
|
+
/** Every query a client registered, in order. */
|
|
40
|
+
watched: string[] = [];
|
|
39
41
|
|
|
40
42
|
client(user: string, store = new Map<string, any>(), logger: any = silent) {
|
|
41
43
|
const local = {
|
|
@@ -64,9 +66,10 @@ class World {
|
|
|
64
66
|
return [[]];
|
|
65
67
|
},
|
|
66
68
|
} as any;
|
|
67
|
-
const manager = new CrdtManager(schema, local, remote, logger
|
|
69
|
+
const manager = new CrdtManager(schema, local, remote, logger);
|
|
68
70
|
const ports: CrdtPorts = {
|
|
69
71
|
watch: async (table, where, cb) => {
|
|
72
|
+
this.watched.push(table);
|
|
70
73
|
const w = { table, where, cb };
|
|
71
74
|
this.watchers.add(w);
|
|
72
75
|
if (this.slow) {
|
|
@@ -158,17 +161,60 @@ describe('CRDT fields', () => {
|
|
|
158
161
|
expect(text(fb)).toBe(text(fa));
|
|
159
162
|
});
|
|
160
163
|
|
|
161
|
-
it('
|
|
164
|
+
it('while typing continues, edits go out on a steady cadence instead of waiting for a pause', async () => {
|
|
162
165
|
const world = new World();
|
|
163
166
|
const a = world.client('user:a');
|
|
164
167
|
const f = await a.manager.open('doc', 'doc:1', 'body');
|
|
165
|
-
for
|
|
168
|
+
// A key every 50 ms for a second: a trailing debounce would send nothing
|
|
169
|
+
// until the typing stopped. The first edit goes out after 50 ms, the rest
|
|
170
|
+
// every 200 ms.
|
|
171
|
+
for (let i = 0; i < 20; i++) {
|
|
166
172
|
f.getDoc().getText('body').insert(i, 'x');
|
|
167
173
|
f.getDoc().commit();
|
|
168
|
-
await vi.advanceTimersByTimeAsync(
|
|
174
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
175
|
+
if (i === 0) expect(world.log).toHaveLength(1);
|
|
169
176
|
}
|
|
170
|
-
expect(world.log.length).toBeGreaterThanOrEqual(
|
|
171
|
-
expect(world.log.length).
|
|
177
|
+
expect(world.log.length).toBeGreaterThanOrEqual(4);
|
|
178
|
+
expect(world.log.length).toBeLessThanOrEqual(6);
|
|
179
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
180
|
+
const copy = new LoroDoc();
|
|
181
|
+
for (const row of world.log) copy.import(row.data);
|
|
182
|
+
expect(copy.getText('body').toString()).toBe('x'.repeat(20));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('edits made while a send waits on the server go out right after it', async () => {
|
|
186
|
+
const world = new World();
|
|
187
|
+
const a = world.client('user:a');
|
|
188
|
+
const f = await a.manager.open('doc', 'doc:1', 'body');
|
|
189
|
+
world.slow = true;
|
|
190
|
+
// A fresh peer: the next send claims it first, and that claim hangs.
|
|
191
|
+
a.manager.setSessionId('');
|
|
192
|
+
f.getDoc().getText('body').insert(0, 'a');
|
|
193
|
+
f.getDoc().commit();
|
|
194
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
195
|
+
f.getDoc().getText('body').insert(1, 'b');
|
|
196
|
+
f.getDoc().commit();
|
|
197
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
198
|
+
world.slow = false;
|
|
199
|
+
world.release();
|
|
200
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
201
|
+
const copy = new LoroDoc();
|
|
202
|
+
for (const row of world.log) copy.import(row.data);
|
|
203
|
+
expect(copy.getText('body').toString()).toBe('ab');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('a first open reads the published document while its log query registers, not before', async () => {
|
|
207
|
+
const world = new World();
|
|
208
|
+
world.slow = true;
|
|
209
|
+
const a = world.client('user:a');
|
|
210
|
+
const opening = a.manager.open('doc', 'doc:1', 'body');
|
|
211
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
212
|
+
// The published-document read is still out, and the log query is already registered.
|
|
213
|
+
expect(world.watched).toEqual(['doc__crdt_log']);
|
|
214
|
+
world.slow = false;
|
|
215
|
+
world.release();
|
|
216
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
217
|
+
expect((await opening).isLoaded()).toBe(true);
|
|
172
218
|
});
|
|
173
219
|
|
|
174
220
|
it('send nothing until the peer is claimed, then everything at once', async () => {
|
|
@@ -80,9 +80,14 @@ interface Session {
|
|
|
80
80
|
verifyTimer: ReturnType<typeof setTimeout> | null;
|
|
81
81
|
disposers: Array<() => void>;
|
|
82
82
|
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
83
|
-
|
|
83
|
+
/** When the armed flush timer fires. */
|
|
84
|
+
flushDueAt: number;
|
|
85
|
+
/** When this session last sent edits (0: not yet). */
|
|
86
|
+
lastSentAt: number;
|
|
84
87
|
persistTimer: ReturnType<typeof setTimeout> | null;
|
|
85
88
|
flushing: Promise<void> | null;
|
|
89
|
+
/** A flush asked for while one was running: it runs once that one is done. */
|
|
90
|
+
flushQueued: Promise<void> | null;
|
|
86
91
|
refetching: boolean;
|
|
87
92
|
presenceTimer: ReturnType<typeof setTimeout> | null;
|
|
88
93
|
presencePending: Uint8Array | null;
|
|
@@ -99,8 +104,6 @@ interface Session {
|
|
|
99
104
|
const LOCAL_TABLE = '_00_crdt_local';
|
|
100
105
|
const LOG_SUFFIX = '__crdt_log';
|
|
101
106
|
const PRESENCE_SUFFIX = '__crdt_presence';
|
|
102
|
-
/** Longest a burst of typing waits before its edits go out. */
|
|
103
|
-
const MAX_WAIT_MS = 2000;
|
|
104
107
|
const PERSIST_DEBOUNCE_MS = 400;
|
|
105
108
|
const PRESENCE_THROTTLE_MS = 250;
|
|
106
109
|
const CLAIM_RETRY_MS = 5000;
|
|
@@ -129,7 +132,10 @@ export class CrdtManager {
|
|
|
129
132
|
private local: LocalStore,
|
|
130
133
|
private remote: RemoteDatabaseService,
|
|
131
134
|
logger: Logger,
|
|
132
|
-
|
|
135
|
+
/** First edit after a quiet spell: wait this long to batch the burst. */
|
|
136
|
+
private debounceMs: number = 50,
|
|
137
|
+
/** Shortest gap between two sends of one field, while edits keep coming. */
|
|
138
|
+
private sendIntervalMs: number = 200
|
|
133
139
|
) {
|
|
134
140
|
this.logger = logger.child({ service: 'CrdtManager' });
|
|
135
141
|
this.connectionUnsubscribes.push(
|
|
@@ -289,9 +295,11 @@ export class CrdtManager {
|
|
|
289
295
|
verifyTimer: null,
|
|
290
296
|
disposers: [],
|
|
291
297
|
flushTimer: null,
|
|
292
|
-
|
|
298
|
+
flushDueAt: 0,
|
|
299
|
+
lastSentAt: 0,
|
|
293
300
|
persistTimer: null,
|
|
294
301
|
flushing: null,
|
|
302
|
+
flushQueued: null,
|
|
295
303
|
refetching: false,
|
|
296
304
|
presenceTimer: null,
|
|
297
305
|
presencePending: null,
|
|
@@ -309,9 +317,11 @@ export class CrdtManager {
|
|
|
309
317
|
|
|
310
318
|
// Claimed now, so the first edits do not wait on the round trip.
|
|
311
319
|
void this.ensureClaimed();
|
|
312
|
-
// First open on this device: start from the published document.
|
|
313
|
-
//
|
|
314
|
-
|
|
320
|
+
// First open on this device: start from the published document. Read at
|
|
321
|
+
// the same time as the log query below registers, not before it: each is
|
|
322
|
+
// a round trip, and the import order does not matter (ops whose
|
|
323
|
+
// predecessors are still missing wait inside Loro until they arrive).
|
|
324
|
+
const canonical = !local.snapshot && !fresh ? this.refetchCanonical(session) : null;
|
|
315
325
|
|
|
316
326
|
session.disposers.push(
|
|
317
327
|
doc.subscribeLocalUpdates(() => {
|
|
@@ -323,20 +333,22 @@ export class CrdtManager {
|
|
|
323
333
|
})
|
|
324
334
|
);
|
|
325
335
|
const ports = this.ports;
|
|
326
|
-
const
|
|
336
|
+
const logWatch = ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
|
|
327
337
|
session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
|
|
328
338
|
});
|
|
339
|
+
const presenceWatch = cursor
|
|
340
|
+
? ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) => this.ingestPresence(session, rows))
|
|
341
|
+
: null;
|
|
342
|
+
const [log, presence] = await Promise.all([logWatch, presenceWatch]);
|
|
329
343
|
session.disposers.push(log.stop);
|
|
344
|
+
if (presence) session.disposers.push(presence.stop);
|
|
330
345
|
void log.settled.then(() => {
|
|
331
346
|
session.logSettled = true;
|
|
332
347
|
void session.ingesting.then(() => this.checkLoaded(session));
|
|
333
348
|
});
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
);
|
|
338
|
-
session.disposers.push(presence.stop);
|
|
339
|
-
}
|
|
349
|
+
// Offline the published document is read once the connection is back
|
|
350
|
+
// instead of holding the open.
|
|
351
|
+
if (canonical) await this.serverWait(canonical);
|
|
340
352
|
// An editor binding an empty document creates its root structure, and
|
|
341
353
|
// two clients doing that at once keep only one of them (with whatever was
|
|
342
354
|
// typed into the other). With nothing local, hand the document out only
|
|
@@ -527,19 +539,37 @@ export class CrdtManager {
|
|
|
527
539
|
|
|
528
540
|
// --- sending -----------------------------------------------------
|
|
529
541
|
|
|
530
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Arm the send. Edits go out on a steady cadence while they keep coming:
|
|
544
|
+
* the first one after a quiet spell waits `debounceMs` (one row for the
|
|
545
|
+
* burst), later ones at least `sendIntervalMs` after the previous send. An
|
|
546
|
+
* armed timer is never pushed back, so continuous typing cannot hold the
|
|
547
|
+
* edits; it is only brought forward (`delay`, e.g. 0 on reconnect).
|
|
548
|
+
*/
|
|
549
|
+
private scheduleFlush(s: Session, delay?: number): void {
|
|
531
550
|
const now = Date.now();
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
551
|
+
const due = delay !== undefined ? now + delay : Math.max(now + this.debounceMs, s.lastSentAt + this.sendIntervalMs);
|
|
552
|
+
if (s.flushTimer) {
|
|
553
|
+
if (s.flushDueAt <= due) return;
|
|
554
|
+
clearTimeout(s.flushTimer);
|
|
555
|
+
}
|
|
556
|
+
s.flushDueAt = due;
|
|
535
557
|
s.flushTimer = setTimeout(() => {
|
|
536
558
|
s.flushTimer = null;
|
|
537
559
|
void this.flush(s);
|
|
538
|
-
},
|
|
560
|
+
}, due - now);
|
|
539
561
|
}
|
|
540
562
|
|
|
541
|
-
|
|
542
|
-
|
|
563
|
+
/** Send what is unsent. A call while a send runs queues one more after it,
|
|
564
|
+
* so edits made during that send are not left waiting for the next edit. */
|
|
565
|
+
private flush(s: Session): Promise<void> {
|
|
566
|
+
if (s.flushing) {
|
|
567
|
+
s.flushQueued ??= s.flushing.then(() => {
|
|
568
|
+
s.flushQueued = null;
|
|
569
|
+
return this.flush(s);
|
|
570
|
+
});
|
|
571
|
+
return s.flushQueued;
|
|
572
|
+
}
|
|
543
573
|
s.flushing = this.sendOwnEdits(s).finally(() => {
|
|
544
574
|
s.flushing = null;
|
|
545
575
|
});
|
|
@@ -557,6 +587,7 @@ export class CrdtManager {
|
|
|
557
587
|
.map((peer) => ({ peer, end: vv.get(peer as `${number}`) ?? 0, from: s.local.peers[peer]?.enqueued ?? 0 }))
|
|
558
588
|
.filter((p) => p.end > p.from);
|
|
559
589
|
if (due.length === 0 || !this.ports) return;
|
|
590
|
+
s.lastSentAt = Date.now();
|
|
560
591
|
if (!(await this.ensureClaimed())) {
|
|
561
592
|
this.retryLater(s);
|
|
562
593
|
return;
|
|
@@ -581,7 +612,12 @@ export class CrdtManager {
|
|
|
581
612
|
}
|
|
582
613
|
s.local.peers[peer] = { enqueued: end };
|
|
583
614
|
}
|
|
584
|
-
|
|
615
|
+
// The outbox marks reach the device with the next persist. An open field
|
|
616
|
+
// sends every few hundred ms while typing, and a persist exports the whole
|
|
617
|
+
// document, so it rides the persist debounce; a crash in between only
|
|
618
|
+
// re-sends rows, which import as no-ops. A closing field saves now.
|
|
619
|
+
if (this.sessions.get(s.key) === s) this.schedulePersist(s);
|
|
620
|
+
else await this.persist(s);
|
|
585
621
|
}
|
|
586
622
|
|
|
587
623
|
private retryLater(s: Session): void {
|
|
@@ -4,7 +4,7 @@ import { runPure } from '../testing/run-pure';
|
|
|
4
4
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
5
|
import * as R from '../state/reducers';
|
|
6
6
|
import { defaultEnv } from './env';
|
|
7
|
-
import { applyMembership, applySubqueryChildren, markMembershipDirty, readDirtyMembership, readMembership, recoverLostView } from './membership.saga';
|
|
7
|
+
import { applyEdgeDeltas, applyMembership, applySubqueryChildren, markMembershipDirty, readDirtyMembership, readMembership, recoverLostView } from './membership.saga';
|
|
8
8
|
import type { StatementResult } from '../kernel/effects';
|
|
9
9
|
|
|
10
10
|
const env = defaultEnv({ tables: [] } as any);
|
|
@@ -405,3 +405,75 @@ describe('dirty helpers', () => {
|
|
|
405
405
|
expect(stuck.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
|
|
406
406
|
});
|
|
407
407
|
});
|
|
408
|
+
|
|
409
|
+
describe('applyEdgeDeltas', () => {
|
|
410
|
+
const handlers = { 'local.upsert': () => undefined };
|
|
411
|
+
const live = (over: Record<string, unknown> = {}) =>
|
|
412
|
+
buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]], ...over });
|
|
413
|
+
|
|
414
|
+
it('commits a joining row to a live view without reading the edges', async () => {
|
|
415
|
+
const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:2', version: 1 }, { hash: 'a', id: 't:1', version: 3 }]), {
|
|
416
|
+
state: buildState([live()]),
|
|
417
|
+
handlers,
|
|
418
|
+
});
|
|
419
|
+
expect([...out.result]).toEqual(['a']);
|
|
420
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 3], ['t:2', 1]]);
|
|
421
|
+
expect(out.log.some((e) => e.kind === 'remote.query')).toBe(false);
|
|
422
|
+
// The durable view row follows, and the bodies are asked for.
|
|
423
|
+
expect(out.log.filter((e) => e.kind === 'local.upsert')).toHaveLength(1);
|
|
424
|
+
expect(out.dispatched).toContainEqual({ type: 'FetchRows' });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('takes the fast path for a planned query with no LIMIT too', async () => {
|
|
428
|
+
const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:2', version: 1 }]), {
|
|
429
|
+
state: buildState([live({ def: { hash: 'a', viewKey: 'vk', plan: { table: 't' } } })]),
|
|
430
|
+
handlers,
|
|
431
|
+
});
|
|
432
|
+
expect([...out.result]).toEqual(['a']);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('settles a delta it already holds without writing anything', async () => {
|
|
436
|
+
const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:1', version: 1 }]), { state: buildState([live()]) });
|
|
437
|
+
expect([...out.result]).toEqual(['a']);
|
|
438
|
+
expect(out.log.some((e) => e.kind === 'local.upsert')).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('leaves to the read a view whose membership is not known and current', async () => {
|
|
442
|
+
const deltas = [{ hash: 'a', id: 't:2', version: 1 }];
|
|
443
|
+
for (const entry of [
|
|
444
|
+
live({ lifecycle: { phase: 'cold' } }),
|
|
445
|
+
live({ lifecycle: { phase: 'cached' } }),
|
|
446
|
+
live({ lifecycle: { phase: 'view-lost' } }),
|
|
447
|
+
live({ serverState: 'materializing' }),
|
|
448
|
+
live({ viewLostRetryAt: 9_000 }),
|
|
449
|
+
// A bounded view: the row this one pushes out must leave in the same render.
|
|
450
|
+
live({ def: { hash: 'a', surql: 'SELECT * FROM t ORDER BY at DESC LIMIT 10' } }),
|
|
451
|
+
live({ def: { hash: 'a', plan: { table: 't', limit: 10 } } }),
|
|
452
|
+
]) {
|
|
453
|
+
const out = await runPure(applyEdgeDeltas(deltas), { state: buildState([entry]) });
|
|
454
|
+
expect(out.result.size).toBe(0);
|
|
455
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1]]);
|
|
456
|
+
}
|
|
457
|
+
expect((await runPure(applyEdgeDeltas([{ hash: 'zz', id: 't:2', version: 1 }]), { state: buildState([live()]) })).result.size).toBe(0);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('readMembership after a LIVE delta', () => {
|
|
462
|
+
const qid = (h: string) => new RecordId('_00_query', h);
|
|
463
|
+
it('a read sent before a row joined does not take it back', async () => {
|
|
464
|
+
const s = buildState([buildEntry({ def: { hash: 'a', id: qid('a') }, lifecycle: { phase: 'live' }, remoteArray: [['thing:1', 1]], serverState: 'ready' })]);
|
|
465
|
+
const out = await runPure(readMembership(env, ['a']), {
|
|
466
|
+
state: s,
|
|
467
|
+
handlers: {
|
|
468
|
+
// The server answers for the moment it ran the read; the delta lands
|
|
469
|
+
// while the answer travels back.
|
|
470
|
+
'remote.query': (_e: any, ctx: any) => {
|
|
471
|
+
ctx.state = R.commitMembership('a', [['thing:1', 1], ['thing:2', 1]], true)(ctx.state);
|
|
472
|
+
return [ok([{ out: new RecordId('thing', '1'), version: 1 }]), ok({ rowCount: 1, state: 'ready' }), ok([])];
|
|
473
|
+
},
|
|
474
|
+
'local.upsert': () => undefined,
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['thing:1', 1], ['thing:2', 1]]);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryHash, RecordVersionArray, ServerViewMeta } from '../types';
|
|
1
|
+
import type { EdgeDelta, QueryHash, RecordVersionArray, ServerViewMeta } from '../types';
|
|
2
2
|
import type { Saga } from '../kernel/saga';
|
|
3
3
|
import type { Settled, StatementResult } from '../kernel/effects';
|
|
4
4
|
import { fx } from '../kernel/effects';
|
|
@@ -22,6 +22,8 @@ import * as sql from './sql';
|
|
|
22
22
|
import {
|
|
23
23
|
FAILED,
|
|
24
24
|
decideMembershipOutcome,
|
|
25
|
+
rebaseRead,
|
|
26
|
+
withDeltas,
|
|
25
27
|
snapshotFromSingle,
|
|
26
28
|
snapshotsFromBatch,
|
|
27
29
|
suspectHashes,
|
|
@@ -148,10 +150,41 @@ const stmt = (results: unknown, i: number): unknown => {
|
|
|
148
150
|
return r && r.status === 'OK' ? r.result : FAILED;
|
|
149
151
|
};
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Commit LIVE edge deltas (rows joining a view, or moving to a newer
|
|
155
|
+
* version) straight to membership, without re-reading the edges: the
|
|
156
|
+
* notification already says what the read would. Returns the hashes it
|
|
157
|
+
* settled; any other change still goes through {@link readMembership}.
|
|
158
|
+
*
|
|
159
|
+
* Only for a query whose membership is known and current (`live`, not
|
|
160
|
+
* materializing, no view-lost recovery pending): before that the read decides
|
|
161
|
+
* what the set is. Removals are never applied here, so a view whose edges
|
|
162
|
+
* are being rewritten never loses (and garbage-collects) bodies mid-rewrite.
|
|
163
|
+
* Nor for a query with a `LIMIT`: a row joining it pushes another out, and
|
|
164
|
+
* rendering the joiner before the read removes the leaver would show one row
|
|
165
|
+
* too many (the id-set render drops the `LIMIT`).
|
|
166
|
+
*/
|
|
167
|
+
export function* applyEdgeDeltas(deltas: EdgeDelta[]): Saga<Set<QueryHash>> {
|
|
168
|
+
const settled = new Set<QueryHash>();
|
|
169
|
+
const byHash = new Map<QueryHash, EdgeDelta[]>();
|
|
170
|
+
for (const d of deltas) byHash.set(d.hash, [...(byHash.get(d.hash) ?? []), d]);
|
|
171
|
+
for (const [hash, list] of byHash) {
|
|
172
|
+
const entry = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
173
|
+
if (!entry || entry.lifecycle.phase !== 'live' || entry.serverState === 'materializing' || entry.viewLostRetryAt !== null) continue;
|
|
174
|
+
if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql)) continue;
|
|
175
|
+
const next = withDeltas(entry.remoteArray, list);
|
|
176
|
+
// Never empty, so always applied.
|
|
177
|
+
if (next !== entry.remoteArray) yield* applyMembership(hash, next);
|
|
178
|
+
settled.add(hash);
|
|
179
|
+
}
|
|
180
|
+
return settled;
|
|
181
|
+
}
|
|
182
|
+
|
|
151
183
|
/**
|
|
152
184
|
* Read the server membership of many queries in as few round trips as the
|
|
153
|
-
* row budget allows, then apply each changed set.
|
|
154
|
-
*
|
|
185
|
+
* row budget allows, then apply each changed set. Every server id-set enters
|
|
186
|
+
* state here, except the rows LIVE reports joining (see
|
|
187
|
+
* {@link applyEdgeDeltas}): registration, the poll, LIVE dirt and view-lost
|
|
155
188
|
* recovery all come through here.
|
|
156
189
|
*/
|
|
157
190
|
export function* readMembership(
|
|
@@ -226,9 +259,10 @@ export function* readMembership(
|
|
|
226
259
|
for (const [hash, snap] of snapshots) {
|
|
227
260
|
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
228
261
|
if (!current) continue;
|
|
262
|
+
const primary = rebaseRead(snap.primary, byHash.get(hash)!.remoteArray, current.remoteArray);
|
|
229
263
|
const metaChanged = current.serverState !== (snap.meta.present ? snap.meta.state : null);
|
|
230
|
-
if (!recordVersionArraysEqual(
|
|
231
|
-
const outcome = yield* applyMembership(hash,
|
|
264
|
+
if (!recordVersionArraysEqual(primary, current.remoteArray) || metaChanged || current.lifecycle.phase === 'cold' || current.lifecycle.phase === 'cached') {
|
|
265
|
+
const outcome = yield* applyMembership(hash, primary, snap.meta);
|
|
232
266
|
if (outcome === 'applied') changed = true;
|
|
233
267
|
if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') stillMaterializing.push(hash);
|
|
234
268
|
}
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
isResolvedBefore,
|
|
7
7
|
metaFromRow,
|
|
8
8
|
parseViewRow,
|
|
9
|
+
rebaseRead,
|
|
9
10
|
snapshotFromSingle,
|
|
10
11
|
snapshotsFromBatch,
|
|
11
12
|
suspectHashes,
|
|
13
|
+
withDeltas,
|
|
12
14
|
} from './membership';
|
|
13
15
|
|
|
14
16
|
describe('durable view row', () => {
|
|
@@ -116,3 +118,34 @@ describe('snapshots', () => {
|
|
|
116
118
|
expect(suspectHashes(nullCount, new Map(), 0)).toEqual([]);
|
|
117
119
|
});
|
|
118
120
|
});
|
|
121
|
+
|
|
122
|
+
describe('withDeltas', () => {
|
|
123
|
+
it('adds a joining row and moves a held one to a newer version', () => {
|
|
124
|
+
const held: Array<[string, number]> = [['t:1', 1], ['t:2', 1]];
|
|
125
|
+
expect(withDeltas(held, [{ id: 't:3', version: 1 }, { id: 't:2', version: 4 }])).toEqual([['t:1', 1], ['t:2', 4], ['t:3', 1]]);
|
|
126
|
+
});
|
|
127
|
+
it('hands back the held set itself when nothing moves', () => {
|
|
128
|
+
const held: Array<[string, number]> = [['t:1', 5]];
|
|
129
|
+
expect(withDeltas(held, [{ id: 't:1', version: 5 }, { id: 't:1', version: 2 }])).toBe(held);
|
|
130
|
+
expect(withDeltas(held, [])).toBe(held);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('rebaseRead', () => {
|
|
135
|
+
it('is the read itself when state did not move while it was in flight', () => {
|
|
136
|
+
const before: Array<[string, number]> = [['t:1', 1]];
|
|
137
|
+
const read: Array<[string, number]> = [['t:2', 1]];
|
|
138
|
+
expect(rebaseRead(read, before, before)).toBe(read);
|
|
139
|
+
});
|
|
140
|
+
it('keeps a row that joined (or moved) after the read was sent', () => {
|
|
141
|
+
const before: Array<[string, number]> = [['t:1', 1]];
|
|
142
|
+
const now: Array<[string, number]> = [['t:1', 3], ['t:2', 1]];
|
|
143
|
+
// The server answered before either change.
|
|
144
|
+
expect(rebaseRead([['t:1', 1]], before, now)).toEqual([['t:1', 3], ['t:2', 1]]);
|
|
145
|
+
// ...or after them: nothing is doubled or moved back.
|
|
146
|
+
expect(rebaseRead([['t:1', 4], ['t:2', 2]], before, now)).toEqual([['t:1', 4], ['t:2', 2]]);
|
|
147
|
+
});
|
|
148
|
+
it('drops a row that left in the meantime', () => {
|
|
149
|
+
expect(rebaseRead([['t:1', 1], ['t:2', 1]], [['t:1', 1], ['t:2', 1]], [['t:2', 1]])).toEqual([['t:2', 1]]);
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/query/membership.ts
CHANGED
|
@@ -104,6 +104,39 @@ export function decideMembershipOutcome(input: MembershipDecisionInput): Members
|
|
|
104
104
|
return knownEmpty ? 'applied' : 'ignored';
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Commit LIVE edge deltas onto a held id-set: each names a row that joined
|
|
109
|
+
* (or moved to a newer version). Returns `held` itself when nothing moves.
|
|
110
|
+
*/
|
|
111
|
+
export function withDeltas(held: RecordVersionArray, deltas: ReadonlyArray<{ id: string; version: number }>): RecordVersionArray {
|
|
112
|
+
const next = new Map(held);
|
|
113
|
+
let moved = false;
|
|
114
|
+
for (const { id, version } of deltas) {
|
|
115
|
+
if ((next.get(id) ?? -1) >= version) continue;
|
|
116
|
+
next.set(id, version);
|
|
117
|
+
moved = true;
|
|
118
|
+
}
|
|
119
|
+
return moved ? Array.from(next.entries()) : held;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A membership read answers for the moment the server ran it, which may be
|
|
124
|
+
* before a LIVE delta that was committed while the read was in flight. Carry
|
|
125
|
+
* what changed in state since the read was sent (`before` -> `now`) over onto
|
|
126
|
+
* the read, so a late answer never takes back a row that just joined.
|
|
127
|
+
*/
|
|
128
|
+
export function rebaseRead(read: RecordVersionArray, before: RecordVersionArray, now: RecordVersionArray): RecordVersionArray {
|
|
129
|
+
if (before === now) return read;
|
|
130
|
+
const was = new Map(before);
|
|
131
|
+
const is = new Map(now);
|
|
132
|
+
const out = new Map(read);
|
|
133
|
+
for (const [id] of before) if (!is.has(id)) out.delete(id);
|
|
134
|
+
for (const [id, version] of now) {
|
|
135
|
+
if (version > (was.get(id) ?? -1) && version > (out.get(id) ?? -1)) out.set(id, version);
|
|
136
|
+
}
|
|
137
|
+
return Array.from(out.entries());
|
|
138
|
+
}
|
|
139
|
+
|
|
107
140
|
// ---- list_ref snapshots ------------------------------------------------------
|
|
108
141
|
|
|
109
142
|
/**
|
|
@@ -222,6 +222,12 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
222
222
|
this.authToken = token;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** The token the session is signed in with (or the configured one), for
|
|
226
|
+
* calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
|
|
227
|
+
getAuthToken(): string | null {
|
|
228
|
+
return this.authToken ?? this.getConfig().token ?? null;
|
|
229
|
+
}
|
|
230
|
+
|
|
225
231
|
/** Resolved reconnect tunables; the supervisor reads its own knobs here. */
|
|
226
232
|
getReconnectConfig(): Required<ReconnectConfig> {
|
|
227
233
|
return this.reconnectConfig;
|
|
@@ -6,6 +6,7 @@ import * as R from '../state/reducers';
|
|
|
6
6
|
import { defaultEnv } from '../query/env';
|
|
7
7
|
import { liveChange, liveInvalidate, liveStart } from './live.saga';
|
|
8
8
|
import { rowOfEdge } from '../client/services';
|
|
9
|
+
import { deltaOfEdge } from '../utils/edges';
|
|
9
10
|
|
|
10
11
|
const env = defaultEnv({ tables: [] } as any);
|
|
11
12
|
|
|
@@ -176,3 +177,53 @@ describe('liveChange with the body joined on', () => {
|
|
|
176
177
|
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('deltaOfEdge', () => {
|
|
182
|
+
const q = new RecordId('_00_query', 'a');
|
|
183
|
+
const rid = new RecordId('thread__crdt_log', 'x1');
|
|
184
|
+
it('reads a joining row off a CREATE or UPDATE, with or without the body joined on', () => {
|
|
185
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 2 });
|
|
186
|
+
expect(deltaOfEdge('UPDATE', { in: q, out: { id: rid, data: 'b' }, version: 3 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 3 });
|
|
187
|
+
});
|
|
188
|
+
it('leaves everything else to the re-read', () => {
|
|
189
|
+
// A row leaving, or a view's edges being rewritten.
|
|
190
|
+
expect(deltaOfEdge('DELETE', { in: q, out: rid, version: 2 })).toBeNull();
|
|
191
|
+
// A subquery child.
|
|
192
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2, parent: new RecordId('thread', 't') })).toBeNull();
|
|
193
|
+
// Nothing to place: no version, no target, a target the session may not read, no query.
|
|
194
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid })).toBeNull();
|
|
195
|
+
expect(deltaOfEdge('CREATE', { in: q, out: null, version: 2 })).toBeNull();
|
|
196
|
+
expect(deltaOfEdge('CREATE', { in: q, out: 'thread__crdt_log:x1', version: 2 })).toBeNull();
|
|
197
|
+
expect(deltaOfEdge('CREATE', { out: rid, version: 2 })).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('liveChange with edge deltas', () => {
|
|
202
|
+
const live = (hash: string) =>
|
|
203
|
+
buildEntry({ def: { hash, viewKey: `vk-${hash}` }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]] });
|
|
204
|
+
|
|
205
|
+
it('commits a row joining a live view at once, with no re-read', async () => {
|
|
206
|
+
const out = await runPure(liveChange(env, ['a'], undefined, [{ hash: 'a', id: 't:2', version: 1 }]), {
|
|
207
|
+
state: { ...buildState([live('a')]), tabRole: 'leader' },
|
|
208
|
+
handlers: { 'local.upsert': () => undefined },
|
|
209
|
+
});
|
|
210
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1], ['t:2', 1]]);
|
|
211
|
+
expect(out.state.membershipDirty.size).toBe(0);
|
|
212
|
+
expect(out.timers.has('membership')).toBe(false);
|
|
213
|
+
// Other tabs still hear about it.
|
|
214
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('re-reads what it could not settle, and ignores deltas for unknown queries', async () => {
|
|
218
|
+
const cold = buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } });
|
|
219
|
+
const out = await runPure(
|
|
220
|
+
liveChange(env, ['a', 'b'], undefined, [
|
|
221
|
+
{ hash: 'b', id: 't:2', version: 1 },
|
|
222
|
+
{ hash: 'zz', id: 't:9', version: 1 },
|
|
223
|
+
]),
|
|
224
|
+
{ state: buildState([live('a'), cold]) }
|
|
225
|
+
);
|
|
226
|
+
expect([...out.state.membershipDirty].sort()).toEqual(['a', 'b']);
|
|
227
|
+
expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/sync/live.saga.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import type { InlineRow, QueryHash } from '../types';
|
|
1
|
+
import type { EdgeDelta, InlineRow, QueryHash } from '../types';
|
|
2
2
|
import type { Saga } from '../kernel/saga';
|
|
3
3
|
import { fx } from '../kernel/effects';
|
|
4
4
|
import type { ClientState } from '../state/client-state';
|
|
5
5
|
import * as R from '../state/reducers';
|
|
6
6
|
import type { SagaEnv } from '../query/env';
|
|
7
7
|
import { listRefTable } from '../query/env';
|
|
8
|
-
import { markMembershipDirty } from '../query/membership.saga';
|
|
8
|
+
import { applyEdgeDeltas, markMembershipDirty } from '../query/membership.saga';
|
|
9
9
|
import { landChunk } from '../query/fetch.saga';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* LIVE on the session's `_00_list_ref` table.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* LIVE on the session's `_00_list_ref` table. A row joining a live view is
|
|
13
|
+
* committed straight from its edge; any other event marks its query's
|
|
14
|
+
* membership dirty and the batched re-read decides. Followers get the dirt
|
|
15
|
+
* relayed by the leader.
|
|
15
16
|
*
|
|
16
17
|
* When the subscription was opened with the body joined on
|
|
17
18
|
* (`liveInlineBodies`) the event ALSO carries the changed row. That is an
|
|
@@ -45,8 +46,12 @@ export function* liveInvalidate(): Saga<void> {
|
|
|
45
46
|
yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* One or more edges of these queries changed on the server. A row joining a
|
|
51
|
+
* live view (`deltas`) is committed at once; anything else marks the query
|
|
52
|
+
* dirty for the batched re-read.
|
|
53
|
+
*/
|
|
54
|
+
export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[], deltas?: EdgeDelta[]): Saga<void> {
|
|
50
55
|
const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
|
|
51
56
|
QueryHash[],
|
|
52
57
|
ClientState['tabRole'],
|
|
@@ -73,7 +78,11 @@ export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[
|
|
|
73
78
|
// Before marking membership dirty: recording the version here is what makes
|
|
74
79
|
// the re-read's `planFetch` find nothing left to pull for this row.
|
|
75
80
|
if (rows && rows.length > 0) yield* landInlineRows(env, rows);
|
|
76
|
-
|
|
81
|
+
// The edge says what a re-read would: commit it, and spare the round trip
|
|
82
|
+
// that used to sit between every remote edit and its render.
|
|
83
|
+
const settled = deltas && deltas.length > 0 ? yield* applyEdgeDeltas(deltas.filter((d) => known.includes(d.hash))) : new Set<QueryHash>();
|
|
84
|
+
const dirty = known.filter((h) => !settled.has(h));
|
|
85
|
+
if (dirty.length > 0) yield* markMembershipDirty(dirty);
|
|
77
86
|
if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
|
|
78
87
|
}
|
|
79
88
|
|