we asked the t3 code maintainers whether they want this, opt-in or on by default, in discussion 29954. if you run t3 code, you can check what it would do for your own traffic with a read-only script:
python3 commit_window_estimate.py \
~/.t3/userdata/statev2.sqlite 2
numbers from busy installs are the most useful thing to post there.
the short version
- the problem: the t3 code server commits to sqlite once per streamed event, and each commit rewrites about 29 pages. it is tracked upstream in issue 24003. pr 24145 batched queued snapshots and was closed under the one-problem rule; its reviewer, @simasrazinskas, found live events rarely queue up and suggested grouping commits across threads instead.
- what we built: that suggestion, with a time window as the trigger. plain
node,message,turn-itemandplanupdates wait up to 25 ms, then commit together. any other write commits the queue first, so order is unchanged. - what it costs: a hard crash can lose up to one window of events that no client had seen yet. in a 4-turn pilot, a 150 ms window saved no more and added 150 ms of lag, so the window has to stay short.
- how sure we are: measured on 156 real codex turns (6,674 stored events) at 30 turns per arm one thread at a time and 24 per arm four threads at once. commit counts are tight; byte savings are clear only with four threads; lag changes are within noise. claude code turns are still to run (its weekly limit was spent), so this is one provider for now. the simulated a/b and the replay estimate below are kept as upper bounds and are no longer the headline.
one commit per event
experiment 03b preloaded a small shim that counts the server's write syscalls per file and parses wal frame headers. the counts cover the server process only. turns are the repo's devReplay acp agent, driven through the real adapter, orchestration, projections and checkpoints. p50 (nearest rank) over 10 tapped turns, on a 618.8 MiB doctor-compacted seed derived from the 654.6 MiB database, at 4 KiB pages:
| per turn | 4 KiB pages |
|---|---|
| commits | 52 |
| wal frames | 1,524 (29 per commit, 297 distinct pages) |
| wal bytes | 5.99 MiB |
| checkpoint copy-back | 1.19 MiB |
| sqlite bytes written | 7.1 MiB, 83% of it wal frames |
- commits keep rewriting the same pages. the event table and its indexes account for 42.5% of frames.
sqlite_sequence, the projection metadata row, the thread row and itsproject_updatedindex each sit on one page that is rewritten about 46 times per turn. - a turn writes about 29× what it adds (median of the per-turn ratios): about 7.1 MiB written to grow the database by about 242 KiB.
- checkpoint tuning cannot remove the main cost. a smaller checkpoint threshold raises copy-back. a larger one lowers copy-back somewhat (modelled), but cannot touch the wal frames, which are paid on every commit. the lever is the number of commits.
the change
fork branch exp/poh/04-commit-batching, based on upstream main 5b9731ce. the feature is one commit behind T3CODE_EXPERIMENTAL_COMMIT_WINDOW_MS, off by default.
- one queue for all runs and threads. a single
EventSinkqueue holds the plain node, message, turn-item and plan updates from every run and thread. it commits as one transaction at most N ms after its first entry. - order is preserved. any other
EventSinkwrite commits the queue first, so commit order and sequence order stay arrival order. an event whose normalization compares against a pending entity commits that entity first. - clients never see unstored events. publication follows the commit.
- graceful shutdown flushes the queue.
the failure modes are the part we asked upstream about:
- hard crash: up to one window of events that were never published is lost.
- batch write fails (disk full, or a lock timeout that outlasts 2 retries): the error is logged, the window's events are dropped, and that thread's next deferred write fails with the cause. without the flag, the single write fails at its call site.
measured on real turns
real codex agent turns (codex-cli 0.162.1, gpt-5.6-luna, low effort) ran through t3 code's real codex adapter on a main that includes 29806. the server was a fresh bench home on the public synthetic seed after t3 doctor --fix (993 events, 70.8 MiB). each turn did real work in a fresh scratch repo: read files, run a command, edit a file, explain, and one long answer with no tools. the same prompts ran in the same order in both arms, alternating off and 25 ms (abba). the turns ran 136 commands and made 37 edits, and none were excluded.
two shapes: one thread at a time (s1), and four threads started together (s4), like subagent fan-out. per-event numbers are the fair comparison because real turns vary in length.
| codex, p50 | off | 25 ms | change [95% ci] |
|---|---|---|---|
| s1 commits per event (30 turns per arm) | 0.704 | 0.547 | −0.156 [−0.18, −0.11], −22% |
| s1 commits per turn | 29 | 22 | −7 [−11, −3] |
| s1 sqlite KiB per event | 111 | 94.5 | −16.9 [−30.6, +15.7] |
| s1 event lag p50 | 60.5 ms | 75.5 ms | +15 [−20, +41] |
| s4 commits per event (24 turns per arm) | 0.703 | 0.542 | −0.16 [−0.19, −0.14], −23% |
| s4 sqlite KiB per event | 115 | 101 | −13.4 [−18.4, −7.7], −12% |
| s4 sqlite MiB per turn | 4.65 | 4.19 | −0.46 [−1.1, −0.09], −10% |
- the saving is real but about half the simulated one. codex events mostly arrive more than 25 ms apart, so fewer of them share a window than in
devReplayturns. - four threads at once did not add coalescing for codex. commits per event with the window on are the same in s4 (0.542) as in s1 (0.547).
- no measurable cost. event lag, first event, first assistant text, frame gap, turn wall, cpu and rss all moved by less than their noise. real provider jitter is larger than 25 ms.
- an unexplained burst. the second four-thread batch in each server boot wrote about 200 extra commits that stored no event, in 11 of 12 runs, in both arms. the s4 rows above use first batches; the second batches are in the data. it does not favour either arm.
simulated turns (upper bound)
four arms. pilot slots 1–3 ran all four (a b c d / d c b a) at fc7cfbeb; slots 4–8 ran off, 25 ms and idx (a b c / c b a) at be02c345; the diff between the two touches only the pooling script, so the slots pool. each slot is one boot with 2 tapped turns.
off: no flags. 8 slots, 16 turns.w25: 25 ms window. 8 slots, 16 turns.idx: no flags, on a seed with the correlation index dropped and the command index partial. 8 slots, 16 turns.w150: 150 ms window. pilot only: 2 slots, 4 turns.
all 13 metrics: median shift vs off, with a bootstrap 95% interval
| metric | off p50 / p95 | Δ 25 ms | Δ idx | Δ 150 ms (pilot) |
|---|---|---|---|---|
| sqlite MiB per turn | 7.79 / 8.99 | −1.62 [−2.90, −0.49] | −0.76 [−1.90, +0.53] | −1.64 [−3.10, −0.47] |
| commits per turn | 51.5 / 52 | −22 [−23, −21] | 0 [−1, +1] | −22 [−23, −21] |
| wal frames per turn | 1580 / 1670 | −372 [−461, −236] | −110 [−203, +19] | −370 [−476, −240] |
| first live event (ms) | 68.5 / 398 | −18 [−67, +33] | −10 [−60, +36] | +4 [−57, +278] |
| first assistant delta (ms) | 802 / 1280 | −86 [−222, +145] | −91 [−228, +185] | +38 [−117, +402] |
| event lag p50 (ms) | 17 / 90 | +15.5 [−14, +20] | −1 [−32, +7] | +150 [+118, +173] |
| event lag p95 (ms) | 236 / 916 | +47 [−96, +160] | −40 [−180, +31] | −14 [−112, +456] |
| ws frame gap p50 (ms) | 220 / 240 | +27.5 [+17, +38] | +2.5 [−8, +17] | +8.5 [−18, +32] |
| assistant delta gap p50 (ms) | 427 / 492 | −3.5 [−15, +1.5] | −2 [−13, +7.5] | +21 [+9.5, +32] |
| turn wall (ms) | 5710 / 7260 | −64 [−345, +338] | −89.5 [−376, +154] | −173 [−407, +618] |
| peak rss (MiB) | 534 / 570 | +24 [−5, +41] | 0 [−14, +24] | −9 [−21, +6] |
| server cpu per turn (s) | 1.10 / 1.81 | −0.27 [−0.67, +0.45] | −0.26 [−0.59, +0.33] | −0.33 [−0.74, +0.59] |
| reconnect replay wire (KiB) | 110 / 112 | 0 [−3, +3] | 0 [−3, +3] | 0 [−3, +3] |
how we read it:
- on simulated turns, 25 ms cuts commits by 43%, wal frames by 24% and sqlite bytes by 21%. reconnect replay wire bytes do not change.
- event lag p50 is the only latency metric with a tight interval. its median rises from 17 to 32.5 ms; the shift is +15.5 ms [−14, +20], below the 25 ms window that bounds the added wait by design. the other latency intervals (first live event, first assistant delta, turn wall, event lag p95) are wide. they rule out large regressions, not small ones.
- the window merges ws frames. each turn sends 3 fewer, and the frame gap grows by 27.5 ms. assistant text keeps the same cadence.
- 150 ms saves no more and adds 150 ms of event lag in the 4-turn pilot, so the window has to stay short. it also shows the latency metrics catch a real delay.
- the index trim had no measurable per-turn effect at 16 turns. upstream has since merged one part of it, the correlation-index removal (pr 23211); the a/b's seeds predate that.
replay estimate (proxy)
the simulated turns are one agent on one thread. to see what the rule does on real traffic, the estimator replays orchestration_events in sequence order, read-only, using the ingestion timestamps (occurred_at). it counts commits under the same window rule. the baseline is one commit per event.
one install, 2026-11-03 03:30–05:28 utc, 9,292 events across 21 threads, mostly the hermes acp agent with delegated subagents:
| window | commits | fewer than one per event |
|---|---|---|
| 10 ms | 6,109 | 34.3% |
| 25 ms | 5,471 | 41.1% |
| 50 ms | 5,051 | 45.6% |
this is an estimate. it assumes one commit per event, treats ingestion time as commit time, and does not model the extra flushes forced when a pending entity is compared against. we transcribed these numbers from the posted discussion; the source database keeps changing, so we did not re-run them for this study (data).
re-run after 29806
29806 merged on november 3, so we re-ran the a/b on top of it. it was a 2×2 of window off vs 25 ms, on the parent of its merge commit (9def583f, "pre") and on the merge commit itself (550c5fa3, "post"). the arms were interleaved inside each job: 4 jobs × 3 slots, so 12 slots and 24 tapped turns per arm, all on github-hosted ubuntu-latest runners (4 vcpu). the seed is synthetic and public, about 200 MiB and 58.7k events from a committed generator. each base's t3 doctor --fix ran on its own copy first. on post that pruned the seed to 993 events and 70.5 MiB; on pre it compacted it to 188 MiB.
| per turn (p50) | pre | post |
|---|---|---|
| commits, off → 25 ms | 51.5 → 29.5 | 51.5 → 29.5 |
| sqlite bytes, off → 25 ms | 6.39 → 4.16 MiB | 6.21 → 4.13 MiB |
| event lag added by the window | +21 ms | +21 ms |
- the window's saving does not depend on pruning. post's saving minus pre's is +0.14 MiB per turn [−0.81, +0.61], zero within noise.
- pruning lowers per-commit cost only a little. with the window off it saves 3.3 KiB per commit [−8.5, −0.1], about 3%. its large effect is file size at rest, so the two changes stack.
- absolute bytes differ from the a/b above because the seed differs; compare the deltas. turn wall, first live event, cpu, rss and reconnect bytes did not move beyond noise (data, run).
the page size trade
the same tap measured bigger pages. they pack the event rows better, but each commit then writes bigger frames:
| page size | file (doctor-compacted) | saved | sqlite writes per turn | vs 4 KiB |
|---|---|---|---|---|
| 4 KiB (default) | 618.8 MiB | 7.1 MiB | ||
| 8 KiB | 566.6 MiB | −8.4% | 11.8 MiB | 1.67× |
| 16 KiB | 527.9 MiB | −14.7% | 21.9 MiB | 3.1× |
wire bytes were identical, and no latency, rss or cpu metric moved beyond noise at n = 10 boots per arm. the cost is ssd write volume, so page size only becomes worth it once turns commit far less often. an earlier a/b measured /proc write_bytes and reported 2.1× at 16 KiB. on that host's filesystem that counter had 16 MiB of round-to-round noise, so the syscall tap replaced it (noise under 0.7 MiB). flag: T3CODE_EXPERIMENTAL_PAGE_SIZE; design note.
background: where the bytes sit
we started from a different question: could the provider own the transcript, with t3 code keeping only a cache? byte accounting on the same 654.6 MiB database said that is the small lever.
t3 doctor --fix, and under three simulated policies, each vacuumed. D drops superseded snapshot bodies, A also evicts tool output, B lets the provider own every transcript byte. source: bytes-by-class.json.- text is 3% of the file. messages, reasoning and tool input total 18.6 MiB.
- tool output is 42.2 MiB (6.8% of the 619.1 MiB post-doctor file). evicting it saves 37.9 MiB (6.1%), and no adapter can serve it back today. even full provider ownership (policy B) removes only a third. ids and metadata, indexes and page slack are 92% of what remains.
- superseded snapshot bodies were the biggest waste. we built a fold for them (experiment 02, flag
T3CODE_EXPERIMENTAL_SNAPSHOT_FOLD): −20.1% afterVACUUM, 18/18 projection tables equal after rebuild. its review found one race (resume check vs replay read) that pr 29806 also handles. both fall back to a snapshot for a cursor in removed history. the designs differ: our fold kept a per-thread watermark, while 29806 uses one global pruning boundary, with retention exceptions such as each stream's newest event. - 29806 merged on november 3; 29811 was closed. 29806 prunes events the projections already cover, which covers most event-log bytes and is why we dropped the fold. 29811 would have returned freed pages at startup for small databases; its author closed it because
t3 doctoralready converts databases to incremental auto-vacuum and the server then returns freed pages every minute. on this database the event log is 408 of 654.6 MiB including indexes. re-measured on top of it, pruning trims per-commit cost by about 3% and leaves the window's saving unchanged.
the case for provider-owned history is freshness: turns run in another client would show up on the next open. it is not bytes.
what this does not show
- one install, one user, linux, under memory pressure. bytes and commit counts are deterministic. latency intervals are wide except event lag.
- one real provider so far. the real-turn a/b covers codex only; claude code was rate-limited and hermes acp failed on its configured model. the
devReplaya/b and the replay estimate are proxies and overstate the saving (43% and 41% vs 22% measured). - an unexplained commit burst on the second four-thread batch per server boot, in both arms; not yet traced.
- small n. 16 tapped turns per arm for off, 25 ms and idx; the 150 ms arm is a 4-turn pilot. page size: 10 tapped turns per arm.
- different bases. each experiment was cut from the upstream main of its day (listed in sources); none of these numbers describe a later main, except the re-run after 29806, which uses a synthetic seed.
- only the sqlite share. the tap counts the server's write syscalls, not device writes. issue 24003's macos data shows the per-commit cost explains only part of the disk writes there.
- the fold's in-pass latency was never measured. host memory pressure stopped every bench server before the fold window; the only bound is the longest fold transaction, 49.8 ms.
sources
fork branches on kvnloo/t3code, each pinned to the sha the results came from, with the upstream main it was cut from:
| experiment | branch @ sha | upstream base |
|---|---|---|
| 00 baseline harness | exp/poh/00-baseline @ 258d7a4e |
9bcf7533 |
| 01 byte accounting | exp/poh/01-byte-accounting @ e17f68ce |
84537c4d |
| 02 snapshot fold | exp/poh/02-snapshot-dedupe @ e3155fc4 |
6f413648 |
| 02b rebuild divergence | exp/poh/02b-rebuild-divergence @ 3b263def |
30b9c4a9 |
| 03 ids, indexes, page size | exp/poh/03-id-index @ 93e43918 |
065a4335 |
| 03b page-size writes | exp/poh/03b-page-writes @ 3676fef0 |
065a4335 |
| 04 commit window (a/b code) | exp/poh/04-commit-batching @ ea608f37 |
5b9731ce |
| 04 live estimator | exp/poh/04-commit-batching @ 45f2edca |
5b9731ce |
| 04b re-run, post | exp/poh/04b-post29806 @ a6382bbe |
550c5fa3 |
| 04b re-run, pre | exp/poh/04b-pre29806 @ b3c71850 |
9def583f |
| 04c real codex turns | exp/poh/04c-real-turns @ 4d373646 (arms ran at b3a89e7a) |
550c5fa3 |
upstream: discussion 29954 (our proposal), issue 24003 and our comment there, pr 24145 (queued-snapshot batching, closed), pr 29806 and pr 29811 (pruning and page reclaim, open), pr 23211 (unused correlation index, merged), discussion 6897 (retention and compaction, closed).
receipts
this post is rendered from posts/t3code-storage-write-amplification.md. the frozen study, its manifest and its pinned sources live under studies/t3code-storage-write-amplification when it has been imported, or on its study pull request when it has not. the research log lists the study's status and links.