Skip to content

fix(db): stop SQLITE_BUSY 500s — WAL journal mode, drop redundant ingest writes, transcript retention - #49

Merged
miguelrisero merged 3 commits into
mainfrom
mr/bfd8-sqlite-write-lock-fix
Jul 26, 2026
Merged

fix(db): stop SQLITE_BUSY 500s — WAL journal mode, drop redundant ingest writes, transcript retention#49
miguelrisero merged 3 commits into
mainfrom
mr/bfd8-sqlite-write-lock-fix

Conversation

@miguelrisero

Copy link
Copy Markdown
Owner

What was actually wrong

The "internal error" on workspace creation was never a workspace-creation bug. It was a 500 from SQLITE_BUSY.

From vk.log:

POST /api/workspaces/start → 500
  error=Container(Sqlx(SqliteError { code: 5, message: "database is locked" }))
POST /api/workspaces/start → 500
  error=Workspace(Database(SqliteError { code: 5, message: "database is locked" }))

2,150 lock errors in the log; 1,218 on Jul 25 alone, up from 14 on Jul 22.

Root cause

journal_mode = DELETE gives SQLite one writer whose exclusive lock blocks every reader. Three background services write continuously (transcript ingest, CliActivityMonitor at 2s, registry reconcile + outbox poll at 30s, pr_monitor) against a database that had grown to 215 MB. Requests spent their entire busy timeout queued behind that lock and then failed.

The decisive evidence is that most slow statements are reads: of 14,071 slow statements, ~9,000 are SELECTs. Readers were not slow — they were blocked.

slow statement count
UPDATE cli_native_files SET discovered_workspace_id … 3,643
SELECT id as "id!: … 3,351
SELECT id, workspace_id, session_id, … 2,997
SELECT f.id, f.claude_session_id, f.dir_path, … 1,402

Changes

1. WAL journal mode + 30s busy timeout (crates/db/src/lib.rs)

Readers stop blocking the writer, which addresses ~2/3 of the contention directly. Busy timeout goes from sqlx's 5s default to 30s so contention delays a request rather than failing it. synchronous=NORMAL pairs with WAL to drop an fsync per commit.

Upstream pinned delete in BloopAI#1882 after reverting BloopAI#1806, recording no reason — the PR body is just This reverts commit 25c6d0a7…, with no discussion and no matching issue. The plausible reason is portability: WAL needs an mmap-able -shm sidecar and fails on some network mounts. Rather than assume either way, this defaults to WAL, falls back to delete automatically if WAL cannot be established, and adds VIBE_KANBAN_SQLITE_JOURNAL_MODE to force either mode. All three connection sites now route through one helper.

2. Stop rewriting unchanged registrations (crates/db/src/models/cli_native_file.rs)

register() wrote unconditionally on every scan of every file — taking the single write lock just to restamp updated_at, which nothing reads and which a real import restamps anyway — then re-read the row it had just written. The top slow statement in the log. Now compares against the row find_latest_by_path already loaded and skips both round trips when nothing changed.

3. Transcript retention (prune_unreachable + a sweep in the ingest service)

Nothing ever reclaimed raw transcript records: 196 MB of a 215 MB database, accumulated in six days. cli_native_files has no foreign key to sessions, so deleting a session stranded its transcript forever — and the cascades that would have collected the rest never fire, because PRAGMA foreign_keys defaults to off on every connection. That is also why the sweep deletes children explicitly rather than relying on ON DELETE CASCADE.

A file is prunable only once it is past the window and unreachable: no claude_session_links row (so nothing can render it, and the unassigned-CLI adoption list has had the full window to claim it) and no outbox row belonging to a still-existing session (so no live feed can replay it). Sweeps are capped per run with one transaction per file, since holding the write lock is the problem retention exists to relieve.

Defaults to 14 days; VIBE_KANBAN_CLI_TRANSCRIPT_RETENTION_DAYS tunes it, 0 disables.

4. Surface contention instead of hiding it (crates/server/src/error.rs)

Every DB error mapped to ErrorInfo::internal, so losing a write race answered 500 / "An internal error occurred." Contention is retryable and the server is healthy, so it now answers 503 with a message that says so. Matching walks the error source chain rather than adding a case per variant, since the same sqlx error arrives wrapped in Container, Workspace or ScratchError depending on route.

Two details the tests pin down, both of which silently defeated an earlier attempt:

  • sqlx implements StdError for Box<dyn DatabaseError>, so the chain element is that box, not the concrete SqliteError — downcasting to SqliteError never matches.
  • sqlx reports SQLite's extended result code, so the primary code must be masked from the low byte. WAL raises SQLITE_BUSY_SNAPSHOT (517), not 5.

Deliberately not done

  • No data deleted. Retention reclaims nothing today: every transcript file is ≤7 days old, so no sane window matches anything yet. It exists to stop unbounded growth going forward.
  • The 47 orphan transcript files (86 MB) are left in place. They back the unassigned-CLI adoption list, so deleting them would discard sessions that can still be claimed. Retention will collect them once they age past the window.
  • PRAGMA foreign_keys is left off. Turning it on globally could start failing writes that currently succeed against possibly-already-violating data — too broad to bundle into a contention fix. Worth its own change.

Testing

612 workspace tests pass; cargo clippy --workspace --all-targets clean.

New coverage:

  • WAL and the 30s timeout are the pragmas SQLite actually settles on (journal_mode is a property of the file, so requesting WAL ≠ getting it), plus the delete fallback.
  • An open read transaction does not block a writer under WAL — the behaviour the change exists for.
  • register() does not write when stats are unchanged, does when they change, and records first workspace attribution exactly once.
  • prune_unreachable removes unreachable transcripts past the window, and spares those inside it, those a session still owns, and those a live session can replay; sweeps respect the file limit.
  • Contention is detected through each wrapper a real route uses, using a genuine SQLITE_BUSY provoked from a held BEGIN EXCLUSIVESqliteError cannot be constructed outside sqlx, and a hand-built chain would not have caught either of the two bugs above. A real primary-key violation confirms the masking does not over-match.

Rollback-journal mode gives SQLite one writer whose exclusive lock blocks
every reader. With the transcript-ingest, activity-monitor and PR-monitor
services all writing continuously against a 215MB database, ordinary
requests spent their whole busy timeout queued behind that lock and then
failed: roughly two thirds of observed slow statements were plain SELECTs,
and workspace creation lost the race often enough to 500 repeatedly.

Default to WAL so readers no longer block the writer, raise the busy
timeout from sqlx's 5s to 30s so contention delays a request instead of
failing it, and pair WAL with synchronous=NORMAL to drop an fsync per
commit.

Upstream pinned `delete` in BloopAI#1882 after reverting BloopAI#1806 without recording
a reason. The plausible one is portability: WAL needs an mmap-able `-shm`
sidecar and so fails on some network mounts. Rather than assume, connect
falls back to `delete` automatically when WAL cannot be established, and
VIBE_KANBAN_SQLITE_JOURNAL_MODE forces either mode outright.

Route all three connection sites through one helper so the settings
cannot drift, and cover the pragmas that SQLite actually settles on --
journal_mode is a property of the file, so requesting WAL is not the same
as getting it.
…error

Every database failure mapped to ErrorInfo::internal, so a request that
merely lost the write race answered 500 with "An internal error occurred.
Please try again." -- the real cause never reached the browser and a
week of lock contention read as a workspace-creation bug.

Contention is retryable and the server is healthy, so answer 503 with a
message that says so. Match on the error source chain rather than adding
cases per variant: the same sqlx error arrives wrapped in Container,
Workspace or ScratchError depending on the route.

Two details the tests pin down, both of which silently defeated an
earlier attempt at this. sqlx implements StdError for
Box<dyn DatabaseError>, so the chain element is that box and not the
driver's concrete SqliteError. And the code sqlx reports is SQLite's
extended result code, so the primary code has to be masked out of the low
byte -- WAL raises SQLITE_BUSY_SNAPSHOT (517), not 5.
…d transcripts

Two sources of avoidable write-lock pressure from transcript ingest.

CliNativeFile::register wrote unconditionally on every scan of every
tracked file, so a poll that observed no change still took the single
write lock just to restamp updated_at -- which nothing reads, and which a
real import restamps anyway. It then re-read the row it had just written.
This was the single hottest slow statement observed (3,643 of 14,071).
Compare against the row find_latest_by_path already loaded and skip both
round trips when nothing changed.

Nothing ever reclaimed raw transcript records, which had grown to 196MB
of a 215MB database in six days, and a larger database makes every commit
and so every lock hold slower. cli_native_files has no foreign key to
sessions, so deleting a session stranded its transcript permanently; the
cascades that would have collected the rest never fire because
PRAGMA foreign_keys defaults to off on every connection, which is also
why the sweep deletes children explicitly.

A file is only prunable once it is past the retention window and
unreachable: no claude_session_links row, so no session can render it and
the unassigned-CLI adoption list has had the full window to claim it, and
no outbox row belonging to a session that still exists, so no live feed
can be replaying it. Sweeps are capped per run and each file is its own
transaction, because holding the write lock is the problem retention
exists to relieve.

Retention defaults to 14 days; VIBE_KANBAN_CLI_TRANSCRIPT_RETENTION_DAYS
tunes it and 0 disables it.
@miguelrisero
miguelrisero merged commit ca12435 into main Jul 26, 2026
7 checks passed
@miguelrisero
miguelrisero deleted the mr/bfd8-sqlite-write-lock-fix branch July 26, 2026 18:13
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