Exp 209: explicit heterogeneous read batch - #223
Conversation
Add `db.selectAll([...ReadStatement...])` — the symmetric read-side analog to exp 208's `executeStatements`: many unrelated SELECTs, one reader-isolate round trip, results returned in statement order. Focused `heterogeneous_read_batch.dart` reproduces same-direction wins on the shape where per-query SQLite work sits below the reader-pool round-trip floor (point 20 PKs -72.9% / -60.2%, medium 20 short lists -46.3% / -42.5%) and reproduces the expected regression on the large-payload guard (4 x 10k -row selects +308% / +175%), confirming selectAll is complementary to parallel `Future.wait` fan-out rather than a hidden replacement for it. Category: moonshot. Direction: stream-rerun-dispatch. Accepted.
There was a problem hiding this comment.
Pull request overview
This PR introduces an explicit heterogeneous read-batching API (Database.selectAll) as experiment 209, allowing callers to amortize reader-pool isolate round trips by executing many small unrelated SELECTs in a single reader-worker request.
Changes:
- Added public
ReadStatement+Database.selectAll(List<ReadStatement>)API, with reader-worker support via a newSelectBatchRequest. - Added correctness tests covering ordering, empty batches, per-statement error SQL, and transaction fallback behavior.
- Added experiment 209 documentation, signals/index entries, and a focused benchmark + results artifact.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/select_all_test.dart | Adds tests for selectAll behavior (ordering, empty list, error propagation, transaction fallback). |
| lib/src/reader/reader_pool.dart | Adds ReaderPool.selectAll dispatching a new SelectBatchRequest. |
| lib/src/reader/read_worker.dart | Implements SelectBatchRequest execution loop and aggregate sacrifice decision. |
| lib/src/read_statement.dart | Introduces ReadStatement value object for batched reads. |
| lib/src/database.dart | Adds public Database.selectAll with transaction fallback to sequential Transaction.select. |
| lib/resqlite.dart | Exports ReadStatement. |
| experiments/signals/entries/209.json | Records experiment 209 signal entry (belief changes + next signals). |
| experiments/JOURNAL.md | Adds a journal lesson tying together reader parallelism vs request batching trade-offs. |
| experiments/index/209.json | Adds experiment index fragment row for 209. |
| experiments/209-heterogeneous-read-batch.md | Adds the full experiment writeup for 209. |
| benchmark/results/2026-07-01T11-11-39Z-exp209-heterogeneous-read-batch.md | Adds the committed focused benchmark output artifact for exp 209. |
| benchmark/experiments/heterogeneous_read_batch.dart | Adds the focused benchmark harness comparing parallel fan-out vs selectAll batching. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Closing under the public API is near-frozen rule (#224). The small-read batch win (~2.5–3.7×) is real, but it comes paired with a +175–308% regression on large reads that the caller has to avoid by choosing |
Hypothesis
Exp 208 just accepted the explicit-semantics version of exp 197's ceiling for writes:
executeStatements([...])collapses N heterogeneous writer-isolate round trips into one. The read path had a symmetric gap. To issue N unrelated SELECTs today the caller usesFuture.wait([db.select(...)])— each future is a full reader-pool_dispatchwalk plus a main→worker→main round trip. When per-query SQLite work is smaller than that round-trip cost — small point-query dashboards, prefetch bursts, "load these five widgets" pages — the round trips dominate. Reader-pool parallelism, capped atclamp(numProcessors - 1, 2, 4)workers by design, can't recover the difference.The bet: below the reader-pool round-trip floor, amortization wins; above it, parallelism wins. If both effects reproduce on the same harness, an explicit
db.selectAll(...)surface is justified — it doesn't compete with parallel fan-out, it complements it, and the caller picks based on the shape of the work.Assumption challenged: each SELECT must pay its own reader-pool round trip; parallelism always beats round-trip amortization.
Approach
Added one public value object and one database method:
The reader worker handles a new
SelectBatchRequestsealed variant. Each statement runs on the worker's dedicated reader connection through the existingexecuteQuerypath — no encoder change, no separate FFI surface, just N sequential prepared-statement acquires + step loops on one worker. The sacrifice decision aggregates estimated bytes across the batch, so a batch containing one huge query still triggers sacrifice while a fanout of small results does not.Failure semantics stay explicit: if any statement fails, the batch aborts on that statement and the original
ResqliteQueryExceptionpropagates with the failing SQL / parameters intact. Inside atransaction,selectAllfalls back to sequentialTransaction.selectcalls so the batch sees the transaction's uncommitted writes.Full detail in experiments/209-heterogeneous-read-batch.md.
Results
Focused harness:
benchmark/experiments/heterogeneous_read_batch.dart— three lanes on the same seeded database (10 000-row indexed table), order-flipped pair, 9 rounds per side.The small-read lanes reproduce a large batch win (~2.5-3.7× on point queries, ~1.75-1.9× on medium lists) — same sign, same ordering across the order flip. The large-read guard reproduces the expected regression (~3× slower), which is not a bug but the shape confirming the trade-off: one worker running four 10k-row reads back-to-back cannot beat four workers running them in parallel.
That guard result is load-bearing for the acceptance decision — it proves
selectAllis not a universal substitute for parallelselectfan-out. It's the right tool when many small heterogeneous reads are issued together;Future.wait([db.select(...)])is the right tool when each read is individually expensive enough for the reader-pool fan-out to matter. If the guard had not regressed, that would have indicated the prototype leaked a cost making it a strictly-worseselect, and the direction would have been rejected on evidence.Outcome
Accepted as a moonshot: explicit read-batch amortization is a real win on the small-heterogeneous-reads shape (
Future.waitfanout is round-trip bound there), and the large-read guard confirms the trade-off is a publish-time knob rather than a hidden default. The public API mirrors exp 208'sexecuteStatements(...)shape, so callers now have symmetric explicit-batching surfaces for reads and writes.The transferable lesson — that request batching and reply batching sit on different sides of the same round-trip floor, so exp 148's reply-batch rejection does not generalize to request-batch rejection — is added to
JOURNAL.md.Test plan
dart analyze --fatal-infos lib/(no issues)dart test test/select_all_test.dart— 4 new tests: results in order, empty-list no-op, per-statement error carries its own SQL, transaction fallback sees uncommitted writesdart test test/database_test.dart test/transaction_test.dart— no regressionsdart test test/benchmark_pipeline_test.dart— terminal-disposition guard passesdart run benchmark/experiments/heterogeneous_read_batch.dart --order=parallel-firstand--order=batch-firstdart run benchmark/finalize_experiment.dart --experiment=experiments/209-heterogeneous-read-batch.mddart run benchmark/check_experiment_dispositions.dartdart run build_runner build --delete-conflicting-outputs(fresh-worktree Drift outputs for the full test suite)🤖 Generated with Claude Code