Skip to content

perf(cojson): incremental CoList entries cache and faster list maintenance - #3540

Open
gdorsi wants to merge 1 commit into
mainfrom
colist-incremental-entries
Open

perf(cojson): incremental CoList entries cache and faster list maintenance#3540
gdorsi wants to merge 1 commit into
mainfrom
colist-incremental-entries

Conversation

@gdorsi

@gdorsi gdorsi commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3537 (worktree-colist-fetch-optimization). Diff to review is packages/cojson/src/coValues/coList.ts plus a changeset.

Summary

Makes CoList view maintenance incremental instead of rebuild-on-every-update (measured same-machine vs the base branch):

  • Write/processing path (append + list maintenance): 4.4x–44x, and no longer superlinear. RawCoList now links insertion entries directly instead of through nested string-keyed lookups, tracks deletions with a counter instead of a separate map lookup, and uses a generation counter for traversal instead of a Set. Appends (the common case) extend the cached entries incrementally instead of invalidating and re-traversing the whole list on every update.
  • Subscribed/streamed fetch of a 100k-item list: 1.9x. A subscribed client no longer re-traverses the entire graph per arriving chunk.

Benchmark table (same machine, best-of-N, vs base branch = #3537):

Metric base (#3537) this branch speedup
Create 100k items (100/tx) 1,489ms 338ms 4.4x
Create 20k items (1/tx) 6,296ms 683ms 9.2x
Create 50k items (1/tx) 72.3s 1.66s 44x
Load 100k×100, subscribed streaming 581ms 299ms 1.9x
Load 100k×100, plain 256ms 314ms 0.8x
Load 20k×1, plain 105ms 114ms 0.9x

Benchmark is bench/colist.load.bench.ts from the base branch (pnpm bench:colist; env knobs SUBSCRIBED, VERIFY, PROFILE, SKIP_VERIFY).

The incremental CoList algorithm

Data model

RawCoList's source of truth is an insertion graph (RGA-style CRDT). Each app op attaches a new entry after an existing entry (or after start), each pre op attaches before one (or before end), and del ops mark insertions as deleted. The visible list is produced by a depth-first traversal (fillArrayFromEntry) over the roots — afterStart roots in arrival order, then beforeEnd roots — where successors of an entry are visited newest-first:

flowchart TB
    S((start)) -.-> A
    A --> B["B (after A, attached 1st)"]
    A --> C["C (after A, attached 2nd)"]
    C --> D["D (after C)"]
Loading

Traversal emits A, C, D, B — C wins over B because successors are visited newest-first. This is also why appendItems writes multi-item appends in reverse order: they read back in the original order.

entries() caches the traversal result in _cachedEntries. The invariant the whole design hangs on:

_cachedEntries, when present, is always identical to what a full traversal (entriesUncached()) would produce.

On the base branch, any new transaction invalidated the cache, so a subscribed client re-traversed the entire graph per arriving chunk — quadratic in list size while streaming. The new code keeps the cache alive across the common case (appends arriving in order) by extending it in place.

Per-batch pipeline

Every batch of new transactions flows through three cleanly separated stages: pure graph ingestion, a rebuild check, and a cache decision made after the graph is up to date.

flowchart TD
    TX["batch of new transactions"] --> SNAP["beginBatchSummary()<br/>summary only if there is a live, non-time-travel cache;<br/>snapshots tail entry + prior successor/root counts"]
    SNAP --> ING["for each op: ingestChange()<br/>pure graph mutation, returns whether the op was applied"]
    ING --> REC["summary.record(change, isValid, applied)<br/>del / pre / duplicate → hasUnsupportedOps<br/>applied valid append → newValidAppends++"]
    REC --> OOO{"batch contains txs older<br/>than already-processed ones?"}
    OOO -- yes --> RB["rebuildFromCore()<br/>reset state, reprocess everything<br/>(also re-attaches orphaned ops)"]
    OOO -- no --> HAS{"summary exists?"}
    HAS -- no --> INV1["invalidate cache"]
    HAS -- yes --> CTS["computeCacheTailSuffix(summary)"]
    CTS -- "suffix (possibly empty)" --> EXT["append suffix to _cachedEntries"]
    CTS -- "null" --> INV2["invalidate cache<br/>(next read does a full traversal)"]
Loading

ingestChange knows nothing about the cache; computeCacheTailSuffix is a pure decision function over the graph plus the batch summary. That separation is what makes the fast path auditable.

The tail-extension check

A batch may extend the cache iff every entry it made visible renders after the previous last visible entry (the tail). Rather than proving that op by op, the suffix pass exploits a structural fact: the batch's new entries can only attach to the old tail, to afterStart, or to each other (an old op can never reference an op that didn't exist yet). So it collects the subtrees the batch hung off the tail region:

flowchart LR
    a --> b --> T["c (old tail)"]
    T ==> d["d (new)"]
    d ==> e["e (new, after d)"]
    T ==> f["f (new, after c)"]
Loading

Suffix roots are the tail's new successors (newest-first, matching DFS pop order) plus any new afterStart roots (in order) — then it runs the same fillArrayFromEntry used for full rebuilds over just those roots. Emitted entries: f, d, e (f is newer than d, so it wins). Because it is the same traversal over the same graph, the suffix order cannot disagree with a full rebuild.

The acceptance test is a single count comparison: the traversal must emit exactly newValidAppends entries. Anything the batch inserted that is not reachable from the tail region comes up missing and invalidates the cache:

flowchart LR
    a --> b --> c["c (tail)"]
    b -. "new append after b<br/>(mid-list, NOT reachable<br/>from suffix roots)" .-> x["x (new)"]
Loading

Here newValidAppends = 1 but the suffix traversal emits 0 entries → mismatch → invalidate. The same mechanism catches orphaned appends (target op not yet arrived) and inserts that were already deleted by an earlier batch's del.

When the cache survives a batch

Batch contains Outcome Why
appends reachable from the tail region only extend in place pure suffix, order proven by shared traversal
a deletion invalidate can hide an entry anywhere in the cached prefix
a prepend (pre op) invalidate renders before its target
a duplicate op (double merge) invalidate conservative
an append to a mid-list or unknown (orphaned) target invalidate caught by the count check
new after-start roots while beforeEnd roots exist invalidate new roots would render before the beforeEnd section
transactions older than already-processed ones full rebuild re-applies everything in order, re-attaching orphans
anything, on a time-travel view (atTime/atFrontier) invalidate conservative

Invalidation is always safe — it just costs one full traversal on the next read. The correctness of the extension path is additionally cross-checked at runtime by the benchmark's VERIFY=1 mode, which compares the incrementally maintained cache against a fresh full traversal.

Net complexity: a streamed append-only load does O(new ops) work per chunk instead of O(list), which is where the subscribed-load speedup comes from.

Caveats

  • Cold one-shot reads of a large imported list are ~10–20% slower (see the plain-load rows): the per-batch summary/suffix bookkeeping during ingest doesn't pay off when the list is read once after import. The win is on every subscribed/streaming or repeatedly-read list, and on the write path.

Test plan

  • pnpm exec vitest --run --root ../../ --project cojson — 76 files, 1397 passed, 13 skipped, 0 failed
  • jazz-tools CoList/CoFeed suites (coList, coList.unique, coList.branch, coFeed, coFeed.branch) — 7 files, 225 passed, 0 failed
  • Benchmark correctness mode (VERIFY=1, plain and SUBSCRIBED=1) — incremental-cache-vs-full-traversal divergence checks pass

@assert-app

assert-app Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review on Assert →

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
file-upload-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
form-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
gcmp-homepage Ready Ready Preview, Comment Jul 7, 2026 2:46pm
image-upload-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-chat Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-chat-1 Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-chat-2 Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-filestream Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-image-upload Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-inspector Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-multi-cursors Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-nextjs Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-organization Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-paper-scissors Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-richtext Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-todo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-vector-search Ready Ready Preview, Comment Jul 7, 2026 2:46pm
jazz-version-history Ready Ready Preview, Comment Jul 7, 2026 2:46pm
music-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
passkey-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
passphrase-auth-demo Ready Ready Preview, Comment Jul 7, 2026 2:46pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
jazz-homepage Ignored Ignored Jul 7, 2026 2:46pm

Request Review

Base automatically changed from worktree-colist-fetch-optimization to main July 22, 2026 16:09
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