Exp 270: answer the repeated read from memory (moonshot, rejected) - #305
Conversation
Caches `select()` results on the main isolate, keyed by (sql, parameters) and invalidated by the same write-dependency signal the stream engine consumes. A hit runs no SQLite and takes no isolate round trip, which is the headroom exps 265 and 269 measured but could not safely collect. Refuses to store anything it cannot prove it can invalidate: unreliable read tables, statements with no table dependency, statements invoking a function outside a new C-side deterministic allowlist, and results past a retention cap. Refuses to keep anything when a write's dirty set is unknown or empty, since DDL and virtual-table writes are indistinguishable from a no-op write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rejected: the cache returns superseded rows whenever a second connection to the same file commits, because resqlite's invalidation only reports writes made through the same Database. The exact prototype is preserved at archive/exp-270. Kept: the focused A/B harness (both arms identical on main, mechanism evidence for the round trip), the foreign-writer probe that fails on the cached arm and passes here, the data_version price, and the benchmark receipts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… path Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Belief impactLearned
What this changed
|
Hypothesis
Two experiments ago we learned that for a hot point read, the isolate round trip
is most of the latency — exp 265
priced it and exp 269
reproduced it at 24–90%. Exp 269 tried to collect that headroom by running the
query on the calling isolate and was rejected, because arbitrary SQLite work
cannot be bounded there; it closed by telling future runners not to retry the
same idea with a better predictor.
So this run went the other way: instead of running the query somewhere cheaper,
don't run it at all. resqlite already computes both halves of a read cache and
uses them for something else — the C authorizer records which tables each
prepared statement reads, and the preupdate hook records which tables each write
changed, and the stream engine has consumed both since exp 106. A cache hit runs
no SQLite whatsoever, so it is bounded by construction, which is exactly what
exp 269 could not achieve.
The bet, stated as the assumption being challenged: the invalidation signal
that is good enough for
stream()is good enough to answerselect()frommemory. Those two consumers fail differently — a stream that misses an
invalidation re-emits late, and a
select()that misses one returns the wrongrows, silently — so this was never mainly a performance question.
Approach
ReadCachesits on the main isolate beside the reader pool, keyed by(sql, parameters). It refuses to store anything whose invalidation it cannotprove: statements whose read-table capture is unreliable, statements that read no
table at all (nothing could ever invalidate
SELECT 1), statements calling afunction outside a new C-side deterministic allowlist (a new
SQLITE_FUNCTIONcase in the authorizer, so
random(),datetime('now')and everycaller-registered function are excluded), and results past a retention cap.
Invalidation is a version stamp rather than an index walk: each table carries a
write counter, each retained result carries the counters its tables were at when
its read was dispatched, and a lookup that finds a mismatch drops the entry. A
write therefore costs one map write per dirty table and nothing else. Two cases
retire everything at once via an epoch counter — an unknown dirty set, and the
one that is easy to miss: a write reporting an empty dirty set, because DDL and
virtual-table writes fire no preupdate hook and so arrive indistinguishable from
a no-op write.
Transaction reads never reach the pool, so they neither hit nor fill;
selectBytesis excluded because its result is a view over native memory the next query
overwrites. Full detail in
experiments/270-read-result-cache.md.
The runtime is reverted on this branch. It is preserved at
archive/exp-270for inspection, not as a base to build on.
Results
Focused AOT A/B, lane-isolated, eight alternating pairs per lane per collection,
two collections with the order flipped. Both arms come from one worktree and one
native library, differing only in a compile-time constant, so no per-
.soplacement offset can exist between them.
point1-repeatpoint1-wide20point1-params(8 cycled ids)page20-repeatread-write-alternate(guard)churn-unique(guard)uncacheable-fn(guard)mixed6-1k(guard)concurrent8(guard)A hit costs 0.48 µs against 4.65 µs for the same read dispatched to a worker —
roughly ten times cheaper — and peak RSS is flat or lower. The guards are the
half that matter: nothing regresses on workloads the cache cannot help, and two
of them wrote the design. Describing a statement on its first sighting rather
than its second cost a reproduced +21% on never-repeating SQL, and a
table→queries invalidation index cost +16–19% on the read/write lane before
version stamps moved that cost off the writer entirely.
Then the probe that decides the experiment.
select_cache_foreign_writer.dartopens a second
Databaseon the same file, commits through it, and reads backthrough the first:
select()after the foreign commit1237587afterafterbeforeafterThat is a wrong answer, not a late one. resqlite's invalidation is built from its
own preupdate hook, so a connection it does not own commits without it hearing
anything.
stream()has always had that boundary and documents it;select()re-reads the file every time and so has never had it.
PRAGMA data_versionwould close the hole, and rather than assume it was tooexpensive we measured it: 2 µs p50 idle, 1 µs p50 with a foreign connection
committing continuously, 10 µs worst. So a validated hit would cost ~2 µs against
4.65 µs — still ~2× faster, but bought by putting a SQLite connection on the
calling isolate, which is exp 269's rejected architecture.
Finally, incidence, which nothing in the repo had ever measured: across the two
workload simulations, Chat Sim (A5) would serve 17.7% of its 9,006 reads from
cache and Feed Paging (A6) 84.3% of its 140 — a near-5× spread, so the value is
a property of how much a workload writes to what it reads. Worth noting that the
release suite cannot answer this at all: its read scenarios repeat one statement
with nothing writing, so
Select → Mapsat 100 rows reads 0.040 → 0.005 ms andthat number is the benchmark measuring itself.
The headline sweep ran against a same-host baseline captured from the exact
parent commit and passed explicitly, because the automatic anchor picked exp
269's hand-authored receipt and skipped itself (exp 269's claim 269.5 warned
about precisely this). Against the parent: 7 wins, 0 wall-time regressions,
162 neutral, memory 1 win, 0 regressions. The wins are the measurement
artifact above, not a result.
Flagged lanes and their disposition. One row is red —
Streaming (Column Granularity) / Overlapping column writes, −592 re-emits. It issqlite_async'srow, and it moved 3,466 / 3,894 / 4,347 / 3,663 / 4,185 across the five repeats
inside that one run; resqlite reports 0 disjoint and 10 overlapping in all
five. Peer non-determinism, not attributable here. Separately, a first sweep of
this same commit against this same baseline flagged fourteen lanes at +12% to
+40% (writes, streaming fan-out, subscription rate). No mechanism connects those
to a diff that adds one call to the write path and one map lookup to the read
path, and the cause was the host: a separate multi-
Databaseprobe was runningconcurrently. The quiet repeat above is the receipt, and both runs are committed.
Outcome
Rejected. Not on performance — the mechanism is a clean 90–96% with no
measurable tax anywhere — but on three things the numbers don't cover:
trade-off-shaped win;
back with the main-isolate SQLite connection exp 269 rejected;
Would reopen if the invalidation signal grows cross-connection scope, or if a real
downstream trace shows read repetition much closer to Feed Paging's number than
Chat Sim's. The successor actually worth building is much smaller and is named in
the writeup: serve
select()from an active stream's own last result, where thecaller has already accepted stream semantics for that query.
The lasting artifacts are the focused A/B harness, the foreign-writer probe (which
fails on the cached arm and passes on main), the incidence probe, and the
data_versionprice.Test plan
dart analyze --fatal-infos— cleandart test— 495 tests with the prototype in place, including 22 newtest/read_cache_test.dartcases; three load-bearing guards verified tofail against a deliberately weakened cache
benchmark/ab_drift_check.dartover all nineselect_cache_foreign_writer.darton both arms, and on the parent worktreeselect_cache_data_version.dart(AOT),select_read_cache_incidence.dartdart testanddart analyzere-run after the runtime revertbenchmark/finalize_experiment.dartgreen