Skip to content

fix(core): opening the ledger while another program holds it waits instead of failing busy - #54

Merged
HackPoint merged 6 commits into
mainfrom
agents/busy-retry-on-open
Oct 13, 2026
Merged

HackPoint merged 6 commits into
mainfrom
agents/busy-retry-on-open

Conversation

@HackPoint

@HackPoint HackPoint commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

What

A ledger still in rollback-journal mode (a first run, or one last written by an old build) is switched to WAL by whichever program opens it first. While another connection holds the write lock, SQLite refuses PRAGMA journal_mode=WAL with SQLITE_BUSY at once, without calling the busy handler, so busy_timeout does not help. That pragma is the first statement of the shared DDL, run by init_schema (daemon, stats) and open_db (hooks, MCP server, CLI, app). Two programs opening the same old ledger together could fail with "database is locked".

Seen on Test (windows-latest) for main dfe4947 (run 38235651076) and again for #55 (run 38254452918): concurrent_upgrade::v1_1_4 and v1_5_0, "Daemon failed beside the others: error returned from database: (code: 5) database is locked" (crates/lumen-daemon/tests/upgrade_matrix/main.rs:302). It did not fail in 150 macOS loops before this change, so only the Windows job on this PR can show the improvement.

A second defect: a migration statement that failed BUSY was swallowed (let _ =) and counted as applied, so a column or an index could stay missing for good.

Change

  • New lumen_core::busy: retry_busy (async) and retry_busy_blocking. Budget 2 s in all, pauses 5/10/20/25 ms. Matches SQLITE_BUSY and its extended codes (261, 517, 773). An attempt that itself used up the budget is not repeated. When the budget runs out the busy error is returned.
  • init_schema and open_db run the DDL through it. A schema migration that fails busy is now an error, so the next open runs it again. Other migration errors are still read as applied (duplicate column, DROP COLUMN idempotency), by design.
  • The one row rewrite among the migrations, UPDATE turns SET is_subagent = 1 ..., moves to a new BACKFILLS list that both openers run after the migrations. On a WAL ledger it is the only statement that takes the write lock, and every open runs it, so a writer that holds the ledger past the busy timeout (5 s) has always refused it, and the open has always gone on without it. That stays: a busy backfill is skipped and the next open redoes it. Making it an error would fail opens that succeed today.
  • schema::open_pool(url) is the one connect-and-init, used by the daemon and by lumen-stats (connect). It closes the pool when the init fails. sqlx-sqlite 0.9 does not switch the journal mode on connect, so only the DDL takes a lock a writer can refuse.
  • Daemon: the "using db" line is logged before the open, so a failed open leaves the path in the log.
  • lumen-core/Cargo.toml: tokio with time only, for the async sleep. It is already in the resolved tree: the packages and features are the same before and after for lumen-core, -cli, -daemon, -mcp and -stats (one new edge, lumen-core to tokio), and Cargo.lock is unchanged. Nothing new on the hook path.
  • silent_fallbacks.rs: two allow entries removed (the migration loops no longer have that shape), two added for the backfill loops, each with its reason: a row rewrite, idempotent, redone by the next open.
  • Cost on the hook path: on a rollback-journal ledger a writer holds, an open waits up to 2 s once. On a WAL ledger nothing changes.

Tests (the test commit comes before the fix)

  • busy.rs: unit tests for the retry, blocking and async: runs again until the call works, does not repeat an error that is not busy, gives the busy error back when the budget is spent (and tries at most 30 times in 120 ms, so a retry with no pause fails), does not repeat a call that used the budget by itself.
  • lumen-core/tests/busy_ledger.rs, 10 tests, with a rusqlite BEGIN IMMEDIATE writer holding the ledger. On a rollback-journal ledger, open_db and open_pool each wait and succeed once the writer lets go, and return the busy error when it never does. Through the pool opener: a column the ledger was busy to add is added, not skipped; a ledger that stays busy is an error, not an applied migration; a busy backfill is redone within the open. Both openers: a backfill that stays busy does not fail the open, and the next open does it. The rusqlite opener waits on its own 5 s busy timeout before the retry, so a missing retry there cannot be seen by a test.
  • lumen-daemon/tests/busy_start.rs: the real daemon binary, started while a writer holds the ledger, serves once the writer lets go. lumen-stats/tests/busy_connect.rs: connect waits for a writer. Both go red when a caller runs the DDL alone with no retry (checked before the backfill change).
  • The upgrade matrix (concurrent_upgrade) is unchanged and as strict as before.
mutation, each reverted caught by
open_db / init_schema swallow a busy schema migration the not-counted-as-applied test / the stays-busy-is-an-error test
a busy backfill fails the open, in either opener the two goes-on-without-a-backfill tests
no pause between retries, blocking and async the two budget-spent unit tests
init_schema: backfill not retried the backfill-run-again test
init_schema: migrations not retried the column-added-not-skipped test and the strict test
DDL not retried in open_db / init_schema the wait-for-a-writer and give-up tests of that opener
backfills never run in either opener the wait and backfill tests of that opener

Gate

Local gate green on 7f2a36e (432 s): fmt, clippy -D warnings, workspace tests with RUSTFLAGS=-D warnings, frontend build and tests with thresholds, e2e, Tauri crate tests, hook e2e, MSRV 1.94.0 build, install verify and the debug app build. Run with LUMEN_RANKED_TIME_BUDGET_MS=60000: the lumen-core ranked-outline budget test is wall-clock bound and flakes on a loaded machine; nothing here touches it.

CRLF clone (core.autocrlf=true): the lumen-core, lumen-daemon and lumen-stats tests, 524 passed, none failed, including busy_ledger, busy_start, busy_connect, the upgrade matrix and the silent-fallback guard.

HackPoint and others added 6 commits October 12, 2026 14:11
SQLite answers SQLITE_BUSY at once, without consulting the busy timeout, when a
connection that already holds a read lock asks for the write lock while another
connection holds it. Switching an old ledger to WAL does exactly that, so two
programs opening one in the same instant can fail an open that a short wait
would have let through (the Windows upgrade-matrix round that fails with
"database is locked"). A migration refused that way is also swallowed today and
reads as already applied.

Red state: busy.rs is a stub that never retries and open_pool is the old
connect-then-init sequence. The unit tests and the six ledger tests fail on it.
…ver read a busy migration as applied

SQLite answers SQLITE_BUSY at once, without the busy timeout, when a connection that already
holds a read lock asks for the write lock while another holds it. Switching a ledger from the
rollback journal to WAL does that, so two programs opening one ledger in the same instant could
hear "database is locked" from an open a short wait would have let through. That is the Windows
CI failure in the upgrade matrix's concurrent rounds.

Every opener now retries a busy open for up to two seconds: the shared pool open
(open_pool, used by the daemon and the app), init_schema, and the rusqlite open_db. A migration
refused as busy used to be swallowed and left undone; it is now an error, and the next open
runs it again. The silent-fallback allowances for those two swallows are gone.

Callers get their own tests: the app's connect and the real daemon binary, each started while a
writer holds the ledger.
…a writer can refuse

sqlx leaves the journal mode alone unless asked (its source says so: changing into WAL needs an
exclusive lock that the busy timeout cannot wait on), so a plain connect never meets a writer. The
switch is the DDL's own pragma, and init_schema retries that. A probe shows it: with a writer
inside a rollback-journal ledger the connect takes 2 ms and succeeds, and the DDL is the statement
that is refused at once.
…ation does, on both openers

The backfill UPDATE takes the write lock on every open, even on a ledger already in WAL, so a
writer that holds the ledger past the busy timeout made an open fail where it used to go on.
Two tests fail on this commit for that (pool and rusqlite). The other new ones pin what is
already right: a busy column migration is an error on the rusqlite opener as well as the pool
opener, and the retry pauses between tries (it was only checked to try at least three times).
…en that fails

The one row rewrite among the migrations takes the write lock on every open, so on a ledger a
writer held past the busy timeout the open now returned the busy error where it used to go on,
and a hook lost its row. It moves to BACKFILLS, which both openers run after the migrations and
read as before: not an error, and done by the next open. Schema migrations stay strict.

open_pool closes its pool when the schema step fails; the MIGRATIONS doc says what the openers
now do with a busy statement.
@HackPoint

Copy link
Copy Markdown
Owner Author

Evidence, for the record. It is not a proof.

  • Before this change concurrent_upgrade::v1_1_4 and v1_5_0 failed on Test (windows-latest) twice that I saw: main dfe4947 (run 38235651076) and test(daemon): vanished-transcript e2e tests hold up under load #55 (run 38254452918). The same test did not fail in 150 loops on macOS under load 45-59, so macOS cannot show the improvement.
  • With this change, Test (windows-latest) passed on 3 of 3 runs of earlier heads of this PR (run 38245071733 attempts 1 and 2, run 38254450956). The race is intermittent, so three passes show nothing by themselves. What pins the fix is the set of tests that hold a writer on a rollback-journal ledger: busy_ledger, busy_start, busy_connect. Each goes red without the retry.
  • Head 7f2a36e adds the backfill handling and its tests after the first review. A second, separate reviewer checks it.

@HackPoint
HackPoint merged commit a9088d5 into main Oct 13, 2026
17 checks passed
@HackPoint
HackPoint deleted the agents/busy-retry-on-open branch October 13, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant