Skip to content

Commit 48ab5ef

Browse files
Yield between inbound sync apply batches
1 parent a551889 commit 48ab5ef

5 files changed

Lines changed: 205 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@treecrdt/sync-protocol': patch
3+
---
4+
5+
Yield between queued inbound sync apply batches so UI work can run between remote opsBatch applies.

packages/sync-protocol/protocol/src/sync.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1675,7 +1675,10 @@ export class SyncPeer<Op> {
16751675
.catch(() => {
16761676
// A prior batch failure should not permanently poison the queue.
16771677
})
1678-
.then(() => this.onOpsBatch(transport, batch));
1678+
.then(async () => {
1679+
await this.onOpsBatch(transport, batch);
1680+
if (batch.ops.length > 0 && !batch.done) await yieldToMacrotask();
1681+
});
16791682
this.opsBatchQueues.set(batch.filterId, current);
16801683
try {
16811684
await current;

packages/sync-protocol/protocol/tests/smoke.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ async function tick(): Promise<void> {
3030
await new Promise<void>((resolve) => setTimeout(resolve, 0));
3131
}
3232

33+
function deferredPromise<T = void>() {
34+
let resolve!: (value: T | PromiseLike<T>) => void;
35+
let reject!: (reason?: unknown) => void;
36+
const promise = new Promise<T>((res, rej) => {
37+
resolve = res;
38+
reject = rej;
39+
});
40+
return { promise, resolve, reject };
41+
}
42+
3343
function orderKeyFromPosition(position: number): Uint8Array {
3444
if (!Number.isInteger(position) || position < 0) throw new Error(`invalid position: ${position}`);
3545
const n = position + 1;
@@ -443,6 +453,70 @@ test('syncOnce waits for responder to apply uploaded ops before resolving', asyn
443453
expect(b.hasOp(replicaHex.a, 3)).toBe(true);
444454
});
445455

456+
test('incoming opsBatch queue yields to macrotasks between applies', async () => {
457+
const docId = 'doc-sync-inbound-apply-yield';
458+
const root = '0'.repeat(32);
459+
const firstApplyCanFinish = deferredPromise();
460+
let applyCalls = 0;
461+
let macrotaskRan = false;
462+
let secondApplyStartedAfterMacrotask = false;
463+
464+
class ProbeBackend extends MemoryBackend {
465+
override async applyOps(ops: Operation[]): Promise<void> {
466+
applyCalls += 1;
467+
if (applyCalls === 1) {
468+
await firstApplyCanFinish.promise;
469+
} else if (applyCalls === 2) {
470+
secondApplyStartedAfterMacrotask = macrotaskRan;
471+
}
472+
await super.applyOps(ops);
473+
}
474+
}
475+
476+
const a = new MemoryBackend(docId);
477+
const b = new ProbeBackend(docId);
478+
const ops = [1, 2, 3].map((counter, index) =>
479+
makeOp(replicas.a, counter, counter, {
480+
type: 'insert',
481+
parent: root,
482+
node: nodeIdFromInt(counter),
483+
orderKey: orderKeyFromPosition(index),
484+
}),
485+
);
486+
487+
const [wa, wb] = createMacrotaskDuplex<Uint8Array>();
488+
const ta = wrapDuplexTransportWithCodec(wa, treecrdtSyncV0ProtobufCodec);
489+
const tb = wrapDuplexTransportWithCodec(wb, treecrdtSyncV0ProtobufCodec);
490+
const pa = new SyncPeer(a);
491+
const pb = new SyncPeer(b);
492+
pa.attach(ta);
493+
pb.attach(tb);
494+
495+
const pushDone = pa.pushOps(ta, ops, {
496+
filterId: 'apply-yield-probe',
497+
maxOpsPerBatch: 1,
498+
});
499+
500+
await waitUntil(() => applyCalls === 1, {
501+
message: 'expected first inbound apply to start',
502+
});
503+
await pushDone;
504+
505+
setImmediate(() => {
506+
macrotaskRan = true;
507+
});
508+
firstApplyCanFinish.resolve();
509+
510+
await waitUntil(() => applyCalls >= 2, {
511+
message: 'expected second inbound apply to start',
512+
});
513+
expect(secondApplyStartedAfterMacrotask).toBe(true);
514+
515+
await waitUntil(() => b.hasOp(replicaHex.a, 3), {
516+
message: 'expected all pushed ops to apply',
517+
});
518+
});
519+
446520
test('pushOps uploads direct ops without reconcile roundtrips', async () => {
447521
const docId = 'doc-push-direct';
448522
const root = '0'.repeat(32);

packages/treecrdt-wa-sqlite/e2e/src/runtime-bench.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import { createTreecrdtClient, type TreecrdtClient } from '@treecrdt/wa-sqlite';
22
import { makeOp, nodeIdFromInt, type BenchmarkResult } from '@treecrdt/benchmark';
33
import type { Operation } from '@treecrdt/interface';
4+
import { bytesToHex } from '@treecrdt/interface/ids';
5+
import { SyncPeer, type SyncBackend } from '@treecrdt/sync-protocol';
6+
import { treecrdtSyncV0ProtobufCodec } from '@treecrdt/sync-protocol/protobuf';
7+
import {
8+
createInMemoryDuplex,
9+
wrapDuplexTransportWithCodec,
10+
} from '@treecrdt/sync-protocol/transport';
411
import { replicaFromLabel } from './op-helpers.js';
512

613
type RuntimeChoice = 'direct' | 'dedicated-worker' | 'shared-worker';
714
type StorageChoice = 'memory' | 'opfs';
15+
type RemoteIngestChoice = 'append-many' | 'sync-peer';
816

917
type RuntimeMixedWriteBenchOptions = {
1018
runtime: RuntimeChoice;
1119
storage?: StorageChoice;
20+
remoteIngest?: RemoteIngestChoice;
1221
docId?: string;
1322
filename?: string;
1423
prefillOps?: number;
@@ -25,6 +34,7 @@ type RuntimeMixedWriteBenchResult = BenchmarkResult & {
2534
extra: {
2635
runtime: RuntimeChoice;
2736
storage: StorageChoice;
37+
remoteIngest: RemoteIngestChoice;
2838
prefillOps: number;
2939
remoteOps: number;
3040
remoteBatchSize: number;
@@ -46,6 +56,7 @@ type RuntimeMixedWriteBenchResult = BenchmarkResult & {
4656
readP50Ms: number;
4757
readP95Ms: number;
4858
readMaxMs: number;
59+
interBatchReadMs: number | null;
4960
localWriteIntervalMs: number;
5061
readIntervalMs: number;
5162
yieldBetweenRemoteBatchesMs: number;
@@ -96,6 +107,16 @@ function summarizeDurations(durationsMs: number[]) {
96107
};
97108
}
98109

110+
function deferredPromise<T = void>() {
111+
let resolve!: (value: T | PromiseLike<T>) => void;
112+
let reject!: (reason?: unknown) => void;
113+
const promise = new Promise<T>((res, rej) => {
114+
resolve = res;
115+
reject = rej;
116+
});
117+
return { promise, resolve, reject };
118+
}
119+
99120
function makeInsertOps(opts: {
100121
replica: Uint8Array;
101122
count: number;
@@ -122,6 +143,7 @@ export async function runRuntimeMixedWriteBench(
122143
): Promise<RuntimeMixedWriteBenchResult> {
123144
const runtime = opts.runtime;
124145
const storage = opts.storage ?? 'opfs';
146+
const remoteIngest = opts.remoteIngest ?? 'sync-peer';
125147
const prefillOps = opts.prefillOps ?? 0;
126148
const remoteOps = opts.remoteOps ?? 2_000;
127149
const remoteBatchSize = opts.remoteBatchSize ?? 500;
@@ -164,10 +186,12 @@ export async function runRuntimeMixedWriteBench(
164186
const remoteBatchDurationsMs: number[] = [];
165187
const localWriteDurationsMs: number[] = [];
166188
const readDurationsMs: number[] = [];
189+
let interBatchReadMs: number | null = null;
190+
let interBatchReadDone: Promise<void> | null = null;
167191
let finalChildCount = 0;
168192
const start = performance.now();
169193

170-
const runRemoteIngest = async () => {
194+
const runAppendManyRemoteIngest = async () => {
171195
for (let batchIndex = 0; batchIndex < remoteBatchCount; batchIndex += 1) {
172196
const batchStartCounter = batchIndex * remoteBatchSize + 1;
173197
const remaining = remoteOps - batchIndex * remoteBatchSize;
@@ -195,6 +219,90 @@ export async function runRuntimeMixedWriteBench(
195219
}
196220
};
197221

222+
const runSyncPeerRemoteIngest = async () => {
223+
if (remoteOps === 0) return;
224+
225+
const remoteApplied = deferredPromise();
226+
let appliedRemoteOps = 0;
227+
const receiverBackend: SyncBackend<Operation> = {
228+
docId,
229+
maxLamport: async () => BigInt(await client.meta.headLamport()),
230+
listOpRefs: async (filter) => {
231+
if ('all' in filter) return client.opRefs.all();
232+
return client.opRefs.children(bytesToHex(filter.children.parent));
233+
},
234+
getOpsByOpRefs: async (opRefs) => client.ops.get(opRefs),
235+
applyOps: async (ops) => {
236+
if (ops.length === 0) return;
237+
238+
const batchStart = performance.now();
239+
try {
240+
await client.ops.appendMany(ops);
241+
} catch (error) {
242+
remoteApplied.reject(error);
243+
throw error;
244+
}
245+
246+
remoteBatchDurationsMs.push(performance.now() - batchStart);
247+
const nextAppliedRemoteOps = appliedRemoteOps + ops.length;
248+
if (appliedRemoteOps === 0 && nextAppliedRemoteOps < remoteOps) {
249+
interBatchReadDone = new Promise<void>((resolve, reject) => {
250+
setTimeout(() => {
251+
const readStart = performance.now();
252+
client.tree.children(rootNode()).then(() => {
253+
interBatchReadMs = performance.now() - readStart;
254+
resolve();
255+
}, reject);
256+
}, 0);
257+
});
258+
}
259+
appliedRemoteOps = nextAppliedRemoteOps;
260+
if (appliedRemoteOps >= remoteOps) remoteApplied.resolve();
261+
},
262+
};
263+
const senderBackend: SyncBackend<Operation> = {
264+
docId,
265+
maxLamport: async () => 0n,
266+
listOpRefs: async () => [],
267+
getOpsByOpRefs: async () => [],
268+
applyOps: async () => {},
269+
};
270+
const [wireA, wireB] = createInMemoryDuplex<Uint8Array>();
271+
const transportA = wrapDuplexTransportWithCodec(wireA, treecrdtSyncV0ProtobufCodec);
272+
const transportB = wrapDuplexTransportWithCodec(wireB, treecrdtSyncV0ProtobufCodec);
273+
const senderPeer = new SyncPeer(senderBackend, { maxOpsPerBatch: remoteBatchSize });
274+
const receiverPeer = new SyncPeer(receiverBackend, { maxOpsPerBatch: remoteBatchSize });
275+
const detachSender = senderPeer.attach(transportA);
276+
const detachReceiver = receiverPeer.attach(transportB);
277+
278+
try {
279+
const ops = makeInsertOps({
280+
replica: remoteReplica,
281+
count: remoteOps,
282+
startCounter: 1,
283+
startLamport: prefillOps + 1,
284+
startNodeInt: 100_000,
285+
startOrderOffset: 100_000,
286+
});
287+
await senderPeer.pushOps(transportA, ops, {
288+
filterId: `runtime-sync-peer-${crypto.randomUUID()}`,
289+
maxOpsPerBatch: remoteBatchSize,
290+
});
291+
await remoteApplied.promise;
292+
if (interBatchReadDone) await interBatchReadDone;
293+
} catch (error) {
294+
throw new Error(
295+
`runtime mixed benchmark remote sync ingest failed: ${errorMessage(error)}`,
296+
);
297+
} finally {
298+
detachSender();
299+
detachReceiver();
300+
}
301+
};
302+
303+
const runRemoteIngest =
304+
remoteIngest === 'sync-peer' ? runSyncPeerRemoteIngest : runAppendManyRemoteIngest;
305+
198306
const runLocalWrites = async () => {
199307
for (let i = 0; i < localWrites; i += 1) {
200308
if (i > 0 && localWriteIntervalMs > 0) await sleep(localWriteIntervalMs);
@@ -242,13 +350,14 @@ export async function runRuntimeMixedWriteBench(
242350
const readSummary = summarizeDurations(readDurationsMs);
243351

244352
return {
245-
name: `runtime-mixed-sync-ingest-local-writes-${storage}-${runtime}-prefill-${prefillOps}`,
353+
name: `runtime-mixed-${remoteIngest}-ingest-local-writes-${storage}-${runtime}-prefill-${prefillOps}`,
246354
totalOps,
247355
durationMs,
248356
opsPerSec: durationMs > 0 ? (totalOps / durationMs) * 1000 : Infinity,
249357
extra: {
250358
runtime,
251359
storage,
360+
remoteIngest,
252361
prefillOps,
253362
remoteOps,
254363
remoteBatchSize,
@@ -270,6 +379,7 @@ export async function runRuntimeMixedWriteBench(
270379
readP50Ms: readSummary.p50Ms,
271380
readP95Ms: readSummary.p95Ms,
272381
readMaxMs: readSummary.maxMs,
382+
interBatchReadMs,
273383
localWriteIntervalMs,
274384
readIntervalMs,
275385
yieldBetweenRemoteBatchesMs,

packages/treecrdt-wa-sqlite/e2e/tests/bench-runtime.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { BenchmarkResult } from '@treecrdt/benchmark';
55

66
type RuntimeChoice = 'direct' | 'dedicated-worker' | 'shared-worker';
77
type StorageChoice = 'memory' | 'opfs';
8+
type RemoteIngestChoice = 'append-many' | 'sync-peer';
89
type RuntimeScenario = {
910
id: string;
1011
runtime: RuntimeChoice;
@@ -20,6 +21,7 @@ const defaultScenarios: RuntimeScenario[] = [
2021

2122
const remoteOps = Number(process.env.TREECRDT_RUNTIME_BENCH_REMOTE_OPS ?? 2_000);
2223
const remoteBatchSize = Number(process.env.TREECRDT_RUNTIME_BENCH_REMOTE_BATCH_SIZE ?? 500);
24+
const remoteIngest = envRemoteIngest('TREECRDT_RUNTIME_BENCH_REMOTE_INGEST', 'sync-peer');
2325
const localWrites = Number(process.env.TREECRDT_RUNTIME_BENCH_LOCAL_WRITES ?? 20);
2426
const readSamples = Number(process.env.TREECRDT_RUNTIME_BENCH_READ_SAMPLES ?? 20);
2527
const readIntervalMs = Number(process.env.TREECRDT_RUNTIME_BENCH_READ_INTERVAL_MS ?? 0);
@@ -40,6 +42,12 @@ function envNumberList(name: string, fallback: number[]): number[] {
4042
return values.length > 0 ? values : fallback;
4143
}
4244

45+
function envRemoteIngest(name: string, fallback: RemoteIngestChoice): RemoteIngestChoice {
46+
const raw = process.env[name];
47+
if (raw === 'append-many' || raw === 'sync-peer') return raw;
48+
return fallback;
49+
}
50+
4351
function envScenarioList(name: string, fallback: RuntimeScenario[]): RuntimeScenario[] {
4452
const raw = process.env[name];
4553
if (!raw) return fallback;
@@ -83,6 +91,7 @@ test('wa-sqlite runtime/storage mixed sync-ingest/local-write benchmarks', async
8391
storage: scenario.storage,
8492
docId: `runtime-mixed-${scenario.id}-${prefillOps}-${suffix}`,
8593
filename: `/runtime-mixed-${scenario.id}-${prefillOps}-${suffix}.db`,
94+
remoteIngest,
8695
prefillOps,
8796
remoteOps,
8897
remoteBatchSize,
@@ -97,6 +106,7 @@ test('wa-sqlite runtime/storage mixed sync-ingest/local-write benchmarks', async
97106
expect(result.totalOps).toBe(remoteOps + localWrites);
98107
expect(result.extra.runtime).toBe(scenario.runtime);
99108
expect(result.extra.storage).toBe(scenario.storage);
109+
expect(result.extra.remoteIngest).toBe(remoteIngest);
100110
expect(result.extra.finalChildCount).toBe(prefillOps + remoteOps + localWrites);
101111
expect(result.extra.remoteBatchDurationsMs.length).toBe(
102112
Math.ceil(remoteOps / remoteBatchSize),

0 commit comments

Comments
 (0)