Skip to content

LeiosDb: bound the WAL, remove the write hotspot, stop dying on lock contention - #2245

Merged
ch1bo merged 3 commits into
ch1bo/leios-eb-validationfrom
ch1bo/leios-db-fixes
Aug 29, 2026
Merged

LeiosDb: bound the WAL, remove the write hotspot, stop dying on lock contention#2245
ch1bo merged 3 commits into
ch1bo/leios-eb-validationfrom
ch1bo/leios-db-fixes

Conversation

@ch1bo

@ch1bo ch1bo commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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-devnet at TPS=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_size was a silent no-op. It was set after journal_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_timeout was set, so SQLite refused a contended lock immediately and all waiting happened in an application loop whose Nth sleep was 100us * N — cumulative 100us * 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 = 1000 now 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_txHashBytes was the dominant write cost. It existed for one query — decrementing missingTxCount for 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 ebs and txs is now two tables: ebTxs keeps the ordered body, and ebsMissingTxs holds 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:

WAL generated per row
with idx_ebTxs_txHashBytes 61.0 MB 4 494 B
two-table, no missing txs 1.8 MB 134 B
two-table, 2% missing 3.2 MB 235 B

EB body ingest latency at matched closure size (~6 500 txs):

median p90
before 179 ms
busy_timeout only 80 ms 284 ms
busy_timeout + two-table 36 ms 84 ms

Per instance, over a full run:

before after
WAL 9 304 MB against a 54 MB database 57–67 MB against a 3 070 MB database
first tooLate vote ~25 min ~1 h 15 min
certification stopped at ~2 h ran 5 h 20 min

The WAL figure is #2233: a writer waiting inside a deferred transaction pinned a read snapshot, freezing back-fill (framesCopied stuck at 546 while framesInLog reached 241 239), so no checkpoint could reset the log.

Instrumentation

TraceLeiosBlockIngest reports, 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.

TraceLeiosDbBusyRetry fires 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 TraceLeiosDbBusyRetry events over 4.5 hours — each one a BEGIN IMMEDIATE that 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. Neither ebTxs, ebsMissingTxs nor txs is pruned, so per-EB cost continues to rise with database size; pruning both resolution tables on the same age rule is the next step, and idx_ebsMissingTxs_ebHashBytes exists to support it.

@nfrisby nfrisby 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.

The code itself looks great. Many of the comments were wrong/incomplete/(weirdly) harmfully misplaced, so I'm Requesting Changes just for that.

Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoDb/SQLite.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs Outdated
Comment thread ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs Outdated
@ch1bo
ch1bo force-pushed the ch1bo/leios-db-fixes branch from 37c83cc to ce5ffc3 Compare August 28, 2026 19:06
@ch1bo
ch1bo force-pushed the ch1bo/leios-db-fixes branch from ce5ffc3 to 55c299f Compare August 29, 2026 13:30
'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
ch1bo force-pushed the ch1bo/leios-db-fixes branch from 55c299f to 988ad18 Compare August 29, 2026 13:32
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
ch1bo force-pushed the ch1bo/leios-db-fixes branch 2 times, most recently from 0bafb08 to 049b6c5 Compare August 29, 2026 13:57
'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
ch1bo force-pushed the ch1bo/leios-db-fixes branch from 049b6c5 to 2a18f25 Compare August 29, 2026 13:59
@ch1bo
ch1bo requested a review from nfrisby August 29, 2026 14:03
@ch1bo
ch1bo merged commit f6f4650 into leios-prototype Aug 29, 2026
3 of 19 checks passed
@ch1bo
ch1bo deleted the ch1bo/leios-db-fixes branch August 29, 2026 14:47
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.
@ch1bo ch1bo added the Leios label Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LeiosDb volatile/immutable split + GC Prototype: EB validation

2 participants