Skip to content

Commit 5d14cae

Browse files
feat(sync): add balanced subtree resync benchmarks
1 parent 22f346e commit 5d14cae

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

docs/BENCHMARKS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pnpm benchmark:postgres
3535

3636
- First view on a new device, structure only: `benchmark:sync:*` with `sync-balanced-children-cold-start`
3737
- First view on a new device, with payloads: `benchmark:sync:*` with `sync-balanced-children-payloads-cold-start`
38+
- Re-sync the same subtree on a restarted client that already has that scope locally: `benchmark:sync:*` with `sync-balanced-children-resync` or `sync-balanced-children-payloads-resync`
3839
- Single end-to-end time-to-first-visible-page number: `benchmark:sync:*` with the same balanced workloads plus `--first-view`
3940
- One-time bootstrap/discovery tax before opening the regional websocket: `benchmark:sync:bootstrap`
4041
- Local render cost after the data is already present: `benchmark:sqlite-node:note-paths -- --benches=read-children-payloads`
@@ -78,6 +79,25 @@ pnpm benchmark:sync:remote -- \
7879

7980
Use `--fanout=20` when you want to model a broader notebook tree.
8081

82+
### Re-Sync The Same Subtree
83+
84+
Balanced-tree re-sync is the closest current benchmark to "restart a client that
85+
already has this subtree locally, then reconcile that same scope again".
86+
87+
```sh
88+
pnpm sync-server:postgres:db:start
89+
pnpm benchmark:sync:local -- \
90+
--workloads=sync-balanced-children-resync,sync-balanced-children-payloads-resync \
91+
--counts=10000,100000 \
92+
--fanout=10
93+
pnpm sync-server:postgres:db:stop
94+
```
95+
96+
These workloads keep the same balanced immediate-subtree shape as the first-view
97+
benchmarks, but the receiver already has the current scoped result. That means
98+
they measure the normal non-empty scoped reconcile path instead of the
99+
empty-receiver direct-send shortcut.
100+
81101
### Prime Sync Server Fixtures
82102

83103
Use this when you want to prebuild sync-server fixtures before running the actual sync benchmarks.
@@ -411,6 +431,8 @@ Product-facing defaults:
411431
- `sync-one-missing`: narrow protocol baseline for a tiny delta
412432
- `sync-balanced-children-cold-start`: new device already knows the scope root and pulls the immediate children of a node from a balanced tree
413433
- `sync-balanced-children-payloads-cold-start`: same balanced-tree cold-start path, plus payloads
434+
- `sync-balanced-children-resync`: same balanced immediate-subtree shape, but the client already has that scoped result locally and re-runs scoped reconcile
435+
- `sync-balanced-children-payloads-resync`: same balanced re-sync path, plus payloads for the scope root and those immediate children
414436

415437
Specialized or synthetic workloads:
416438

packages/sync/server/postgres-node/src/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ type DocContext = {
9595

9696
type PostgresNodeDocStore = {
9797
provider: WebSocketSyncServerDocProvider<Operation>;
98+
// When the exact applied ops are available in-process, peers can use the
99+
// fast delta path. Cross-process invalidation only knows "this doc changed",
100+
// so callers may omit ops and force peers to rescan their subscriptions.
98101
notifyDocUpdate: (docId: string, ops?: readonly Operation[]) => void;
99102
closeAll: () => Promise<void>;
100103
};
@@ -482,6 +485,10 @@ export function createPostgresNodeDocStore(
482485
let closing = false;
483486
let closeAllPromise: Promise<void> | undefined;
484487

488+
// `ops` is optional because not every update source can provide the exact
489+
// applied batch. Local applyOps calls can forward precise ops to in-process
490+
// peers, while pg_notify-style cross-process invalidation only reports the
491+
// doc id and falls back to subscription rescans.
485492
const notifyDocUpdate = (docId: string, ops?: readonly Operation[]): void => {
486493
const ctx = docs.get(docId);
487494
if (!ctx) return;

packages/treecrdt-benchmark/src/sync.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { benchTiming } from './timing.js';
66
export type SyncBenchWorkload =
77
| 'sync-all'
88
| 'sync-balanced-children-cold-start'
9+
| 'sync-balanced-children-resync'
910
| 'sync-balanced-children-payloads-cold-start'
11+
| 'sync-balanced-children-payloads-resync'
1012
| 'sync-children'
1113
| 'sync-children-cold-start'
1214
| 'sync-children-payloads'
@@ -25,6 +27,10 @@ export const DEFAULT_SYNC_BENCH_WORKLOADS = [
2527
'sync-balanced-children-cold-start',
2628
'sync-balanced-children-payloads-cold-start',
2729
] as const satisfies readonly SyncBenchWorkload[];
30+
export const PRODUCT_RESYNC_SYNC_BENCH_WORKLOADS = [
31+
'sync-balanced-children-resync',
32+
'sync-balanced-children-payloads-resync',
33+
] as const satisfies readonly SyncBenchWorkload[];
2834
export const SYNTHETIC_SYNC_BENCH_WORKLOADS = [
2935
'sync-all',
3036
'sync-children',
@@ -34,6 +40,7 @@ export const SYNTHETIC_SYNC_BENCH_WORKLOADS = [
3440
] as const satisfies readonly SyncBenchWorkload[];
3541
export const ALL_SYNC_BENCH_WORKLOADS = [
3642
...DEFAULT_SYNC_BENCH_WORKLOADS,
43+
...PRODUCT_RESYNC_SYNC_BENCH_WORKLOADS,
3744
...SYNTHETIC_SYNC_BENCH_WORKLOADS,
3845
] as const satisfies readonly SyncBenchWorkload[];
3946
export const DEFAULT_SYNC_BENCH_ROOT_CHILDREN_WORKLOADS = [
@@ -252,6 +259,89 @@ function buildBalancedChildrenColdStartCase(opts: {
252259
};
253260
}
254261

262+
function buildBalancedChildrenResyncCase(opts: {
263+
size: number;
264+
fanout: number;
265+
replicas: { s: ReplicaId; p: ReplicaId };
266+
root: string;
267+
payloadBytes: number;
268+
withPayloads: boolean;
269+
}): SyncBenchCase {
270+
const treeSize = opts.size;
271+
if (!Number.isInteger(treeSize) || treeSize <= opts.fanout) {
272+
throw new Error(`balanced children re-sync requires size > fanout (${opts.fanout})`);
273+
}
274+
275+
const sharedOps = buildFanoutInsertTreeOps({
276+
replica: opts.replicas.s,
277+
size: treeSize,
278+
fanout: opts.fanout,
279+
root: opts.root,
280+
});
281+
const scopeRootInsert = sharedOps[0];
282+
if (!scopeRootInsert || scopeRootInsert.kind.type !== 'insert') {
283+
throw new Error('expected balanced tree seed to start with scope root insert');
284+
}
285+
286+
const targetParent = scopeRootInsert.kind.node;
287+
const targetChildren = targetChildrenForFirstChild(treeSize, opts.fanout);
288+
const scopedNodes = new Set([targetParent, ...targetChildren]);
289+
const opsB: Operation[] = [...sharedOps];
290+
291+
if (opts.withPayloads) {
292+
let counter = 0;
293+
let lamport = maxLamport(sharedOps);
294+
for (let i = 0; i < sharedOps.length; i += 1) {
295+
const op = sharedOps[i];
296+
if (op?.kind.type !== 'insert') continue;
297+
opsB.push(
298+
makeOp(opts.replicas.p, ++counter, ++lamport, {
299+
type: 'payload',
300+
node: op.kind.node,
301+
payload: payloadBytesFromSeed(i + 1, opts.payloadBytes),
302+
}),
303+
);
304+
}
305+
}
306+
307+
// Re-sync benchmarks model a client that already has the exact scoped result
308+
// from an earlier session, so reconcile should confirm "already in sync"
309+
// instead of transferring the subtree again.
310+
const opsA = opsB.filter((op) => {
311+
if (op.kind.type === 'insert') {
312+
return op.kind.node === targetParent || op.kind.parent === targetParent;
313+
}
314+
if (op.kind.type === 'payload') {
315+
return scopedNodes.has(op.kind.node);
316+
}
317+
return false;
318+
});
319+
320+
return {
321+
name: `sync-balanced-children${opts.withPayloads ? '-payloads' : ''}-resync-fanout${opts.fanout}-${treeSize}`,
322+
opsA,
323+
opsB,
324+
filter: { children: { parent: nodeIdToBytes16(targetParent) } },
325+
totalOps: 0,
326+
extra: {
327+
treeSize,
328+
fanout: opts.fanout,
329+
targetParent,
330+
targetDepth: 1,
331+
targetChildren: targetChildren.length,
332+
coldStart: false,
333+
balancedTree: true,
334+
knownScopeRoot: true,
335+
nonEmptyLocalResult: true,
336+
payloadBytes: opts.withPayloads ? opts.payloadBytes : 0,
337+
payloadsEverywhere: opts.withPayloads,
338+
pageSize: Math.min(DEFAULT_SYNC_BENCH_PAGE_SIZE, targetChildren.length),
339+
},
340+
expectedFinalOpsA: opsA.length,
341+
expectedFinalOpsB: opsB.length,
342+
};
343+
}
344+
255345
export function buildSyncBenchCase(opts: {
256346
workload: SyncBenchWorkload;
257347
size: number;
@@ -328,6 +418,28 @@ export function buildSyncBenchCase(opts: {
328418
});
329419
}
330420

421+
if (workload === 'sync-balanced-children-resync') {
422+
return buildBalancedChildrenResyncCase({
423+
size,
424+
fanout,
425+
replicas: { s: replicas.s, p: replicas.p },
426+
root,
427+
payloadBytes,
428+
withPayloads: false,
429+
});
430+
}
431+
432+
if (workload === 'sync-balanced-children-payloads-resync') {
433+
return buildBalancedChildrenResyncCase({
434+
size,
435+
fanout,
436+
replicas: { s: replicas.s, p: replicas.p },
437+
root,
438+
payloadBytes,
439+
withPayloads: true,
440+
});
441+
}
442+
331443
if (workload === 'sync-root-children-fanout10') {
332444
const treeSize = size;
333445
if (treeSize < fanout)

0 commit comments

Comments
 (0)