Skip to content

perf(zql): de-generator the pure-passthrough push delegations - #6524

Draft
arv wants to merge 5 commits into
mlaw/ivm-pull-fnsfrom
arv/ivm-push-fns
Draft

perf(zql): de-generator the pure-passthrough push delegations#6524
arv wants to merge 5 commits into
mlaw/ivm-pull-fnsfrom
arv/ivm-push-fns

Conversation

@arv

@arv arv commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on mlaw/ivm-pull-fns (was #6523 / arv/ivm-pull-fns).

Rebased onto the cleanup fixes. mlaw/ivm-pull-fns carries a475ecc
(14 reproductions of pull-protocol cleanup regressions) and d3fbd4c (their
fixes) on top of the fetch-path conversion. These three commits rebase onto
it cleanly and the whole suite passes, including the 14 new tests.

The results below predate that base and need re-taking. d3fbd4c
restores the single-row cap on primary-key scans in MemorySource#fetch,
which the conversion had dropped -- a PK lookup was walking to the end of the
index and rejecting every remaining row through the filter predicate. The
push benchmarks materialize a view before pushing, so they ran that path.

Two changes to what is left of the iterator protocol after the fetch path moved
to pull functions.

1. The filter chain's push path was pure yield*

Output.push returns Stream<'yield'> so that a slow push can suspend. But
Filter, FilterStart, FilterEnd, filterPush and
maybeSplitAndPushEditChange never yield on their own — every branch of all
five is a plain delegation. Each was a generator layer costing a resume per
yield and buying nothing, the same shape as the old generateRows.

They are now ordinary functions that return the downstream stream:

push(change: Change): Stream<'yield'> {
  return filterPush(change, this.#output, this, this.#predicate);
}

Downstream stays lazy — calling a generator function does not run its body —
and the empty cases share one frozen EMPTY_YIELDS instead of each allocating
a generator. buildFilterPipeline inserts a FilterStart/FilterEnd pair into
every pipeline, so this removes at least two generator layers from every
push, filtered or not.

The operators that genuinely suspend (take, exists, cap, skip,
union-fan-in, the sources) are untouched. Replacing Stream<'yield'> itself
wants a resumable-continuation type rather than PullStream<T> — push carries
no values — and is a separate design.

One semantic change: the predicate now runs at push() call time rather
than at first next(). No call site anywhere stores a push stream without
consuming it at the same site — all 67 consumers are inline yield*/for...of/
consume — and the predicate is required pure, so it is not observable.

2. BTreeSet is no longer iterable

The iterator protocol allocates a result object per value, which is the cost
the fetch path exists to avoid, and an iterable API is an easy way to
reintroduce it by accident.

Finding the real callers by making next() throw turned up one on a hot path:

BTreeForwardIterator.next → toSorted (iterables.ts) → MemorySource#getOrCreateIndex → fetch

toSorted(tree, cmp) is [...tree].sort(), so every secondary-index build
allocated one IteratorResult per row
. That is now BTreeSet#toArray(),
which drains via nextValue().

With that gone, next() had no production callers left, so ValueIterator is
pull-only, [Symbol.iterator] is gone from the iterators and from BTreeSet,
and drainValues() collects a scan where a test needs an array.

Two things fell out:

  • Storage#scan is deleted. It had no production callers — only its own
    tests — and was the last thing in zql iterating a BTreeSet. zqlite's del
    test used it only to verify deletion and now asserts with get.
  • RowScan#close() was calling this.#rows.return?.(), which was always a
    no-op
    — the BTree iterators never defined return. Removing iterability
    made that visible. An index scan holds no external resource, so close()
    now just sets #done. Worth noting given how load-bearing close() is for
    the SQLite cursors; this particular call never did anything.

Results

Android emulator (Hermes), --run push, 4 paired interleaved rounds, medians.
ops/sec, so higher is better:

benchmark base this in-run noise
Filter / push add open issue (passes filter) 391.2 423.9 +8.7% ±0.4%
Filter / push add closed issue (filtered out) 417.9 441.0 +5.7% ±0.3%
MemorySource push: add/remove 1000 rows, sort 4 keys 416.8 438.8 +5.2% ±0.4%
MemorySource push: add/remove 1000 rows, sort 2 keys 424.9 440.9 +4.1% ±1.2%
MemorySource push: add/remove 1000 rows, sort 1 key 426.6 441.4 +3.9% ±0.3%

The two Filter / push benchmarks are the direct target and every round was
positive. The MemorySource push gains follow from the same cause: even a
filterless push crosses the FilterStart/FilterEnd pair.

Everything dominated by join or relationship work is flat — Join / push edit issue title −0.3%, push: add issue (with creator join) −0.2%, the
relationship-heavy view 2.2–2.5% against ±3% noise. Those layers still suspend
for real and were untouched.

This does not fix #6523's push regression. push: add comment (child relation) is +0.6% against ±2.4% noise — flat, not recovered.

On the measurement

V8 could not resolve this at all. Five paired rounds on the host produced
nothing usable: push: add issue (no join) read −12.4% with all four of the
first rounds negative, then round five came back +13.0%. One
hydrate: issues with creator round swung +40.4%. Had I stopped at four rounds
I would have reported a win that is not there. The emulator's in-run spreads
are ±0.3–0.4% on the benchmarks that matter here, so the Android numbers are
the ones to trust.

Caveat: one add open issue round came in at +16.3% against +6.7/+8.4/+9.0.
The median ignores it, but treat 8.7% as approximate.

Verification

shared 772 · zql 1447 · zqlite 198 — each down by exactly the tests removed
(two iterator-protocol tests, the MemoryStorage.scan test, the scan prefix
test). Types and lint clean.

The hydration A/B did not produce a usable result: the Android emulator was
too degraded after hours of benchmarking, with in-run noise at a median of
±23.9% and a uniform -76% swing across all ten benchmarks in one round. It
needs a cold emulator and a pm clear between arms, and should be re-run
together with the push suite against the new base.

@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
replicache-docs Ready Ready Preview Sep 10, 2026 3:59pm UTC
zbugs Ready Ready Preview Sep 10, 2026 3:59pm UTC
zero-throughput Ready Ready Preview Sep 10, 2026 3:59pm UTC

Request Review

Copilot AI 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.

🟡 Changes recommended

BTreeSet.toArray() can silently truncate sets containing an undefined key.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes ZQL push pipelines and B-tree scans by removing unnecessary generator and iterator-protocol overhead.

Changes:

  • Converts pass-through filter pushes to direct stream delegation.
  • Makes BTreeSet pull-only and adds array draining.
  • Removes the unused Storage.scan API and tests.
File summaries
File Description
packages/zqlite/src/database-storage.ts Removes storage scanning.
packages/zqlite/src/database-storage.test.ts Replaces scan assertions with gets.
packages/zql/src/ivm/stream.ts Adds shared empty push stream.
packages/zql/src/ivm/operator.ts Removes Storage.scan.
packages/zql/src/ivm/memory-storage.ts Uses BTreeSet.toArray.
packages/zql/src/ivm/memory-storage.test.ts Removes scan tests.
packages/zql/src/ivm/memory-source.ts Avoids iterable-based index construction.
packages/zql/src/ivm/maybe-split-and-push-edit-change.ts Directly delegates push streams.
packages/zql/src/ivm/filter.ts Removes generator delegation.
packages/zql/src/ivm/filter-push.ts Returns downstream or empty streams directly.
packages/zql/src/ivm/filter-operators.ts Removes pass-through generators.
packages/shared/src/btree-set.ts Replaces iterability with pull-only traversal.
packages/shared/src/btree-set.test.ts Adapts tests to pull traversal.
packages/shared/src/btree-set.bench.ts Benchmarks pull traversal and toArray.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

/** Collects the rest of `it` into an array. */
export function drainValues<K>(it: ValueIterator<K>): K[] {
const result: K[] = [];
for (let v = it.nextValue(); v !== undefined; v = it.nextValue()) {
Comment on lines 159 to 161
get: (key, def?) => this.#get(cgID, opID, key, def),
set: (key, val) => this.#set(cgID, opID, key, val),
del: key => this.#del(cgID, opID, key),
arv added 3 commits September 10, 2026 16:45
The whole filter chain's push path never yields on its own: Filter,
FilterStart, FilterEnd, filterPush and maybeSplitAndPushEditChange are all
pure `yield*` delegations. Each was a generator layer costing a resume per
yield and buying nothing, the same shape as the old `generateRows`.

Also adds BTreeSet#toArray so building a secondary index and cloning storage
stop allocating an IteratorResult per row.
The iterator protocol allocates a result object per value, which is the cost
the IVM fetch path exists to avoid, and leaving an iterable API in place is an
easy way to reintroduce it by accident. ValueIterator is now pull-only
(`nextValue()`), BTreeSet exposes `toArray()` instead of `[Symbol.iterator]`,
and `drainValues()` collects a scan where a test needs an array.

Also drops Storage#scan, which had no production callers -- only its own
tests -- and was the last thing in zql that iterated a BTreeSet.

RowScan#close() no longer calls `return?.()`: the BTree iterators never
defined it, so it was always a no-op, and an index scan holds no external
resource to release.
`nextValue()` uses `undefined` as its exhaustion sentinel, so a set whose keys
can be `undefined` is now ambiguous -- that was fine while `next()` still
returned an IteratorResult, and is not any more.

map.ts already had a private `Defined = {} | null` for the same reason (a
`Map.get` miss); it moves to defined.ts so both can use it.
Every close was a hand-written try/finally, and the ones that were forgotten
are what d3fbd4c had to go back and fix. This adds the scope-based form and
closes the leaks the audit turned up.

- `withPull(stream, fn)` closes on the normal path and on a throw, the pull
  analogue of `withRead`/`withWrite`. Uses try/catch rather than a plain
  `finally` so a failing close cannot replace the operation's error.
- `forEachPull(stream, fn)` adds the loop; `fn` returns `'break'` to stop
  early. A `break` out of a hand-written loop is exactly the abrupt
  completion that needed the `finally`.
- `forEachSkippingYields` for the Node-stream case, next to `skipYields`.
  Named for what it drops: a consumer that can suspend must propagate the
  markers, so the choice should be visible at the call site.

Leaks found while writing the tests, all the same shape:

- `filterPull`, `takeWhilePull` and `mapPull` left the source open when the
  caller's callback threw. A combinator has no scope to close in and the
  exception propagates past the caller's own close(), so it has to release
  the source itself.
- `limitedScan` set `done` in its catch without closing, so the caller's
  close() then skipped the source -- the same bug d3fbd4c fixed in
  FilterStartPull.
- `drainPull` and `drainPullMap` never closed at all.

Six of the thirteen new tests fail without these fixes.

Converts the eight call sites whose loop body can move into a callback. The
remaining thirteen are inside push-path generators and use `yield`, which
cannot cross a function boundary; those keep their try/finally.
`void | 'break'` rejected a shorthand arrow whose body produces a value
(`v => rows.push(v)`): TypeScript only lets a function return anything when
the expected type is exactly `void`, and any union -- including
`void | undefined | 'break'` -- drops that.

The trade is not avoidable. The rule that permits the shorthand is 'ignore the
returned value', so nothing catches a misspelled sentinel and also accepts the
shorthand; overloads restore the shorthand but swallow the typo just as
quietly. Documented on the function and pinned by a test.
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.

2 participants