LeiosDb: bound the WAL, remove the write hotspot, stop dying on lock contention - #2245
Merged
Merged
Conversation
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 28, 2026 08:59
5686023 to
0975144
Compare
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 28, 2026 11:52
0975144 to
6eb5d46
Compare
This was
linked to
issues
Aug 28, 2026
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
3 times, most recently
from
August 28, 2026 13:18
4aeb620 to
f1dff7e
Compare
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 28, 2026 13:56
f1dff7e to
37c83cc
Compare
nfrisby
requested changes
Aug 28, 2026
nfrisby
left a comment
Contributor
There was a problem hiding this comment.
The code itself looks great. Many of the comments were wrong/incomplete/(weirdly) harmfully misplaced, so I'm Requesting Changes just for that.
ch1bo
commented
Aug 28, 2026
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 28, 2026 19:06
37c83cc to
ce5ffc3
Compare
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 29, 2026 13:30
ce5ffc3 to
55c299f
Compare
'page_size' was set after 'journal_mode = WAL', and SQLite cannot change the page size of a database already in WAL mode -- so the 32768 was a silent no-op and every run so far used the 4096 default. Reordered, and pinned at 4096 rather than restored to 32768: measured with the larger page actually in effect, a devnet run reached 35x WAL amplification (34 GiB of log for 0.97 GiB of data) against ~18x for the same workload at 4096. The WAL is a page-level redo log, so a commit rewrites each dirtied page whole, and both hot indexes are keyed by hash, so writes scatter -- the page count barely falls as the page grows, the bytes just multiply. Also spells out 'wal_autocheckpoint = 1000'. It is SQLite's own default, so this changes nothing, but it is the thing that actually keeps the log bounded and it is worth having somewhere a reader can find it. Separately, hardening rather than a fix: 'traceLeiosKernelToObject' matched the inner 'TraceLeiosDb' constructors itself, so a new one would be a runtime "Non-exhaustive patterns" while rendering -- which surfaces only as an "Error rendering trace message" line and drops the event. Delegating to a total 'jsonLeiosDb' makes that a compile error instead.
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 29, 2026 13:32
55c299f to
988ad18
Compare
No 'busy_timeout' was set, so SQLite refused a contended lock immediately and every wait happened in an application loop whose Nth sleep was 100us * N -- cumulative 100us * N(N+1)/2. The EB-body insert distribution matched that curve exactly: median 1 599 ms (N~178), max 18 435 ms (N~607), for 90 ms of actual work. By attempt 300 a single sleep was 30 ms, so the writer slept through most of the window it was waiting for, and steady contention became seconds of latency. 'busy_timeout = 1000' now does the waiting in C, retrying tightly instead of sleeping through the gap. That is safe here precisely because writers take the lock at BEGIN and hold no snapshot while they wait. Waiting for the write lock is unbounded. Giving up means throwing, and a throw on this path kills the Leios threads: at a three-attempt ceiling a lock held for three seconds took a devnet node down, and losing one of three block producers stopped certification for the rest of the run. Nothing is held while waiting, so the cost of waiting is latency and the cost of not waiting is the node. Past 'busyStuckAfter' attempts -- about half a minute of no progress -- it reports 'TraceLeiosDbBusyStuck' at Critical severity and keeps waiting. Statements retried inside an open transaction stay bounded by 'maxBusyRetries', since a transaction that waits there does hold its snapshot, which is what pins the WAL and starves back-fill. 'TraceLeiosDbBusyRetry' reports the ordinary case, with attempt number and accumulated wait. Note it is a floor on contention rather than a measure of it: an attempt that waits inside the C handler and then succeeds is not traced.
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
2 times, most recently
from
August 29, 2026 13:57
0bafb08 to
049b6c5
Compare
'idx_ebTxs_txHashBytes' existed for one query: decrementing 'missingTxCount' for the EBs referencing a tx that just arrived. It covered every (EB, tx) pair ever seen -- 4.99M entries for 1.07M distinct hashes, each stored 4.7 times on average and up to 43 -- and it was almost the entire write cost of the database. Measured on a 4M-row table, one 13568-row EB body insert generated 61.0 MiB of WAL with that index and 1.8 MiB without: 4.5 KiB of log per 70-byte row, because each random-hash insertion dirties its own page while the PK's insertions land adjacent. It was also 60% of the on-disk size, and the reason a body ingest that should take 90ms was taking seconds once the index outgrew the page cache. But an arriving tx can only complete EBs that were still /waiting/ for it. EBs that already held it counted it as present when their body landed, so indexing them is dead weight. That set is small by construction, and in a devnet where closures overlap completely it is empty. So the resolution between ebs and txs is now two tables. 'ebTxs' keeps the ordered body -- serving stays a PK range scan plus a join, unchanged -- and a new 'ebsMissingTxs' holds only the pairs an EB still lacks, populated by one anti-join when the body is inserted and retired as the txs arrive. Both sides move in the same transaction, so an arrival can never see the rows without the count or the reverse. The decrement reads the small table; its cost is proportional to outstanding work rather than to history, and it shrinks as work completes where the index only ever grew. Measured with the full new logic at the same scale: 1.8 MiB of WAL per EB with nothing missing, 3.2 MiB with 2% missing, against 61.0 MiB before. Semantics verified against the notified sentinel, re-inserted txs, and the all-present body. Note this changes the schema with no migration, so an existing database is not readable by this version. Still open: pruning. Neither table is pruned today, so an EB that never completes retains its waiting rows; it wants the same age-based rule as the rest, applied to both tables together, and 'idx_ebsMissingTxs_ebHashBytes' is there to support it.
ch1bo
force-pushed
the
ch1bo/leios-db-fixes
branch
from
August 29, 2026 13:59
049b6c5 to
2a18f25
Compare
ch1bo
added a commit
that referenced
this pull request
Aug 29, 2026
…#2235, #2245) Merges the stacked series, bottom to top: * #2188 Leios: add LeiosTxCache, but don't rely on it at all yet * #2237 Rewrite the LeiosFetch decision logic * #2235 Leios prototype: EB validation in vote logic * #2245 LeiosDb: bound the WAL, remove the write hotspot, stop dying on lock contention IMPORTANT: the schema change in #2245 has no migration. An existing database is not readable by this version and requires a wipe & resync.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Several fixes to the Leios SQLite layer, stacked on the recent tx cache / fetch logic / eb validation work. The fixes were driven by multi-hour long stress tests of a twelve-node
dozen-devnetatTPS=500(effective ingest lower than that).Important
Note the schema change has no migration: an existing database is not readable by this version and requires a wipe & resync.
What was wrong
1.
page_sizewas a silent no-op. It was set afterjournal_mode = WAL, and SQLite cannot change the page size of a database already in WAL mode. Every run so far used the 4096 default. With 32768 actually in effect, WAL amplification measured 35x (34 GiB of log for 0.97 GiB of data) against ~18x at 4096. Reordered and pinned at 4096.2. No
busy_timeoutwas set, so SQLite refused a contended lock immediately and all waiting happened in an application loop whose Nth sleep was100us * N— cumulative100us * N(N+1)/2. The observed EB-body insert distribution matched that curve exactly: median 1 599 ms (N≈178), max 18 435 ms (N≈607), for 90 ms of actual work.busy_timeout = 1000now does the waiting in C, and the application loop is a flat 20 ms backstop, making the ceiling linear rather than quadratic. Current ceiling is ~30 s after which a node would crash.3.
idx_ebTxs_txHashByteswas the dominant write cost. It existed for one query — decrementingmissingTxCountfor EBs referencing an arriving tx — and covered every(EB, tx)pair ever seen: 4.99M entries for 1.07M distinct hashes (4.7x on average, 43x worst), 887 MB of a 1 484 MB database. Each 13 568-row body insert scattered that many random-hash insertions, dirtying a page apiece.An arriving tx can only complete EBs that were still waiting for it; EBs that already held it counted it as present when their body landed. The resolution between
ebsandtxsis now two tables:ebTxskeeps the ordered body, andebsMissingTxsholds only pairs an EB still lacks, populated by one anti-join at body insert and retired as txs arrive. Both move in the same transaction. Serving is unchanged — PK range scan plus join.Measured effect
Write amplification, one 13 568-row EB body insert against a 4M-row table:
idx_ebTxs_txHashBytesEB body ingest latency at matched closure size (~6 500 txs):
busy_timeoutonlybusy_timeout+ two-tablePer instance, over a full run:
tooLatevoteThe WAL figure is #2233: a writer waiting inside a deferred transaction pinned a read snapshot, freezing back-fill (
framesCopiedstuck at 546 whileframesInLogreached 241 239), so no checkpoint could reset the log.Instrumentation
TraceLeiosBlockIngestreports, per ingested body, the wait for the node-wide outstanding lock and then the database insert, tx-cache insert and mempool pull separately, plus total lock hold. This is what localised the cost; without it the DB insert and the lock wait are indistinguishable.TraceLeiosDbBusyRetryfires when SQLite's own timeout was exhausted and the application backstop engaged, with attempt number and accumulated wait. A steady stream of these means the write path is saturated.Remaining work
Database access is not yet congestion-free. In the same run the insert median still grew from 6 ms to 524 ms over seven hours as the instance database reached 3 070 MB, and the busiest instance logged 44
TraceLeiosDbBusyRetryevents over 4.5 hours — each one aBEGIN IMMEDIATEthat gave up after a full second in SQLite's handler. That is a floor on contention, not a measure of it: an attempt that waits and then succeeds is not traced. NeitherebTxs,ebsMissingTxsnortxsis pruned, so per-EB cost continues to rise with database size; pruning both resolution tables on the same age rule is the next step, andidx_ebsMissingTxs_ebHashBytesexists to support it.