Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
f12e76a
feat(benchmark): target real note tree sync paths
marcus-pousette Mar 20, 2026
4ebbb8f
feat(benchmark): add sync time-to-first-view metric
marcus-pousette Mar 20, 2026
845bee6
feat(benchmark): profile sync backend calls
marcus-pousette Mar 20, 2026
3c7c211
feat(sync): shortcut small scoped cold-start sync
marcus-pousette Mar 21, 2026
5f058a1
feat(benchmark): reuse local sync fixtures across samples
marcus-pousette Mar 21, 2026
22722ff
feat(benchmark): cache local sync fixtures across runs
marcus-pousette Mar 21, 2026
74c888f
feat(benchmark): add local sync fixture priming
marcus-pousette Mar 21, 2026
5503789
fix(benchmark): use builtin websocket for remote sync benches
marcus-pousette Mar 21, 2026
c080c96
fix(riblt-wasm): disable wasm-opt in release builds
marcus-pousette Mar 21, 2026
01ce273
fix(benchmark): avoid recursive remote websocket cleanup
marcus-pousette Mar 22, 2026
f6c9e13
fix(sync-server): use dedicated readiness pg client
marcus-pousette Mar 22, 2026
2749a8e
feat(benchmark): tune sync ops batch size
marcus-pousette Mar 22, 2026
e78bad7
feat(benchmark): add post-seed cooldown probe
marcus-pousette Mar 22, 2026
f77f268
fix(benchmark): avoid full remote verifier sync
marcus-pousette Mar 22, 2026
db41f40
fix(sync-server): avoid overlapping pg control queries
marcus-pousette Mar 22, 2026
f846ce8
feat(benchmark): support remote fixture reuse
marcus-pousette Mar 22, 2026
14397ba
fix(sync): wait for uploaded batches to apply
marcus-pousette Mar 22, 2026
6dfa2b8
feat(benchmark): add explicit sync upload benchmark
marcus-pousette Mar 22, 2026
008c8f5
feat(sync): shortcut uploads to empty receivers
marcus-pousette Mar 22, 2026
f74853f
fix(benchmark): use fresh remote docs for rebuild
marcus-pousette Mar 22, 2026
2fe8856
fix(sync): defer large postgres upload materialization
marcus-pousette Mar 22, 2026
86e5758
perf(postgres): bulk insert deferred sync uploads
marcus-pousette Mar 22, 2026
a6d75ae
perf(postgres): bulk insert incremental sync uploads
marcus-pousette Mar 22, 2026
e2c9204
feat(debug): profile postgres sync upload phases
marcus-pousette Mar 22, 2026
21a86b1
perf(postgres): batch index and last-change writes
marcus-pousette Mar 22, 2026
4e325e0
perf(postgres): preload nodes for payload batches
marcus-pousette Mar 22, 2026
839ce19
style(rust): format postgres changes
marcus-pousette Mar 23, 2026
e305622
fix(clippy): avoid op-ref allocation
marcus-pousette Mar 23, 2026
16bd296
docs: clarify sync and materialization fast paths
marcus-pousette Mar 23, 2026
76bf329
fix(sync): refresh replay capabilities on live pushes
marcus-pousette Mar 24, 2026
315c49e
style(playground): stop rotating sync status icon
marcus-pousette Mar 24, 2026
01c36bf
fix(playground): improve new-doc and remote sync flows
marcus-pousette Mar 24, 2026
48c25ff
perf(sync): push live ops directly
marcus-pousette Mar 24, 2026
4559245
perf(sync): delta-push live all subscriptions
marcus-pousette Mar 24, 2026
f0a2a92
fix(sync-server): forward exact ops to local subscribers
marcus-pousette Mar 24, 2026
327ca43
fix(playground): align sync hook auth options
marcus-pousette Mar 29, 2026
c2f97f1
style(ts): format sync benchmark stack files
marcus-pousette Mar 29, 2026
59a70af
fix(wa-sqlite): restore closed client export
marcus-pousette Mar 29, 2026
379d7e9
fix(wa-sqlite): restore client close and drop semantics
marcus-pousette Mar 29, 2026
3ebbe07
docs(sync): clarify SyncPeer option semantics
marcus-pousette Apr 1, 2026
c9501b9
refactor(sync): move hello tracing out of protocol env
marcus-pousette Apr 1, 2026
0705c6b
docs(sync): explain hello direct-send capabilities
marcus-pousette Apr 1, 2026
b8036e2
refactor(sync): clarify direct push stream ids
marcus-pousette Apr 1, 2026
2fdabc9
refactor(sync): dedupe ops batch helpers
marcus-pousette Apr 1, 2026
fdf5f2e
refactor(sync): extract trace helpers
marcus-pousette Apr 1, 2026
bc3e42d
merge main into sync/tree-sync-perf-benchmarks
marcus-pousette Apr 1, 2026
867a651
refactor(sync): extract capability helpers
marcus-pousette Apr 2, 2026
45a9f99
refactor(sync): centralize initiator hello capabilities
marcus-pousette Apr 2, 2026
624367d
docs(sync): clarify reconcile and subscription flows
marcus-pousette Apr 2, 2026
dfe707e
style(sync): format protocol and server files
marcus-pousette Apr 2, 2026
cd799d6
docs(bench): clarify sync runner scope
marcus-pousette Apr 2, 2026
fe3e836
fix(postgres): preserve multiplicity in bulk append dedupe
marcus-pousette Apr 2, 2026
767a5e7
refactor(postgres): move append profiling into module
marcus-pousette Apr 2, 2026
f930171
docs(postgres): clarify append materialization flow
marcus-pousette Apr 2, 2026
ed100d5
docs(postgres): explain preload and flush paths
marcus-pousette Apr 2, 2026
801b834
perf(postgres): cache hot read statements
marcus-pousette Apr 2, 2026
9486880
refactor(postgres): extract read queries from store
marcus-pousette Apr 2, 2026
5fea383
refactor(postgres): extract local ops from store
marcus-pousette Apr 2, 2026
37b9976
feat(sync): add balanced subtree resync benchmarks
marcus-pousette Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pnpm build
pnpm test
```

## Benchmarks
For benchmark commands, product-facing note/sync scenarios, and the sync target matrix (`direct`, local Postgres sync server, remote sync server), see [docs/BENCHMARKS.md](docs/BENCHMARKS.md).

## Playground
- Live demo (GitHub Pages): https://cybersemics.github.io/treecrdt/

Expand Down
385 changes: 385 additions & 0 deletions docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
# Benchmarks

The benchmark suite is most useful when you treat it as a set of product questions, not just a set of packages.

## Start Here

From the repo root:

```sh
pnpm benchmark
pnpm benchmark:sync:help
```

Useful top-level entrypoints:

```sh
pnpm benchmark
pnpm benchmark:sqlite-node
pnpm benchmark:sqlite-node:ops
pnpm benchmark:sqlite-node:note-paths
pnpm benchmark:sync
pnpm benchmark:sync:direct
pnpm benchmark:sync:local
pnpm benchmark:sync:prime
pnpm benchmark:sync:remote
pnpm benchmark:web
pnpm benchmark:wasm
pnpm benchmark:postgres
```

`pnpm benchmark` writes JSON results under `benchmarks/`.

## Which Benchmark Answers What?

- First view on a new device, structure only: `benchmark:sync:*` with `sync-balanced-children-cold-start`
- First view on a new device, with payloads: `benchmark:sync:*` with `sync-balanced-children-payloads-cold-start`
- 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`
- Single end-to-end time-to-first-visible-page number: `benchmark:sync:*` with the same balanced workloads plus `--first-view`
- Local render cost after the data is already present: `benchmark:sqlite-node:note-paths -- --benches=read-children-payloads`
- Local mutation cost inside a large existing tree: `benchmark:sqlite-node:note-paths -- --benches=insert-into-large-tree`
- Protocol/storage baselines and worst-case stress: `sync-one-missing`, `sync-all`, `sync-children*`, `sync-root-children-fanout10`

That split is intentional:

- The sync benches answer "how long until the needed subtree data is in the local store?"
- The note-path benches answer "once the data is local, how quickly can the app render and mutate it?"

## Recommended Product-Facing Runs

### First View Sync

Balanced-tree cold-start sync is the closest current benchmark to "open a node on a fresh device and load the first visible page".

```sh
pnpm benchmark:sync:direct -- \
--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \
--counts=10000,50000,100000 \
--fanout=10
```

```sh
pnpm sync-server:postgres:db:start
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \
--counts=10000,50000,100000 \
--fanout=10
pnpm sync-server:postgres:db:stop
```

```sh
TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \
pnpm benchmark:sync:remote -- \
--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \
--counts=10000,50000,100000 \
--fanout=10
```

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

### Re-Sync The Same Subtree

Balanced-tree re-sync is the closest current benchmark to "restart a client that
already has this subtree locally, then reconcile that same scope again".

```sh
pnpm sync-server:postgres:db:start
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-resync,sync-balanced-children-payloads-resync \
--counts=10000,100000 \
--fanout=10
pnpm sync-server:postgres:db:stop
```

These workloads keep the same balanced immediate-subtree shape as the first-view
benchmarks, but the receiver already has the current scoped result. That means
they measure the normal non-empty scoped reconcile path instead of the
empty-receiver direct-send shortcut.

### Prime Sync Server Fixtures

Use this when you want to prebuild sync-server fixtures before running the actual sync benchmarks.

```sh
pnpm benchmark:sync:prime
```

By default this primes the read-only first-view workloads for `10k`, `50k`, and `100k` nodes and forces a rebuild. After that, matching local benchmark runs reuse those fixtures as cache hits instead of reimporting the same large server docs.

You can still override the forwarded args:

```sh
pnpm benchmark:sync:prime -- \
--workloads=sync-balanced-children-payloads-cold-start \
--counts=50000,100000 \
--server-fixture-cache=rebuild
```

You can also prime the remote target explicitly:

```sh
TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \
pnpm benchmark:sync:remote prime -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--server-fixture-cache=rebuild
```

Then benchmark against that already-seeded remote doc without reseeding:

```sh
TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \
pnpm benchmark:sync:remote -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--first-view \
--server-fixture-cache=reuse
```

By default, the local sync target runs the Postgres sync server in a spawned child process so local and remote measurements are closer to each other. When you add `--profile-backend`, the local target intentionally switches to the in-process server so per-backend timings are visible inside the benchmark process.

Local server benchmarks now seed the Postgres backend directly before the timer starts. That keeps the measured path honest, because the actual sync to the client still goes through the real websocket server, while avoiding huge protocol-seed setup costs that are not part of the benchmark question.

For read-only local server workloads, the harness now prepares that server fixture once per benchmark case and reuses it across warmup and measured samples. It also reuses the same seeded Postgres fixture across separate benchmark runs by default when the workload definition matches, so repeated `50k/100k` runs do not keep reimporting the same large server doc.

Use `--server-fixture-cache=rebuild` when you want to force a fresh fixture, or `--server-fixture-cache=off` when you want every run to seed an isolated throwaway fixture. For remote fixtures, `--server-fixture-cache=reuse` assumes the deterministic fixture doc already exists and skips reseeding.

### Time To First Visible Page

Add `--first-view` when you want one number that includes:

- scoped sync into the local store
- the immediate local `childrenPage(...)` read
- payload fetches for the parent and visible children when the workload carries payloads

```sh
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-payloads-cold-start \
--counts=10000 \
--fanout=10 \
--first-view
```

For custom `--count` or `--counts` runs, the sync bench now defaults to multiple measured samples instead of silently falling back to one. Use `--iterations=N` and `--warmup=N` when you want explicit control over stability versus runtime.

Add `--post-seed-wait-ms=N` when you want to probe whether immediate post-upload backlog is skewing the measured first-view path. This is mainly a debugging aid for remote runs.

### Upload Benchmarks

Use prime/upload mode when you want an explicit benchmark for seeding a sync-server doc.

This measures the full server-fixture creation path and writes a result file under `benchmarks/sqlite-node-sync/server-fixture-*.json` with `durationMs`, `opsPerSec`, and the seeded `fixtureOpCount`.

```sh
TREECRDT_SYNC_SERVER_URL=ws://host/sync \
pnpm benchmark:sync:upload:remote -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--server-fixture-cache=rebuild
```

Use this to answer a different question than first-view:

- `benchmark:sync:remote ... --first-view` answers "how fast can a new device open an existing subtree?"
- `benchmark:sync:upload:remote ...` answers "how long does it take to upload and materialize a large tree on the sync server?"

### Small-Scope Direct Send

Add `--direct-send-threshold=N` when you want to experiment with a clean-slate shortcut for small scoped syncs.

When enabled, if the requesting peer has an empty local result for the requested filter and the responder has at most `N` matching ops, the protocol skips the RIBLT round and sends the scoped ops directly in `opsBatch`.

This is most relevant for first-view note loading where the client knows the scope root but has not synced its immediate children yet.

```sh
TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--fanout=10 \
--first-view \
--direct-send-threshold=64
```

Add `--max-ops-per-batch=N` when you want to force smaller `opsBatch` messages. This is useful for stress-testing large upload paths and for debugging remote seed behavior where very large inbound batches may monopolize a server task.

```sh
TREECRDT_SYNC_SERVER_URL=ws://host/sync \
pnpm benchmark:sync:remote -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--first-view \
--direct-send-threshold=64 \
--max-ops-per-batch=500
```

### Backend Call Profiling

Add `--profile-backend` when you want per-backend timings for:

- `listOpRefs`
- `getOpsByOpRefs`
- `applyOps`

This is especially useful on the local Postgres sync-server target because it shows whether the bottleneck is on the client SQLite side or the server Postgres side.

```sh
TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \
--count=10000 \
--fanout=10 \
--profile-backend
```

### Transport Profiling

Add `--profile-transport` when you want sync message counts, encoded byte counts, and a short event timeline showing where time is spent across the handshake, RIBLT exchange, and ops batches.

```sh
TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \
--count=10000 \
--fanout=10 \
--profile-transport
```

### Hello Stage Profiling

Add `--profile-hello` when you want the responder-side `hello -> helloAck` path broken into internal stages such as:

- `maxLamport`
- `listOpRefs`
- `filterOutgoingOps` when auth filtering is active
- decoder setup
- `helloAck` send

This is the right profiler when the coarse transport timeline says `hello -> helloAck` is expensive and you need to know whether that cost is database work, auth filtering, or protocol setup.

```sh
TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \
pnpm benchmark:sync:local -- \
--workloads=sync-balanced-children-payloads-cold-start \
--count=10000 \
--fanout=10 \
--first-view \
--profile-hello
```

For `local-postgres-sync-server`, child-process runs capture hello traces by parsing the server process output. For `direct` and in-process debug runs, the benchmark collects the same trace in-process without writing debug noise into the result stream. Remote runs currently only have the coarse transport profile, not internal server hello stages.

### Local First View Read Path

This measures the app-shaped local read immediately after sync: fetch the visible children page plus payloads for the parent and those children.

```sh
pnpm benchmark:sqlite-node:note-paths -- \
--benches=read-children-payloads \
--counts=10000,50000,100000 \
--fanout=10 \
--page-size=10 \
--payload-bytes=512
```

### Local Mutation in a Large Tree

This measures inserting one node with a payload into an already-large balanced tree.

```sh
pnpm benchmark:sqlite-node:note-paths -- \
--benches=insert-into-large-tree \
--counts=10000,50000,100000 \
--fanout=10 \
--payload-bytes=512
```

## Sync Targets

The sync runner supports the same workload definitions across multiple environments:

- `direct`: in-memory connected peers, no sync server
- `local-postgres-sync-server`: local WebSocket sync server backed by Postgres
- `remote-sync-server`: remote WebSocket sync server

That keeps the workload constant while you compare transport and backend behavior.

### Local Postgres Defaults

```sh
postgres://postgres:postgres@127.0.0.1:5432/postgres
```

Override with `TREECRDT_POSTGRES_URL` or `--postgres-url=...`.

The Docker helper is only a convenience. The local sync benchmark just needs a reachable Postgres URL, so a native local Postgres instance works too:

```sh
TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \
pnpm benchmark:sync:local -- --workloads=sync-balanced-children-payloads-cold-start --count=10000
```

### Remote Sync Server URL

The remote URL is intentionally not hardcoded in this repo. Different deployments can have different latency, auth, retention, and scaling settings, so pass it at runtime through `TREECRDT_SYNC_SERVER_URL` or `--sync-server-url=...`.

For public HTTPS deployments, prefer `wss://.../sync`. Use `ws://.../sync` for local or other plain HTTP deployments.

## Current Sync Workloads

Current sync workload definitions live in `packages/treecrdt-benchmark/src/sync.ts`.

Product-facing defaults:

- `sync-one-missing`: narrow protocol baseline for a tiny delta
- `sync-balanced-children-cold-start`: new device already knows the scope root and pulls the immediate children of a node from a balanced tree
- `sync-balanced-children-payloads-cold-start`: same balanced-tree cold-start path, plus payloads
- `sync-balanced-children-resync`: same balanced immediate-subtree shape, but the client already has that scoped result locally and re-runs scoped reconcile
- `sync-balanced-children-payloads-resync`: same balanced re-sync path, plus payloads for the scope root and those immediate children

Specialized or synthetic workloads:

- `sync-all`: overlapping divergent peers reconcile all ops
- `sync-children`: scoped sync against a synthetic high-fanout parent
- `sync-children-cold-start`: same synthetic high-fanout shape in one-way mode
- `sync-children-payloads`: synthetic high-fanout subtree with payloads
- `sync-children-payloads-cold-start`: one-way version of that same synthetic high-fanout payload case
- `sync-root-children-fanout10`: balanced-tree root-children delta with a move boundary case

The `sync-children*` workloads are still worth keeping because they act as worst-case or stress-style scoped sync scenarios. They are not the best default proxy for normal note-taking, because they put a very large number of direct children under one parent.

## Useful Flags

All sync entrypoints forward arguments to `packages/treecrdt-sqlite-node/scripts/bench-sync.ts`.

Common sync flags:

- `--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start`
- `--counts=100,1000,10000`
- `--count=1000`
- `--storages=memory,file`
- `--targets=direct,local-postgres-sync-server`
- `--fanout=10`
- `--first-view`
- `--iterations=5`
- `--warmup=1`
- `--profile-backend`
- `--profile-transport`
- `--profile-hello`
- `--sync-server-url=ws://host/sync`
- `--postgres-url=postgres://...`

Common note-path flags:

- `--benches=read-children-payloads,insert-into-large-tree`
- `--counts=10000,50000,100000`
- `--fanout=10`
- `--page-size=10`
- `--payload-bytes=512`

## What Is Still Missing?

The remaining gaps are mostly infrastructure-related now:

- a healthy, repeatable local Postgres bootstrap path that does not depend on a stuck Docker daemon
- a working public websocket deployment path for the remote sync target
Loading
Loading