Exp 283: the second walk over the same rows - #319
Conversation
A dirtied stream re-executes its query so the engine can learn whether its result changed: a hash pass, then a second step pass whenever the hash moved. `decodeQueryWithInitialHash` (exp 097, shipped for initial stream registration) folds the same canonical digest while it builds the result, so a rerun that was going to decode anyway can step SQLite once instead of twice. The stream engine stamps each rerun with whether the previous one changed; the worker decodes during the hash pass only when it did.
Adds the two focused harnesses (pass price, order-flipped A/B), the decode-first correctness test, the experiment writeup, its README row fragment and its signal entry, plus retroactive claims 097.1 and 228.1 that the run's correctness rests on.
The release suite flagged the target fan-out lane at +91.4%. Two facts came out of unpicking it. The lane's wall is quantized by a 200 ms settle window, so the flag is one extra window around ~46 ms of work. And what triggers the extra window is that the candidate emits ~12% more during a write burst: resqlite_query_hash resets on exit, so the hash-first path's second pass opens a fresh WAL snapshot, and the rows a stream emits are fresher than the digest stored beside them. The second walk was buying emission freshness. Trading ~12% more emissions for 12-16% less wall is a semantic-shaped trade in the dimension the library advertises, so the runtime is archived at archive/exp-283 rather than shipped. The census, pass price, predictor accuracy, queueing amplification and lane quantization all stand.
Belief impactLearned
What this changed
|
There was a problem hiding this comment.
🟡 Changes recommended
The new benchmark harnesses contain correctness/compilation issues (switch fall-through and sentinel-wait logic) and the experiment lacks a required benchmark/results/ artifact for chart linkage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Records Experiment 283 (“the second walk over the same rows”) in the repo’s experiments system, including a new signal entry, retroactive claims for exps 097 and 228, and two focused benchmark harnesses intended to measure rerun-pass cost and an A/B drain metric.
Changes:
- Add experiment 283 writeup + index row + signal entry, and update the stream-rerun-dispatch direction synthesis.
- Mint retroactive typed claims for experiments 097 and 228 to support 283’s narrative/edges.
- Add two new benchmark harnesses:
stream_rerun_pass_price.dartandstream_rerun_one_pass_ab.dart.
File summaries
| File | Description |
|---|---|
| experiments/signals/entries/283.json | New signal entry capturing hypotheses, claims, and next signals for exp 283. |
| experiments/signals/entries/228.json | Adds retroactive claim 228.1 (canonical hash invariant) referenced by exp 283. |
| experiments/signals/entries/097.json | Adds retroactive claim 097.1 (one-pass decode+hash equivalence) referenced by exp 283. |
| experiments/signals/base.json | Updates stream-rerun-dispatch direction status/readout/notes to incorporate exp 283 findings. |
| experiments/JOURNAL.md | Adds three reusable experiment-methodology lessons from exp 283. |
| experiments/index/283.json | Adds index row fragment for experiment 283. |
| experiments/283-stream-rerun-one-pass.md | Adds the full experiment 283 writeup and disposition rationale. |
| benchmark/experiments/stream_rerun_pass_price.dart | New focused harness to price hash vs decode vs one-pass decode+hash. |
| benchmark/experiments/stream_rerun_one_pass_ab.dart | New A/B harness to time a concurrent burst + sentinel-drain metric. |
Review details
Suppressed comments (1)
benchmark/experiments/stream_rerun_pass_price.dart:156
- This second
switch (arm)has the same missingbreakproblem, which would (if it compiled) add the same timing to multiple lists and corrupt the medians.
switch (arm) {
case 0:
hash.add(us);
case 1:
decode.add(us);
- Files reviewed: 9/9 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review of #319 found that the sentinel stream was an ordinary partition the burst also wrote to, with its completer armed before the burst started. Each sample therefore ended at whichever of that stream's reruns fired first -- a random prefix of the backlog, biased toward whichever arm finished changed reruns faster. The sentinel now watches a partition excluded from the burst, and its completer is armed after the burst is issued. The same collection then reads -1.7% (drift-suspected) on the 100-row fan-out lane, not -12.5%, and -11.3% (reproduced) on the 1,000-row lane, not -5.7%. The withdrawn figures are recorded as claim 283.8, along with the rule. The earlier claim that the win exceeded its per-rerun model four-fold was the same defect measuring a prefix; it is withdrawn, and with it the JOURNAL entry built on it. The corrected reconciliation runs the ordinary way: 47% of the isolated mechanism survives end to end.
Hypothesis
A reactive stream re-runs its query whenever a write dirties something it
depends on, and has to decide whether the fresh result is worth emitting. Exp
075 made the common case cheap:
resqlite_query_hashsteps the statement tocompletion in C, folds every cell into an FNV digest, and an unchanged stream
costs one SQLite pass and no Dart objects at all.
What nobody had looked at is what happens when the digest does move. The
statement has already been consumed, so it gets stepped a second time, from
the top, to build the result. That second walk has been there since April and
had never been measured — the two paths were always described as "hash only"
versus "hash plus decode", which quietly hides that the second one is two SQLite
passes rather than one.
resqlite already has a decoder that does both at once: exp 097's
decodeQueryWithInitialHashfills the result and folds the identical canonicaldigest in a single pass, but was scoped to initial stream registration. Exp 228
later named this exact reopening — early rejection may be revisited if "the
changed-result decode can produce the canonical hash without another full pass."
The bet was that a stream's reruns do not change at random: a partition under
active writes changes on rerun after rerun, so the previous rerun's outcome is
enough to pick the right decoder.
Approach
StreamEntrygains onebool lastRerunChanged;ReaderPool.selectIfChangedforwards it as
SelectIfChangedRequest.decodeFirst;executeQueryIfChangedgrows a second arm that calls exp 097's decoder and discards the built result if
the digest turns out to match after all. The flag lives on the main isolate for
the reason exp 260 established for the row hint — a reader worker sees a sample
of a stream's reruns and is destroyed by the sacrifice path.
The runtime is reverted on this branch. It is preserved at
archive/exp-283,and the temporary rerun census at
archive/exp-283-census.What ships here is the writeup, two focused harnesses, the signal entry, and
retroactive claims 097.1 and 228.1. Full detail in
experiments/283-stream-rerun-one-pass.md.
Results
The premise is true. A temporary census counted reruns on scaled
reproductions of the release suite's three reactive lanes:
Per-stream coalescing is far stronger than the suites' own docstrings assume,
and the fan-out lane's reruns are not mostly unchanged — the familiar "99 of
100 streams are unchanged" describes the writes, not the reruns.
The mechanism is real. With no pool, no isolates and no message hop, the
removed pass is 35–45% of a changed rerun's SQLite-and-decode work at every
width from 1 row to 1,000 (
10.72 → 6.50 µsat 100×2;87.94 → 53.08 µsat1000×2).
End to end it wins, but narrowly and not where expected. Four order-flipped
passes, two AOT bundles from separate worktrees, one lane per process, 41
samples after 8 warmup:
fanout-wide— 20 streams × 1,000-row partitionsfanout— 100 streams × 100-row partitionskeyed-pk(guard)feed(guard)writes(zero-ceiling control)The win scales with the result the pass re-walks: at 1,000 rows the removed pass
is 34.86 µs and the lane moves −9% to −12% in every pass; at 100 rows it is
4.22 µs and the effect does not clear the harness floor (the control moved
+16.5% in its worst pass).
And then the release suite flagged it.
--fail-on-regressionfired onHigh-Cardinality Stream Fan-out / 100 streams × 200 writes / resqliteat+91.4% — the same shape the A/B says is 12–25% faster. Both readings are
correct, and unpicking that is the experiment's real result.
That lane's wall is quantized: its settle loop waits a 200 ms quiet window
and stops at the first one with no new emission, so a measured iteration that
emits nothing costs one window and one that emits anything costs two. The lane
is bimodal at ~246 ms and ~448 ms around roughly 46 ms of work, and +91.4% is
exactly one window. Filed as
#318.
What decides the mode is whether a convergence emission lands in a measured
iteration. Running the lane 35 times per arm:
origin/mainThe third row is the attribution — wiring the flag through every layer while the
worker ignores it reproduces the baseline rate, so the shift is the decoder.
Harness-side content comparison found zero same-content emissions on either
arm: every extra emission carries genuinely different data, and the candidate
simply makes more of them during the burst (post-baseline emissions on the first
iteration: 91–101 on
origin/mainover six runs, 102–115 on the candidate overeight — about +12%).
Why: the second walk was not waste
resqlite_query_hashresets the statement on exit, so thedecodeQuerythat follows opens a fresh read transaction on a newer WAL snapshot. During a
burst, the rows a stream emits are fresher than the digest stored beside them,
and the stream converges in fewer emissions because each emission has skipped
ahead. The one-pass decoder takes exactly one snapshot — hash and rows always
agree, which is the more defensible contract and what exp 228's invariant asks
for in spirit — but it gives up that free refresh and needs one more rerun to
catch up.
So the second SQLite pass costs 35–45% of a changed rerun and it buys emission
freshness. Nobody knew it was buying anything, which is why this looked like
free money.
Outcome
Rejected, on two counts that compound. The win is narrower than first
measured — real and reproduced on wide results, invisible on narrow ones — and
what it does deliver, it cannot pay for: trading roughly 12% more emissions
during a write burst for less wall time is a semantic-shaped trade in the exact
dimension the library advertises — resqlite's reactive story is
that hash suppression keeps streams from re-emitting. This repo's record on
semantic-shape trades (exps 197, 212, 213) is that a reproduced win does not
settle them, so that call belongs to a maintainer rather than a scheduled run,
and the runtime is archived.
It reopens on any of three things: a design that keeps one snapshot per rerun
and still converges as fast (a cheap freshness re-check — row count, a
data-version probe — rather than a second full walk, gated on result size since
that is where the win lives); evidence that burst-time intermediate emissions
are immaterial because the UI layer coalesces them; or a maintainer's ruling
that the wall time is worth the emissions.
What stands regardless of the verdict: the rerun census (claim 283.1), the pass
price (283.2), the predictor's accuracy (283.3), the corrected end-to-end result
(283.4), the awaited-burst measurement trap (283.5), the freshness finding
(283.6), the lane quantization (283.7) and the sentinel rule (283.8).
Orthogonal to #155 (exp 160, incremental view maintenance): that prototype
reduces how often a stream reaches the fallback rerun; this was about making the
fallback rerun cheaper.
Host caveat. Load average ran 1.8–14.1;
mediaanalysisdheld a core for muchof the session, and the corrected collection was taken in the quietest window
available (1.8–4.1). The zero-ceiling
writescontrol moved +16.5% in its worstpass and +3.7% at the median, which is why only
fanout-wide— consistent at−9% to −12% in every pass — is read as an effect.
Test plan
dart analyze --fatal-infosclean on the reverted treedart test test/query_decoder_test.dart test/stream_test.dart test/reader_pool_test.dart test/experiment_outcomes_test.dart test/knowledge_impact_test.dart— all passingarchive/exp-283)benchmark/ab_drift_check.dartverdicts above--repeat=5 --fail-on-regression --fail-on-memory-regression --compare-to=…exp270-parent-baseline.md— the run that caught the trade-off; not committed, since no runtime shipsbenchmark/finalize_experiment.dartgreen