Skip to content

Commit 18736d0

Browse files
Marcus PousetteMarcus Pousette
authored andcommitted
perf(wa-sqlite): prioritize reads during background sync
1 parent eee9e75 commit 18736d0

8 files changed

Lines changed: 252 additions & 32 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@treecrdt/interface': patch
3+
'@treecrdt/wa-sqlite': patch
4+
'@treecrdt/sync-sqlite': patch
5+
---
6+
7+
Let dedicated-worker clients prioritize engine reads between background sync append batches while
8+
preserving normal call ordering and bounding foreground starvation.

packages/sync-protocol/material/sqlite/src/backend.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Operation } from '@treecrdt/interface';
22
import { bytesToHex, hexToBytes } from '@treecrdt/interface/ids';
3+
import type { WriteOptions } from '@treecrdt/interface/engine';
34
import type { SqliteRunner } from '@treecrdt/interface/sqlite';
45
import { deriveOpRefV0 } from '@treecrdt/sync-protocol';
56
import type { Filter, OpRef, SyncBackend } from '@treecrdt/sync-protocol';
@@ -18,7 +19,7 @@ export type TreecrdtSyncBackendClient = {
1819
ops: {
1920
all?: () => Promise<Operation[]>;
2021
get: (opRefs: OpRef[]) => Promise<Operation[]>;
21-
appendMany: (ops: Operation[]) => Promise<unknown>;
22+
appendMany: (ops: Operation[], opts?: WriteOptions) => Promise<unknown>;
2223
};
2324
};
2425

@@ -169,7 +170,7 @@ export function createTreecrdtSyncBackendFromClient(
169170

170171
applyOps: async (ops) => {
171172
if (ops.length === 0) return;
172-
await client.ops.appendMany(ops);
173+
await client.ops.appendMany(ops, { priority: 'background' });
173174
},
174175

175176
...(pending

packages/treecrdt-sync/tests/in-memory-sync.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ test('syncOnce defaults split inbound applies into modest batches', async () =>
112112
const { client: aClient, getOps: getAllA } = createInMemoryTestClient(docId, []);
113113
const { client: bClient } = createInMemoryTestClient(docId, remoteOps);
114114
const appendBatchSizes: number[] = [];
115+
const appendPriorities: Array<string | undefined> = [];
115116
const appendMany = aClient.ops.appendMany.bind(aClient.ops);
116117
aClient.ops.appendMany = async (ops, writeOpts) => {
117118
appendBatchSizes.push(ops.length);
119+
appendPriorities.push(writeOpts?.priority);
118120
return appendMany(ops, writeOpts);
119121
};
120122

@@ -142,6 +144,7 @@ test('syncOnce defaults split inbound applies into modest batches', async () =>
142144
expect(appendBatchSizes.length).toBeGreaterThan(1);
143145
expect(Math.max(...appendBatchSizes)).toBeLessThanOrEqual(DEFAULT_MAX_OPS_PER_BATCH);
144146
expect(appendBatchSizes.reduce((sum, size) => sum + size, 0)).toBe(totalOps);
147+
expect(new Set(appendPriorities)).toEqual(new Set(['background']));
145148
});
146149

147150
test('syncOnce pulls insert, move, payload, and delete operations', async () => {

packages/treecrdt-ts/src/engine.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export function addMaterializationWriteId(
126126

127127
export type WriteOptions = {
128128
writeId?: string;
129+
/**
130+
* Scheduling hint for clients that serialize requests through a dedicated worker.
131+
* Background writes may yield to foreground operations.
132+
*/
133+
priority?: 'background';
129134
};
130135

131136
export type LocalWriteAuthSession = {

packages/treecrdt-wa-sqlite/src/client.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
type RpcResponse,
4040
type RpcResult,
4141
} from './rpc.js';
42+
import { createPrioritizedRpcCall } from './rpc-scheduler.js';
4243
import { openTreecrdtDb, type OpenTreecrdtDbOptions, type OpenTreecrdtDbResult } from './open.js';
4344
import type {
4445
ClientMaterializationDispatcher,
@@ -52,6 +53,7 @@ import type {
5253
NormalizedRuntimeOptions,
5354
NormalizedStorageOptions,
5455
RpcCall,
56+
RpcCallOptions,
5557
RuntimeMode,
5658
SharedWorkerFactory,
5759
StorageMode,
@@ -324,15 +326,8 @@ async function createWorkerClient(opts: {
324326
>();
325327
let terminalError: Error | null = null;
326328
let closed = false;
327-
let callQueue: Promise<void> = Promise.resolve();
328329

329330
const closedError = new Error(CLIENT_CLOSED_ERROR);
330-
const settleQueue = <T>(promise: Promise<T>): Promise<void> =>
331-
promise.then(
332-
() => undefined,
333-
() => undefined,
334-
);
335-
336331
const callRaw = <M extends RpcMethod>(method: M, params: RpcParams<M>): Promise<RpcResult<M>> => {
337332
if (closed) return Promise.reject(closedError);
338333
const id = nextId++;
@@ -342,11 +337,7 @@ async function createWorkerClient(opts: {
342337
worker.postMessage({ id, method, params } satisfies RpcRequest<M>);
343338
});
344339
};
345-
const call = <M extends RpcMethod>(method: M, params: RpcParams<M>): Promise<RpcResult<M>> => {
346-
const run = callQueue.then(() => callRaw(method, params));
347-
callQueue = settleQueue(run);
348-
return run;
349-
};
340+
const call = createPrioritizedRpcCall(callRaw);
350341

351342
const onMessage = (ev: MessageEvent<RpcResponse | RpcPushMessage>) => {
352343
const data = ev.data;
@@ -744,7 +735,7 @@ export async function buildDirectClient(
744735
throw wrapError(method, err);
745736
}
746737
};
747-
const call: RpcCall = (method, params) => {
738+
const call: RpcCall = (method, params, _options) => {
748739
const run = callQueue.then(() => runDirectCall(method, params));
749740
callQueue = settleQueue(run);
750741
return run;
@@ -775,7 +766,8 @@ export async function buildDirectClient(
775766

776767
// --- helpers
777768

778-
function makeTreecrdtClientFromCall(opts: {
769+
/** @internal Exported for deterministic client scheduling tests. */
770+
export function makeTreecrdtClientFromCall(opts: {
779771
mode: ClientMode;
780772
runtime: RuntimeMode;
781773
storage: StorageMode;
@@ -806,18 +798,20 @@ function makeTreecrdtClientFromCall(opts: {
806798
localWriters.set(key, next);
807799
return next;
808800
};
801+
const foregroundCall: RpcCall = (method, params) =>
802+
call(method, params, { priority: 'foreground' });
809803

810804
const opsSinceImpl = async (lamport: number, root?: string) => {
811-
const rows = await call('opsSince', [lamport, root]);
805+
const rows = await foregroundCall('opsSince', [lamport, root]);
812806
return decodeSqliteOps(rows);
813807
};
814-
const opRefsAllImpl = async () => decodeSqliteOpRefs(await call('opRefsAll', []));
808+
const opRefsAllImpl = async () => decodeSqliteOpRefs(await foregroundCall('opRefsAll', []));
815809
const opRefsChildrenImpl = async (parent: string) =>
816-
decodeSqliteOpRefs(await call('opRefsChildren', [parent]));
810+
decodeSqliteOpRefs(await foregroundCall('opRefsChildren', [parent]));
817811
const opsByOpRefsImpl = async (opRefs: Uint8Array[]) =>
818-
decodeSqliteOps(await call('opsByOpRefs', [opRefs.map((r) => Array.from(r))]));
812+
decodeSqliteOps(await foregroundCall('opsByOpRefs', [opRefs.map((r) => Array.from(r))]));
819813
const treeChildrenImpl = async (parent: string) =>
820-
decodeSqliteNodeIds(await call('treeChildren', [parent]));
814+
decodeSqliteNodeIds(await foregroundCall('treeChildren', [parent]));
821815
const treeChildrenPageImpl = async (
822816
parent: string,
823817
cursor: { orderKey: Uint8Array; node: Uint8Array } | null,
@@ -826,34 +820,42 @@ function makeTreecrdtClientFromCall(opts: {
826820
const rpcCursor = cursor
827821
? { orderKey: Array.from(cursor.orderKey), node: Array.from(cursor.node) }
828822
: null;
829-
return decodeSqliteTreeChildRows(await call('treeChildrenPage', [parent, rpcCursor, limit]));
823+
return decodeSqliteTreeChildRows(
824+
await foregroundCall('treeChildrenPage', [parent, rpcCursor, limit]),
825+
);
830826
};
831-
const treeDumpImpl = async () => decodeSqliteTreeRows(await call('treeDump', []));
832-
const treeNodeCountImpl = async () => Number(await call('treeNodeCount', []));
827+
const treeDumpImpl = async () => decodeSqliteTreeRows(await foregroundCall('treeDump', []));
828+
const treeNodeCountImpl = async () => Number(await foregroundCall('treeNodeCount', []));
833829
const treeParentImpl = async (node: string) => {
834-
const result = await call('treeParent', [node]);
830+
const result = await foregroundCall('treeParent', [node]);
835831
if (result === null) return null;
836832
return nodeIdFromBytes16(toRpcBytes(result));
837833
};
838-
const treeExistsImpl = async (node: string) => Boolean(await call('treeExists', [node]));
834+
const treeExistsImpl = async (node: string) =>
835+
Boolean(await foregroundCall('treeExists', [node]));
839836
const treeGetPayloadImpl = async (node: string) => {
840-
const result = await call('treePayload', [node]);
837+
const result = await foregroundCall('treePayload', [node]);
841838
return result === null ? null : toRpcBytes(result);
842839
};
843-
const headLamportImpl = async () => Number(await call('headLamport', []));
840+
const headLamportImpl = async () => Number(await foregroundCall('headLamport', []));
844841
const replicaMaxCounterImpl = async (replica: Operation['meta']['id']['replica']) =>
845-
Number(await call('replicaMaxCounter', [Array.from(encodeReplica(replica))]));
842+
Number(await foregroundCall('replicaMaxCounter', [Array.from(encodeReplica(replica))]));
846843
const appendManyImpl = async (operations: Operation[], writeOpts?: WriteOptions) => {
844+
const callOptions = writeOpts?.priority ? { priority: writeOpts.priority } : undefined;
847845
if (operations.length <= APPEND_MANY_RPC_CHUNK_SIZE) {
848-
const outcome = await call('appendMany', [operations]);
846+
const outcome = await call('appendMany', [operations], callOptions);
849847
materialized.emitOutcome(outcome, writeOpts?.writeId);
850848
return;
851849
}
852850

853851
const outcomes: MaterializationOutcome[] = [];
854852
for (let start = 0; start < operations.length; start += APPEND_MANY_RPC_CHUNK_SIZE) {
855853
outcomes.push(
856-
await call('appendMany', [operations.slice(start, start + APPEND_MANY_RPC_CHUNK_SIZE)]),
854+
await call(
855+
'appendMany',
856+
[operations.slice(start, start + APPEND_MANY_RPC_CHUNK_SIZE)],
857+
callOptions,
858+
),
857859
);
858860
}
859861
materialized.emitOutcome(mergeMaterializationOutcomes(outcomes), writeOpts?.writeId);
@@ -928,7 +930,8 @@ function makeTreecrdtClientFromCall(opts: {
928930
runner,
929931
ops: {
930932
append: async (op, writeOpts?: WriteOptions) => {
931-
const outcome = await call('append', [op]);
933+
const callOptions = writeOpts?.priority ? { priority: writeOpts.priority } : undefined;
934+
const outcome = await call('append', [op], callOptions);
932935
materialized.emitOutcome(outcome, writeOpts?.writeId);
933936
},
934937
appendMany: appendManyImpl,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { RpcCall, RpcCallOptions } from './types.js';
2+
3+
export type RpcSchedulePriority = NonNullable<RpcCallOptions['priority']> | 'normal';
4+
5+
type ScheduledJob = {
6+
priority: RpcSchedulePriority;
7+
run: () => Promise<unknown>;
8+
resolve: (value: unknown) => void;
9+
reject: (reason: unknown) => void;
10+
};
11+
12+
// Bound foreground bypasses so a steady read stream cannot starve sync forever.
13+
const MAX_FOREGROUND_BURST = 8;
14+
15+
/** Serializes RPC work while allowing reads to bypass only explicitly-background work. */
16+
export function createRpcScheduler() {
17+
const queue: ScheduledJob[] = [];
18+
let running = false;
19+
let foregroundBurst = 0;
20+
21+
const nextJobIndex = (): number => {
22+
// A normal call is an ordering barrier: a later read must not jump an earlier local write.
23+
const normalBarrier = queue.findIndex((job) => job.priority === 'normal');
24+
const foreground = queue.findIndex(
25+
(job, index) =>
26+
job.priority === 'foreground' && (normalBarrier === -1 || index < normalBarrier),
27+
);
28+
if (
29+
foreground !== -1 &&
30+
(foregroundBurst < MAX_FOREGROUND_BURST || queue[0]?.priority === 'foreground')
31+
) {
32+
return foreground;
33+
}
34+
return 0;
35+
};
36+
37+
const drain = () => {
38+
if (running || queue.length === 0) return;
39+
const [job] = queue.splice(nextJobIndex(), 1);
40+
if (!job) return;
41+
running = true;
42+
foregroundBurst = job.priority === 'foreground' ? foregroundBurst + 1 : 0;
43+
void Promise.resolve()
44+
.then(job.run)
45+
.then(job.resolve, job.reject)
46+
.finally(() => {
47+
running = false;
48+
drain();
49+
});
50+
};
51+
52+
return <T>(priority: RpcSchedulePriority, run: () => Promise<T>): Promise<T> =>
53+
new Promise<T>((resolve, reject) => {
54+
queue.push({
55+
priority,
56+
run,
57+
resolve: resolve as (value: unknown) => void,
58+
reject,
59+
});
60+
drain();
61+
});
62+
}
63+
64+
/** Applies priority scheduling before a dedicated worker request is posted. */
65+
export function createPrioritizedRpcCall(runRaw: RpcCall): RpcCall {
66+
const schedule = createRpcScheduler();
67+
68+
return (method, params, options) =>
69+
schedule(options?.priority ?? 'normal', () => runRaw(method, params));
70+
}

packages/treecrdt-wa-sqlite/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ export type MessagePortProxy = {
7171
removeEventListener: (type: 'message' | 'messageerror', fn: (ev: any) => void) => void;
7272
};
7373

74+
export type RpcCallOptions = { priority?: 'foreground' | 'background' };
7475
export type RpcCall = <M extends RpcMethod>(
7576
method: M,
7677
params: RpcParams<M>,
78+
options?: RpcCallOptions,
7779
) => Promise<RpcResult<M>>;
7880
export type SharedWorkerFactory = (options?: WorkerOptions & { name?: string }) => SharedWorker;
7981
export type CrossTabMaterializationScope = {

0 commit comments

Comments
 (0)