Experiment 257: Native independent-autocommit envelope

Date: 2026-07-29T06:29:09-04:00

Status: Rejected

Category: Moonshot

Direction:stream-rerun-dispatch

Benchmark Run: none (focused order-flipped cross-worktree gate; exact

prototype and harness archived together)

Archive:archive/exp-257

Problem

Exp 180 already coalesces concurrently issued standalone writes into one

MultiExecuteRequest. The worker still loops over the members in Dart,

allocating and freeing one native parameter arena, calling resqlite_execute,

reading one native result, and harvesting dirty dependencies for every

statement. Each member deliberately remains an independent SQLite autocommit.

That left a tempting distinction after exp 197: true group commit is fast but

changes visibility, durability, and failure semantics; perhaps the runtime

could keep those semantics and remove only the repeated Dart/native

orchestration.

Preflight selection

Wednesday is moonshot-default cadence. Four live candidates were ranked before

the claim:

CandidateIncidencePotential valueComplexityDisposition
Native independent-autocommit envelope332selected bounded moonshot
Alias-aware BatchRequest BLOB retest122no fresh production incidence; adjacent BLOB work already active
sqlite3mc 2.4.0 audit211current signal waits for a relevant 3.54.x point release or hot-path changelog
Stream lineage / rerun scheduling333collided with open stream work in PRs #274 and #155

The selected candidate had a public workload already in the repository, no

public API growth, and an immediate kill gate.

Hypothesis

Assumption challenged: preserving independent autocommit outcomes requires

one Dart-to-C execution orchestration per coalesced member.

For a homogeneous, parameterized MultiExecuteRequest, pack all parameter sets

once and call one private native loop. The loop reuses the cached statement and

writer mutex, but fully steps and resets each set outside an explicit

transaction so every success remains its own autocommit. It stops at the first

error; Dart snapshots that error, then resumes later members through the scalar

path to retain per-caller failure isolation.

Acceptance required the public homogeneous insert burst to improve at least

10% in both process orders. Sequential and mixed-SQL routes had to stay neutral.

The candidate also had to preserve per-member results, later writes after an

error, trigger/FK side effects, and final stream state. Failure to reproduce

the 10% insert win was the predeclared kill condition.

Approach

The exact prototype is commit d502717 at archive/exp-257:

  1. add a private resqlite_run_independent_autocommits C entry point;
  2. admit only groups with two or more writes sharing SQL text and positive

parameter arity;

  1. flatten the whole parameter matrix with allocateBatchParams;
  2. collect the successful WriteResult prefix and first error in one call;
  3. resume the remaining suffix through executeWrite; and
  4. harvest one dirty-dependency union for the native prefix.

The prototype changed no public API. Its expanded coalescing suite covered

monotonic insert IDs, interleaved errors with later commits, triggers, FK

cascades, exact error snapshots, mixed-SQL fallback, and final stream state.

The decision harness in the archive runs 3 warmups plus 11 samples per lane.

Each burst contains 256 concurrent calls; a measured round contains 20 bursts

(5,120 independent autocommits). The insert lane uses the public

Future.wait([db.execute(...)]) shape. An integer-only no-op UPDATE lane

removes WAL payload and isolates fixed envelope machinery. Mixed SQL and

sequential writes are inert controls.

Results

Separate baseline and candidate worktrees ran in both orders. Lower is better;

delta is candidate relative to baseline.

OrderLaneBaseline p50Candidate p50Delta
candidate → baselinehomogeneous insert75.502 ms62.132 ms−17.7%
baseline → candidatehomogeneous insert57.564 ms55.503 ms−3.6%
candidate → baselineinteger-only no-op15.286 ms17.994 ms+17.7%
baseline → candidateinteger-only no-op15.091 ms17.186 ms+13.9%
candidate → baselinemixed-SQL fallback59.385 ms61.827 ms+4.1%
candidate → baselinesequential control61.125 ms60.112 ms−1.7%

The primary lane does not reproduce the required 10% win: it improves 17.7%

in one order but only 3.6% in the reverse order. More importantly, the

integer-only no-op lane is slower in both orderings. Removing repeated FFI

calls did not remove the dominant independent autocommit work, while the

whole-group parameter/result allocation added enough fixed cost to regress a

lane with almost no SQLite payload.

An initial run of the older writer_pipelining.dart harness changed sign

(+16.1% slower, then −18.8% faster), which is why the larger warmed gate above

was used for the decision rather than treating a noisy smoke result as a win.

The mixed and sequential controls remain within the same small host-noise band;

they do not rescue the failed homogeneous lanes.

Safety review

The archived implementation passed its focused tests, but review found

additional blockers that would have required redesign even if performance had

cleared the gate:

sqlite3_step() returns SQLITE_ROW; the loop ignored reset's result and

could report an uncommitted member as successful.

to carry them, and a union attached to the last success could schedule

invalidation after earlier member futures resolved. OR FAIL and

conditional-trigger side effects make that observable.

homogeneous burst of large BLOBs can add tens of MiB beyond the scalar

path's one-row arena, so any retry needs an admission cap and peak-RSS gate.

retained SQLITE_STATIC bindings after Dart freed the packed arena. A kept

design needs explicit layout assertions and sqlite3_clear_bindings().

calls wait inside leaf FFI for the whole burst rather than between members.

These are documented reopen requirements, not patched in the rejected

prototype.

Outcome

Rejected. One native call is not a useful substitute for one native call

per member while the expensive semantic boundary — N independent SQLite

autocommits — remains. The product-shaped insert win missed the bar in the

reverse order, and the int-only diagnostic regressed consistently. The runtime

prototype was archived and then reverted.

Keep exp 180's scalar member loop. Reopen this shape only if a design avoids

whole-group repacking (for example, transport already delivers a bounded

native-ready arena), clears 10% on public 100/200-member insert bursts in both

orders, holds 2/8/32-member bursts neutral, and includes BLOB peak-RSS plus

diagnostic-latency guards. It must also honor reset-time commit errors and

apply dependency changes before resolving any affected member.

True commit merging remains a separate semantic/API question. This experiment

does not weaken exp 197's measured ceiling; it shows that retaining every

autocommit while only collapsing the FFI loop is too small and too costly.

Transferable lesson

Collapsing N calls into one native envelope is not automatically amortization.

If the envelope first materializes all N inputs and results, it can replace an

optimized scalar encoder and bounded one-row lifetime with a larger allocation

whose cost exceeds the removed call boundaries. Benchmark the aggregate

materialization path, not only the native loop.

Test plan

Archived prototype:

Final publication branch:

autocommits, trigger/FK effects, and awaited final stream state