Skip to content

Exp 209: explicit heterogeneous read batch - #223

Closed
danReynolds wants to merge 1 commit into
mainfrom
exp-209-heterogeneous-read-batch
Closed

Exp 209: explicit heterogeneous read batch#223
danReynolds wants to merge 1 commit into
mainfrom
exp-209-heterogeneous-read-batch

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

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 uses Future.wait([db.select(...)]) — each future is a full reader-pool _dispatch walk 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 at clamp(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:

final results = await db.selectAll([
  const ReadStatement('SELECT body FROM users WHERE id = ?', [1]),
  const ReadStatement('SELECT COUNT(*) AS c FROM tasks'),
  const ReadStatement('SELECT value FROM settings WHERE key = ?', ['theme']),
]);

The reader worker handles a new SelectBatchRequest sealed variant. Each statement runs on the worker's dedicated reader connection through the existing executeQuery path — 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 ResqliteQueryException propagates with the failing SQL / parameters intact. Inside a transaction, selectAll falls back to sequential Transaction.select calls 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.

Lane Shape Parallel Batch Δ
point 20 single-row PK lookups 0.366 / 0.259 ms 0.099 / 0.103 ms −72.9% / −60.2%
medium 20 × ~10-row list SELECTs 0.281 / 0.259 ms 0.151 / 0.149 ms −46.3% / −42.5%
large [guard] 4 × 10 000-row SELECTs 1.567 / 1.640 ms 6.397 / 4.506 ms +308% / +175%

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 selectAll is not a universal substitute for parallel select fan-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-worse select, 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.wait fanout 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's executeStatements(...) 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 writes
  • dart test test/database_test.dart test/transaction_test.dart — no regressions
  • dart test test/benchmark_pipeline_test.dart — terminal-disposition guard passes
  • dart run benchmark/experiments/heterogeneous_read_batch.dart --order=parallel-first and --order=batch-first
  • dart run benchmark/finalize_experiment.dart --experiment=experiments/209-heterogeneous-read-batch.md
  • dart run benchmark/check_experiment_dispositions.dart
  • dart run build_runner build --delete-conflicting-outputs (fresh-worktree Drift outputs for the full test suite)

🤖 Generated with Claude Code

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.
Copilot AI review requested due to automatic review settings July 1, 2026 11:17
@danReynolds danReynolds added approved Experiment succeeded: a kept win or a passing guard type: moonshot Frontier experiment challenging an architecture assumption labels Jul 1, 2026

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.

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 new SelectBatchRequest.
  • 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.

Comment thread experiments/209-heterogeneous-read-batch.md
@danReynolds

Copy link
Copy Markdown
Owner Author

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 selectAll vs parallel fan-out based on the shape of their data. That's precisely the case the rule calls out — "if the caller has to pick the new API based on the shape of their data, it is a trade-off, not a massive win" — so the new public ReadStatement/selectAll surface isn't justified. Evidence preserved on the branch; exp-209-claim kept to reserve the number.

@danReynolds danReynolds closed this Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Experiment succeeded: a kept win or a passing guard type: moonshot Frontier experiment challenging an architecture assumption

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants