Skip to content

perf: delta sync, path-keyed subscriptions, and proxy caching (#29, #31, #33) - #39

Open
Ayush2k02 wants to merge 7 commits into
zenbu-labs:mainfrom
Ayush2k02:fm/build-zenbu-perf
Open

Ayush2k02 wants to merge 7 commits into
zenbu-labs:mainfrom
Ayush2k02:fm/build-zenbu-perf

Conversation

@Ayush2k02

@Ayush2k02 Ayush2k02 commented Aug 3, 2026

Copy link
Copy Markdown

What

Fixes the three open perf issues: #29 delta sync on reconnect, #31 path-keyed subscriptions, #33 RPC proxy caching.

Why

Reconnects re-sent the whole root document, every subscriber re-rendered on any write regardless of what it actually read, and every RPC call re-created its proxy — all unnecessary work on the hot paths.

How

A monotonic root version counter + bounded op log lets reconnects request a delta instead of the full root; per-hook read-path tracking narrows re-renders to paths actually touched; the RPC proxy is cached and reused instead of rebuilt per call.

Suggested reading order

  1. packages/kyju/src/v2/db/root-version-log.ts
  2. packages/kyju/src/v2/db/handlers/connect.ts
  3. packages/kyju/src/v2/db/handlers/write.ts
  4. packages/kyju/src/v2/react/read-tracking.ts
  5. packages/kyju/src/v2/replica/replica.ts
  6. packages/zenrpc/src/proxy.ts

Derive doc

https://derive.to/artifacts/zenbu-js-perf-trio-pr-1-sm5o46te


Open in Devin Review

zenbu-labs#33)

createProxy allocated a fresh Proxy + path array on every property
access, so repeated calls on the same method path (e.g. `client.users.get`
in a loop, or re-read across renders) re-allocated the whole chain each
time. Each proxy node now caches its children in a Map keyed by property
name, so re-traversing a path returns the same node; only the terminal
apply (which must produce a unique request id per call) still allocates.

Call behavior is unchanged — verified with identity + request-count
tests. Micro-benchmark: traversal-only (no invocation) is ~4x faster;
full round-trip calls (dominated by nanoid + JSON.stringify) see a
smaller but still real ~1.4x.
…writes (zenbu-labs#31)

Replica listeners lived in one flat set, so every write anywhere in the
document invoked every subscriber's callback — a `useDb(root =>
root.app.settings)` hook re-ran its selector on a completely unrelated
write elsewhere in the tree.

`replica.subscribe(cb, getPaths?)` now takes an optional thunk returning
the root paths a listener currently cares about; the notify fiber skips
callbacks whose paths didn't change. The check is cheap and correct
because `setAtPath`/`deleteAtPath` only clone containers along the
write path — comparing `getAtPath(prevRoot, path)` against
`getAtPath(nextRoot, path)` with `Object.is` is enough, no separate
diff pass needed. Connect/disconnect transitions and listeners with no
`getPaths` (the default) always fire, so this is fully additive.

`useDb` derives its watched paths automatically via a read-tracking
proxy (`react/read-tracking.ts`) wrapped around the root during the
selector call, so `useDb(root => root.app.settings)` needs no API
change to benefit. `client.<field>.subscribe(cb)` already knows its
path statically and now passes it straight through. `useCollection` is
unchanged — collections have their own subscription mechanism.

Tests cover exact-path, descendant, sibling, and parent-path-with-
unchanged-leaf edge cases at the replica level, plus useDb-level tests
for nested-object selectors and the `root => root` identity case.
Benchmark: 200 subscribers watching disjoint paths, 50 writes
concentrated on one — 10000 callback invocations before, 50 after.
zenbu-labs#29)

Every connect/reconnect sent the entire root document, even when the
replica already had almost all of it and just needed the handful of
writes it missed while offline.

The database now tracks a monotonic root version plus a bounded log of
the root ops that produced each version (root-version-log.ts). A
replica's `connect` carries `sinceVersion` — its last known version —
whenever it has one; the server replies `mode: "delta"` with just the
missed ops if the log can serve them contiguously, or `mode: "full"`
otherwise (first-ever connect, gap older than the retained log, or the
client's version is ahead of the server's — e.g. a process restart).
Both cases converge to the identical root.

A single scalar counter is enough rather than a version vector: there's
one canonical in-memory root per process and every write is already
serialized through the same mutex, so there's no concurrent-writer
history to reconcile — see spec.md's new "Reconnect (delta sync)"
section for the full protocol writeup.

`rootVersion` is tracked on its own `Ref`, deliberately outside
`ClientState`/`stateRef` — it's protocol bookkeeping, not observable
application data, so updating it never publishes to `stateRef.changes`
and costs path-keyed or whole-state subscribers (zenbu-labs#31) nothing.

Tests: root-version-log unit tests (contiguous delta, gap fallback,
version-regression fallback, capacity-0 disables delta), integration
tests covering exact convergence after a delta reconnect (including
root.delete), the too-old-gap full-sync fallback, and a first-ever
connect always getting a full sync. Benchmark: a representative
~40-field document reconnecting after one changed field ships 201B
instead of 2776B (13.8x); a no-op reconnect still ships fewer bytes
than a full sync even for a tiny document.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread packages/kyju/src/v2/react/read-tracking.ts Outdated
Comment thread packages/kyju/src/v2/replica/replica.ts
Comment thread packages/kyju/src/v2/db/db.ts Outdated
Comment thread packages/kyju/src/v2/react/index.ts Outdated
… drift, getSnapshot perf

Four fixes from the Devin review of the kyju perf PR (zenbu-labs#29/zenbu-labs#31/zenbu-labs#33):

1. read-tracking stale-after-edit (BUG): `pruneToLeafPaths` discarded a
   shallower subscription path whenever a deeper read extended it, which is
   unsound when the selector RETURNS a container it also read a child of
   (e.g. `root.messages.find(m => m.id === active)` then render `msg.text`).
   The returned object's path was pruned behind `msg.id`, so an edit to
   `msg.text` never re-rendered. Now track proxy→path, collect the paths of
   values the selector actually returns (collectReturnedPaths), and keep
   those paths regardless of any deeper read. Regression test added.

2. reconnect drift (BUG): an optimistic write the server REJECTED
   (WriteFailedError) stayed visible forever, because reconnect switched
   from full resync to delta sync (which layers missed ops on the local
   root instead of replacing it) and could no longer heal local drift. Roll
   the optimistic apply back the moment the rejection ack lands, scoped to
   the touched paths (write + write-batch), so local state can't diverge
   from the server and a delta reconnect stays sound. Regression tests added
   (rejected write rolled back; delta reconnect doesn't resurrect it; a
   rejected batch keeps accepted ops and drops the rest).

3. db.ts version-log comment (COMMENT): corrected the claim that a fresh
   process "starts at version 0 with an empty log" — startup migrations run
   through handleWrite and populate the log. The real guarantee is that a
   client reconnecting to a restarted process uses a brand-new replica
   (no sinceVersion → full sync); getOpsSince is the backstop. No behavior
   change (no reuse-across-restart path exists).

4. getSnapshot perf (PERF): measured. Typical selectors cost ~1.6µs/call
   (negligible). A large-collection scan was ~15ms/call, dominated by an
   O(n^2) prefix check in pruneToLeafPaths — rewritten to O(n·depth)
   (~11x faster on that path). Added a safe fast path: when neither the
   store root nor the selector reference changed, reuse the cached output
   and watched paths instead of rebuilding the proxy tree. Gated on selector
   identity so inline selectors that close over changed props still re-track
   correctly; memoizing a heavy selector makes the fast path apply.
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@Ayush2k02 is attempting to deploy a commit to the zenbu-labs Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant