Skip to content

perf(zqlite): fetch-template cache, direct SQL formatting, single-shot iterator logging - #6433

Draft
Karavil wants to merge 7 commits into
rocicorp:mainfrom
goblinshq:capy/zqlite-fetch-cost
Draft

perf(zqlite): fetch-template cache, direct SQL formatting, single-shot iterator logging#6433
Karavil wants to merge 7 commits into
rocicorp:mainfrom
goblinshq:capy/zqlite-fetch-cost

Conversation

@Karavil

@Karavil Karavil commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Per-fetch and per-row cost cuts in TableSource. Six commits, all inside packages/zqlite, all behavior-preserving. No API changes outside the package. Measured against a replica built from the synthetic classroom workload behind #6438.

54.95 ms → 37.30 ms per hydration wave, −32.1%, with byte-identical rows.

What the evidence said

Statement caching is not the problem, and neither is statement preparation. Driving real TableSource fetches against the replica, db.prepare is called 0 times in a warm wave, before and after. Identical shapes hit StatementCache every time.

Self time under TableSource.#fetch in a sync-worker CPU profile of the N=8 wave replay from #6438 (#fetch is 28.5% of all samples):

frame share of #fetch
native SQLite next 37.3%
SQL build + formatStandard + normalize + cache lookup ~27%
zqlite Statement / iterator wrapper (withContext alone 4.7%) ~18%
row conversion (fromSQLiteTypes, #mapFromSQLiteTypes) ~5.4%

Per-row ladder on the 973-row tracker fetch: native iterate 1.27 us/row → + wrapper 1.42 → + fromSQLiteTypes 2.00 → + generators 2.39.

Follow-up hydration profiles put buildSelectQuery at 8.8–9.1% inclusive, zqlite format at 8.2–8.6% inclusive, formatStandard at 5.2–5.6% self, and normalizeWhitespace at ~1% self, over a warm tracker hydration that runs 7,795 fetches across only 12 distinct SQL texts.

The commits

  1. Log a completed SQLite iterator's slow-query epilogue once. An exhausted iterator logged twice: next() logs on done, then #fetch's finally closes it and return() logs the same query again. 6,822 of 7,795 profiled tracker iterators and all 362 roster iterators did this. #log() is now idempotent; early closes and aborts still log.

  2. Build the slow-query log context only when the query is slow. Statement.run/get/all/iterate, the iterator epilogue and Database.#run each derived a LogContext and spread an attribute object on every call, then logIfSlow discarded both. withContext is 4.7% of #fetch self time. Output is unchanged: logIfSlow already re-applied every attribute, and withContext merges rather than appends.

  3. Format SQL with a SQLite-specific item walker. @databases/sql's formatStandard ends every call by dedenting: split, filter, measure each line's indent, take the minimum, rejoin -- dead work for the single-line SQL the builder emits (SQL formatting was 3.0% self in the profile). format and compile now walk the items directly, skip the dedent when the text has no newline (provably a no-op there, since the trailing trim() removes the leading whitespace either way), and memoize escaped identifiers, bounded at 10,000 entries.

  4. Materialize fetched rows from a precomputed column plan. #fetch selects exactly Object.keys(this.#columns), so the per-row Object.keys(row) allocation and schema lookups in fromSQLiteTypes are avoidable. Exported fromSQLiteTypes is untouched for getRow, the snapshotter and the write authorizer.

  5. Reuse a fetch's SQL text across fetches of the same shape. A per-connection template keyed by the constrained columns in order, each multi-constraint's columns and arity, and reverse; values are rebound in buildSelectQuery's order, with the same per-entry column check multiConstraintToSQL makes. The connection already fixes the table, columns, filters and ordering, and values never change the text because constraintsToSQL emits col = ? even for a null. StatementCache grows getNormalized so the canonical text is normalized once per template rather than once per fetch. Requests with a start keep the old path, because gatherStartConstraints chooses comparisons from the cursor row's values.

(The sixth commit is the guard priced in its own section below.)

Numbers

Fixture: the SQLite replica (.db + -wal + -shm) that zero-cache builds by initial-syncing the #6438 fixture -- 136 students, 973 problem trackers, 973 conversations, 957 mastery assessments, all identities synthetic. The wave replays the SQL shapes observed in the #6438 server log: problem_tracker by assignment (973 rows), 973 × conversation by tracker, 973 × mastery_assessment by tracker, assignment by id, student_class_membership with multi-constraint IN of arity 1/2/5/17/126 -- plus reverse fetches and cursor paging over both bases. 2,064 fetches, 4,665 rows per wave. Before and after run interleaved from two checkouts of the same base commit to cancel machine drift; each number is the median of 25 waves. One machine; treat the ratio as the portable result.

round before after
1 54.9 ms 37.3 ms
2 54.9 ms 37.5 ms
3 55.2 ms 37.3 ms
4 54.8 ms 37.1 ms
5 54.0 ms 37.6 ms
6 55.1 ms 37.1 ms

54.98 ms → 37.32 ms, −32.1% on medians, and −32.1% on per-round minima (54.833 → 36.80 ms). The before and after ranges do not overlap.

Cumulative through each commit, same method, three interleaved rounds each:

through commit median vs base
base 55.0 ms --
1 -- idempotent iterator log 55.0 ms 0%
2 -- deferred log context 52.5 ms −4.6%
3 -- SQLite formatter 48.9 ms −11.1%
4 -- row conversion plan 46.4 ms −15.6%
5 -- fetch-template cache 37.3 ms −32.1%

Commit 1 does not move this benchmark, and that is expected: its duplicate epilogue only becomes expensive when the log context is built eagerly, which commit 2 removes anyway. Its value on top of main is the profiled 1–2% of tracker CPU and, either way, one slow-query record per iterator instead of two.

The win is per-fetch dominated. A single 973-row fetch, where SQL costs amortize over many rows, improves only through the row plan: 2.39 → 2.25 us/row.

Isolated micro-numbers: the formatter is 45–48% cheaper than formatStandard on the four dominant fetch shapes, and the row plan halves row conversion -- 0.36 → 0.19 us/row on the 8-column problem_tracker, 1.08 → 0.57 us/row on the 17-column assignment.

Work eliminated

Counters over one warm wave, before and after:

counter before after
rows 4,665 4,665
iterators started 2,064 2,064
db.prepare calls 0 0
SQL formats 2,064 68
distinct SQL texts formatted 16 4

The 68 remaining formats are exactly the 68 cursor-paging fetches (17 rows × 2 bases × 2 directions) that bypass the template by design.

Equality proof

The measurement harness has a dump mode that writes every fetched row, in fetch order, as JSONL (bigints tagged so they survive JSON.stringify). Before and after are byte-identical:

$ cmp rows-before-ext.jsonl rows-after-ext.jsonl && echo IDENTICAL
IDENTICAL
$ sha256sum rows-before-ext.jsonl
21b2a36669ab56bdd515457138acd97904f8bd7acd9509e55463fa3e5aa7106d

4,665 rows across five tables covering string, number, boolean, json and null columns, ascending and descending multi-column orderings, single and compound constraints, multi-constraint IN of five different arities, reverse fetches, and cursor paging on both bases.

The unit tests add the shapes the replica does not exercise: the same constrained columns in a different order, chained multi-constraints, compound multi-constraints whose later entries list their columns in a different order, and connection filters. Each cached shape is compared against building and formatting the query from scratch, twice, so it is served once by a fresh template and once by a cached one. Deliberately breaking the template key on reverse, on the constraint columns, on multi-constraint arity, on the filter-value position, or on the compound column order fails those tests. A heterogeneous multi-constraint must throw rather than misbind, on the fresh path and on the cached one.

The template key is injective in the request's shape: each column name is framed by its own length, so a name that itself contains the | or ; separators cannot make two shapes share a template. A regression test pins it with columns named a|b, c, a, b|c and a;1|b -- the two two-column shapes bind the same number of values, so a shared key would return plausible wrong rows rather than fail at bind time.

Tests

pnpm --filter zqlite test                 204 passed (13 files)
pnpm --filter zql test                    1316 passed, 2 skipped (76 files)
pnpm --filter zql-integration-tests test  1165 passed, 16 skipped (91 files)
pnpm --filter zero-cache test             4851 passed, 32 skipped (344 files)
pnpm --filter zero-cache check-types      clean
pnpm --filter zqlite check-types          clean
npx oxlint --quiet --config oxlint.config.ts packages/zqlite/src
                                          19 warnings (18 on main; the extra is
                                          `valid-title` on a table-driven test,
                                          matching the file's existing pattern)
npx oxfmt --check packages/zqlite/src     clean

Reproducing

The A/B wave driver, the counter scripts and the dump-mode equality harness are scratch scripts and are not committed, so the wall-clock tables above cannot be regenerated verbatim from this branch; the method is stated with each table so the numbers carry their context. What an outside reviewer can run:

  • The committed unit tests in packages/zqlite/src, which pin every behavioral claim above -- template-key completeness, the multi-constraint guard, formatter/formatStandard equivalence, and row-conversion equivalence.
  • The fixture: seed a database with bench(zero-cache): N-group wave-replay harness and scaling baseline #6438's seed/apply-seed.sh and let zero-cache initial-sync it; the resulting replica is the one described above, and the wave's fetch shapes and per-shape row counts are listed in the Numbers section.
  • The interleaving method: two worktrees at the same base commit (one with the branch applied), alternating runs, median of 25 waves per round.

What the multi-constraint guard costs

multiConstraintToSQL asserts that every entry of a multi-constraint carries the first entry's columns, and a fetch served from a template never reaches it. An earlier revision of the template cache trusted that invariant instead of re-deriving it, which would have turned a loud assert into silently wrong bindings on a cache hit for any future caller that broke the shape. The templated binding path now makes the same check -- a column count per entry, plus a presence check per expected column.

On the production-shaped wave, which binds 787 multi-constraint entries, the guard is invisible: 37.30 ms without it, 37.32 ms with it, under 0.1% of the wave.

Priced on a pathological wave that is nothing but arity-126 multi-constraint fetches -- 800 fetches, 100,800 entries checked per wave, single-column and compound -- against the same branch with the guard removed:

median min
without guard 95.7 ms 94.4 ms
with guard 96.4 ms 95.4 ms

+1.2% of a wave that does nothing else, or roughly 12 ns per entry. It stays unconditional.

Both halves are load-bearing, and the test proves it: removing the count check lets an entry with an extra column through, and removing the presence check lets an entry with the right number of columns but a different one through.

Not done, and why

Templating cursor-paged fetches. gatherStartConstraints derives its comparison operators from the cursor row's values -- a null bound emits IS NOT NULL, or FALSE, or nothing at all -- so the text depends on values and the key would need a null mask over the ordering fields. Paging fetches do not repeat a shape often enough to pay for that risk; in the replay they are 68 of 2,064 fetches.

Per-row performance.now() in LoggingIterableIterator.next. Two calls per row, 7.8% of #fetch self time. #sqliteRowTimeSum and the total elapsed measure genuinely different intervals, so collapsing them would change when the type=sqlite warning fires.

@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@capyai is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

capyai added 6 commits August 28, 2026 17:39
Every exhausted iterator ran the slow-query epilogue twice. `next()` logs
when the native iterator reports `done`, and `TableSource.#fetch` then
closes the iterator in its `finally`, so `return()` logs the same query
again. Each duplicate costs a `performance.now()`, two threshold checks
and, when the query is slow, two `LogContext.withContext` calls and two
attribute objects.

In a profiled warm tracker hydration, 6,822 of 7,795 iterators reached
`done` through `next()` and were then closed, so they all logged twice;
all 362 roster iterators did the same. The remaining 973 tracker
iterators are closed early and still need their `return()` log.

`#log()` is now idempotent, so the first completion describes the query
and later closes stay silent. Early closes and aborts are unaffected,
and the native iterator is still closed exactly once.

Covered by a new `db.test.ts` case asserting one total/sqlite pair for
an exhausted-then-closed iterator, an early close, and an abort; it
fails on main with four log records instead of two.
… slow

`logIfSlow` already discards everything it is handed when the query beats
the threshold, but every caller paid for the arguments first.
`Statement.run/get/all`, `LoggingIterableIterator.#log` and
`Database.#run` each derived a `LogContext` -- which spreads the parent
context into a fresh object and constructs a new logger -- and spread a
new attribute object, on every single call. A fetch does this three
times: once for `iterate` and twice for the epilogue.

`LogContext.withContext` is 4.7% of self time under `TableSource.#fetch`
in the eight-group diagnosis profile, and 1.6-1.9% of a tracker
hydration in the follow-up profiles. It buys nothing on the fast path.

The context and the attribute object are now built inside the threshold
branch, from the method and type the caller passes as plain strings.

Log output is unchanged. `logIfSlow` already re-applied every attribute
to the context, so the call-site `withContext` calls were setting keys
that were about to be set again to the same values, and `withContext`
merges rather than appends. `db.test.ts` pins the exact context objects
for pragma, exec, prepare, run, get, all and both iterate records at
threshold 0, and passes unchanged.

Measured on a production-shaped replay of the diagnosis kit's replica
(2,060 fetches, 3,049 rows per wave, medians of 25 waves, before/after
interleaved from two checkouts): -5.4%.
`@databases/sql`'s generic `formatStandard` ends every format by
dedenting the text: it splits on newlines, filters blank lines, measures
each line's leading whitespace, takes the minimum, and rejoins. The SQL
the query builder emits is a single line, so that pass finds nothing and
throws away the arrays it allocated. It also re-escapes the same table
and column identifiers on every call.

`TableSource.#fetch` formats a query for every fetch, so this is on the
hot read path. `formatStandard` is the diagnosis's "SQL formatting 3.0%
self", 6.4% of self time under `#fetch`, and 5.2-5.6% self in the
follow-up tracker profiles.

`format` and `compile` now walk the SQL items directly. The dedent still
runs for multi-line templates -- `getUniqueIndexes` has one -- and is
skipped when the text has no newline, where it is provably a no-op: with
one line the common indent is that line's leading whitespace, and the
trailing `trim()` removes it either way. Escaped identifiers are
memoized, bounded at 10,000 entries so a client-influenced query shape
cannot grow the map without limit.

Output is byte-identical. A new `sql.test.ts` case asserts equality with
`formatStandard` for single-line, multi-line/dedent, whitespace-only,
multi-name and quote-escaped identifier shapes, and the existing
`query-builder.test.ts` golden SQL is unchanged.

Measured on a production-shaped replay of the diagnosis kit's replica
(2,060 fetches, 3,049 rows per wave, medians of 25 waves, before/after
interleaved from two checkouts): -5.8%. In isolation the formatter is
45-48% cheaper than `formatStandard` on the four dominant fetch shapes.
`fromSQLiteTypes` converts an arbitrary row: it calls `Object.keys(row)`,
then looks every key up in the schema record to find its type. On the
fetch path neither is necessary. `#fetch` selects exactly
`Object.keys(this.#columns)`, so every row SQLite returns has those keys,
in that order, and the types are known when the source is constructed.

Row conversion is 4.3% of self time under `TableSource.#fetch` in the
eight-group diagnosis profile, and the largest JavaScript cost above raw
SQLite in a per-row ladder on the 973-row tracker fetch: native iterate
1.27 us/row, the zqlite iterator wrapper 1.42, `fromSQLiteTypes` 2.00,
the generator chain 2.39.

`#rowFromSQLiteTypes` walks precomputed name and type arrays instead,
producing the same row object with the same key order. Exported
`fromSQLiteTypes` is untouched: `getRow`, the snapshotter and the write
authorizer still convert rows whose columns they cannot assume.

Measured on a production-shaped replay of the diagnosis kit's replica
(2,060 fetches, 3,049 rows per wave, medians of 25 waves, before/after
interleaved from two checkouts): -2.8%, and 4-8% on a single 973-row
fetch where the per-fetch costs amortize. In isolation, row conversion
halves: 0.36 -> 0.19 us/row on the 8-column `problem_tracker`, 1.08 ->
0.57 us/row on the 17-column `assignment`. `table-source.test.ts` already
covers every value type, bigint bounds and JSON through fetch.
A hydration issues thousands of fetches that differ only in the values
they bind. A profiled warm tracker hydration ran 7,795 fetches over 12
distinct SQL texts; the production-shaped replay used here runs 2,064
fetches over 16. Every one of them rebuilt the `@databases/sql` object
tree, serialized it, and normalized the result's whitespace before the
statement cache could find the already-prepared statement.
`buildSelectQuery` is 8.8-9.1% inclusive and zqlite `format` 8.2-8.6%
inclusive in those profiles; `normalizeWhitespace` is another ~1% self.
Statement preparation was never the problem and is unchanged.

`#fetch` now looks up a per-connection template keyed by everything that
can change the SQL text, and rebinds values into it. The connection
fixes the table, the columns, the filter conditions and the ordering, so
the key is the constrained columns in order, each multi-constraint's
columns and arity, and `reverse`. Values cannot change the text:
`constraintsToSQL` emits `col = ?` even for a null. The bindings are
rebuilt in `buildSelectQuery`'s order -- constraints, then each
multi-constraint entry by entry using the first entry's column order the
way `multiConstraintToSQL` does, then the connection's fixed filter
literals, captured when the template was built. `StatementCache` grows a
`getNormalized` entry point so the canonical text is normalized once per
template instead of once per fetch, and the template map is capped the
way the statement cache is, because multi-constraint arity comes from
caller-chosen chunk sizes.

Requests with a `start` keep the old path. `gatherStartConstraints`
picks comparisons from the cursor row's values -- a null bound emits
`IS NOT NULL`, or `FALSE`, or nothing -- so their text depends on values,
and paging does not repeat a shape often enough to be worth the risk.

Results are identical. Replaying the diagnosis kit's replica with the
production fetch shapes plus reverse, cursor paging and multi-constraint
arities of 1/2/5/17/126, the 4,665 fetched rows are byte-identical
before and after, `db.prepare` calls stay at 0, iterators started stay
at 2,064, and SQL formats fall from 2,064 to 68 -- exactly the 68 paging
fetches that bypass the template. New `table-source.test.ts` cases check
every cached shape against building and formatting the query from
scratch, over null, boolean and JSON values, compound constraints, the
same columns in a different order, chained and compound
multi-constraints, varying arity, reverse, and both start bases; each
runs twice so it is served once by a fresh template and once by a cached
one. Deliberately breaking the key on `reverse`, on the constraint
columns, on multi-constraint arity, on the filter-value position, or on
the compound column order fails those tests.

One invariant is now trusted rather than re-derived: `multiConstraintToSQL`
asserts that every entry of a multi-constraint has the first entry's
columns, and a templated fetch takes the column order from the first
entry without re-checking the rest. FlippedJoin builds them from a single
parentKey, which is what that assertion documents.

Measured on the same replay (medians of 25 waves, before/after
interleaved from two checkouts): 54.95 ms -> 37.30 ms, -32.1%.
… path

`multiConstraintToSQL` emits one binding per column of a multi-constraint's
first entry, and asserts that every other entry carries those same columns.
A fetch served from a cached template never reaches it, so the previous
commit trusted that invariant instead of re-deriving it. That turns a loud
assertion into silently wrong bindings the moment any caller breaks the
shape -- an entry missing a column would bind NULL for it, and the query
would return plausible, wrong rows. Today only FlippedJoin builds these,
from a single parentKey, but the assertion is the contract and the
templated path has to honour it.

The binding loop now checks each entry's column count against the first
entry's, and checks that each expected column is present. Both halves are
needed and both are covered: an entry with an extra column has every
expected column present and is only caught by the count, and an entry with
a different column of the same arity is only caught by the presence check.
The failure message matches the builder's.

Cost, measured against the same branch with the check removed. On the
production-shaped replay, which binds 787 multi-constraint entries per
wave, it is invisible: 37.30 ms without, 37.32 ms with, under 0.1% of the
wave and unchanged at -32.1% against main. On a pathological wave that is
nothing but arity-126 multi-constraint fetches -- 800 fetches, 100,800
entries checked -- it is 95.7 -> 96.4 ms median, +1.2% of a wave that does
nothing else, or about 12 ns per entry. It stays unconditional.

The new `table-source.test.ts` case asserts that a heterogeneous
multi-constraint throws rather than misbinds on both paths: on the fresh
path, where the builder rejects it, and on the cached path, reached by
fetching a well-formed request of the same shape first.
@Karavil
Karavil force-pushed the capy/zqlite-fetch-cost branch from 778d662 to 7adc22f Compare August 28, 2026 17:39
…hape

`fetchTemplateKey` joined the constrained column names with `|`, and each
multi-constraint's arity with `;`, unescaped. A schema whose column names
contain those characters can therefore hash two different request shapes
onto one key, and the cached template then serves the first shape's SQL
text for the second shape's bindings. When the two shapes bind different
numbers of values that fails loudly at bind time; when the counts match it
does not. `a|b` + `c` against `a` + `b|c` is two constrained columns either
way, so the second shape's values bind into the first shape's columns and
the fetch returns plausible, wrong rows.

Each column name is now framed by its own length. That makes the key
injective: the length is digits terminated by the first `:`, so a name can
no longer spell a separator, whatever it contains.

The new `table-source.test.ts` case builds a table with columns `a|b`, `c`,
`a`, `b|c` and `a;1|b`, and fetches both ambiguous shapes fresh and cached.
Against the unfixed key the `a` + `b|c` fetch returns the `a|b` + `c` row.
It also pins that SQLite round-trips such names, since the shapes only
agree if the emitted identifiers are quoted correctly.

Cost: the length field is about 19 ns per key on the wave's shapes, 30 ->
49 ns measured in isolation over 2,000 rounds per side. The wave builds one
key per templated fetch, about 2,000 of them, so it is near 0.1% of a
37 ms wave. Priced end to end on 180 templated fetches -- point
constraints, a compound constraint, reverse, and multi-constraint `IN`
lists of arity 1/2/5/17/126 -- interleaved A/B with the file swapped, six
rounds per side: 14.624 ms before, 14.542 ms after on the fastest
iteration. It does not show up.
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