Skip to content

fix: keep the cost-usage database on transient SQLite failures, only rebuild on corruption (refs #2760) - #2765

Merged
steipete merged 1 commit into
mainfrom
review/store-internals
Aug 8, 2026
Merged

fix: keep the cost-usage database on transient SQLite failures, only rebuild on corruption (refs #2760)#2765
steipete merged 1 commit into
mainfrom
review/store-internals

Conversation

@steipete

@steipete steipete commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Adversarial review of the SQLite cost-store internals (#2761/#2762, refs #2760) found one real data-loss defect and an observability gap; this PR fixes them and adds a failure-injection test suite.

Defect: any SQLite error destroyed the whole database

CostUsageStore.withDatabase(default:) treated every thrown error as corruption: it deleted the database (plus -wal/-shm), recreated it, retried, and on a second failure deleted it again. Three reachable non-corruption triggers:

  • Constraint violationappendTokenSnapshots / appendUsageRows / replaceBufferedLines resolve file_id via (SELECT id FROM files WHERE path = ?); for an unregistered path that yields NULL into a NOT NULL column. The error nuked the DB, the retry against the fresh empty DB hit the same constraint, and the DB was nuked a second time — all history gone from one bad batch.
  • Lock contentionCostUsageStoreAccess.read (used by the app, the CLI, and the project indexer) opens a writable connection. Cross-process contention past the 5s busy timeout returned SQLITE_BUSY and deleted the database out from under the other process.
  • Disk fullSQLITE_FULL during a write deleted the user's entire usage history, exactly when the old JSON path would have kept the previous artifact.

Fix: classify the primary result code. Transient/environment/data-shape codes (BUSY, LOCKED, NOMEM, READONLY, INTERRUPT, IOERR, FULL, PERM, TOOBIG, CONSTRAINT, MISUSE, AUTH, RANGE) return the fallback and keep the file; corruption/schema-drift codes (CORRUPT, NOTADB, ERROR, CANTOPEN, plus invalidData / incompatibleSchema) keep the existing rebuild behavior, so the "dropped table degrades to fresh store" contract is unchanged. A preserved failure whose transaction cannot be rolled back drops the connection (file intact) instead of leaving it wedged.

Observability

rebuildCount existed but was never read outside tests — field rebuilds were invisible. Rebuilds now log a warning with the reason and running count through the existing token-cost log category, and preserved (non-rebuild) failures log too.

Failure injection results

Scenario Result
Constraint orphan (unknown path) previously: double rebuild, total loss; now: returns false, history intact, rebuildCount stays 0
Failed transaction then new write connection recovers, subsequent writes succeed
Mid-file structural corruption (page headers XORed on every page after the first) quick_check/open detects, rebuild count 1, store usable
-wal deleted after checkpoint no loss, no rebuild
Stale/garbage -shm recovered, all rows readable
Zero-byte DB file rebuild once, usable
DB path occupied by a directory recovered via rebuild
Future user_version on disk (downgrade) rebuild instead of misreading
Disk full / BUSY not directly injectable through the actor's private connection; covered by the classification unit tests (FULL/BUSY preserve the file)

Known limitation (unchanged, documented in the test): value-only bit flips inside b-tree cells are undetectable without a checksumming VFS — quick_check verifies structure, not content. Budget behavior was re-audited: in-window trims under byte pressure mark catchUpPending and preserve previousReportPayload (the #2646 mitigation holds), and incremental vacuum reclaim is asserted by measurement in the existing suite.

Proof: swift test --filter CostUsageStore (58 tests, 3 suites, all green) and make check clean; Codex autoreview clean.

…rebuild on corruption (refs #2760)

withDatabase treated every error as corruption and deleted the entire
database. That destroyed user history on lock contention (app and CLI
both open writable connections), disk-full, and even a plain NOT NULL
constraint violation from appending rows for an unregistered path --
which then failed again on the fresh database and nuked it a second time.

Classify SQLite primary result codes: transient/environment/data-shape
errors (BUSY, LOCKED, FULL, NOMEM, IOERR, CONSTRAINT, READONLY, ...) now
return the fallback and keep the file; corruption and schema drift
(CORRUPT, NOTADB, ERROR, invalid data, incompatible schema) still
rebuild. Rebuilds and preserved failures are now logged via the
token-cost category so field rebuilds are observable, and a failed
transaction that could not roll back drops the connection instead of
leaving it wedged.

Adds a failure-injection suite: constraint orphans, connection reuse
after a rolled-back transaction, mid-file structural corruption, deleted
-wal / stale -shm sidecars, zero-byte databases, the database path
occupied by a directory, and a future schema version on disk.
@clawsweeper

clawsweeper Bot commented Aug 8, 2026

Copy link
Copy Markdown

ClawSweeper status: review started.

I am starting a fresh review of this pull request: fix: keep the cost-usage database on transient SQLite failures, only rebuild on corruption (refs #2760) This is item 1/1 in the current shard. Shard 0/1.

This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking.

Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 338db90cd5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return opened
} catch {
self.rebuildDatabase()
self.rebuildDatabase(reason: "open failed: \(error)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the database on transient open failures

When a fresh CostUsageStore is opened while another app/CLI process holds the SQLite file locked, openDatabase() can throw SQLITE_BUSY from its setup/validation pragmas before any operation runs. This catch bypasses shouldRebuild(after:) and immediately calls rebuildDatabase, which removes the main DB and sidecars before the outer withDatabase classifier ever sees the transient error; CostUsageStoreAccess.read creates exactly such new store instances, so startup/read contention can still erase cost history.

Useful? React with 👍 / 👎.

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