fix(db): stop SQLITE_BUSY 500s — WAL journal mode, drop redundant ingest writes, transcript retention - #49
Merged
Conversation
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.
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.
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:2,150 lock errors in the log; 1,218 on Jul 25 alone, up from 14 on Jul 22.
Root cause
journal_mode = DELETEgives SQLite one writer whose exclusive lock blocks every reader. Three background services write continuously (transcript ingest,CliActivityMonitorat 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.UPDATE cli_native_files SET discovered_workspace_id …SELECT id as "id!: …SELECT id, workspace_id, session_id, …SELECT f.id, f.claude_session_id, f.dir_path, …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=NORMALpairs with WAL to drop an fsync per commit.Upstream pinned
deletein BloopAI#1882 after reverting BloopAI#1806, recording no reason — the PR body is justThis reverts commit 25c6d0a7…, with no discussion and no matching issue. The plausible reason is portability: WAL needs an mmap-able-shmsidecar and fails on some network mounts. Rather than assume either way, this defaults to WAL, falls back todeleteautomatically if WAL cannot be established, and addsVIBE_KANBAN_SQLITE_JOURNAL_MODEto 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 restampupdated_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 rowfind_latest_by_pathalready 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_fileshas no foreign key tosessions, so deleting a session stranded its transcript forever — and the cascades that would have collected the rest never fire, becausePRAGMA foreign_keysdefaults to off on every connection. That is also why the sweep deletes children explicitly rather than relying onON DELETE CASCADE.A file is prunable only once it is past the window and unreachable: no
claude_session_linksrow (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_DAYStunes it,0disables.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 inContainer,WorkspaceorScratchErrordepending on route.Two details the tests pin down, both of which silently defeated an earlier attempt:
StdError for Box<dyn DatabaseError>, so the chain element is that box, not the concreteSqliteError— downcasting toSqliteErrornever matches.SQLITE_BUSY_SNAPSHOT(517), not 5.Deliberately not done
PRAGMA foreign_keysis 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-targetsclean.New coverage:
journal_modeis a property of the file, so requesting WAL ≠ getting it), plus thedeletefallback.register()does not write when stats are unchanged, does when they change, and records first workspace attribution exactly once.prune_unreachableremoves 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.SQLITE_BUSYprovoked from a heldBEGIN EXCLUSIVE—SqliteErrorcannot 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.