Skip to content

Fix corruption from rowid reuse when database transactions aren't committed#10

Open
ksuther wants to merge 8 commits into
developfrom
fix/stale-keycache-corruption
Open

Fix corruption from rowid reuse when database transactions aren't committed#10
ksuther wants to merge 8 commits into
developfrom
fix/stale-keycache-corruption

Conversation

@ksuther

@ksuther ksuther commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

I tried throwing Opus 4.8 and Fable 5 at our ever-persistent database corruption bug.

The first two commits are the primary fixes where we weren't handling rollbacks or failed commits properly and the key cache wasn't being invalidated.

The short version is the keyCache wouldn't get invalidated, so the mapping of collection/key to rowid wouldn't get reset. This could then lead to the wrong object getting saved at the wrong rowid because rowids get reused if the transaction isn't committed.

The other commits are other fixes that Fable found while analyzing the rollback bug.

Tests

  • The rollback failure has a unit test (I don't think we were failing because of rollback).
  • Fable simulated the commit failure by creating a small disk image, putting the database on the disk image, and filling up the disk image to trigger failing writes.

Giant Claude-generated description

Persistent on-disk corruption from a stale keyCache after an uncommitted write

Summary

Under specific conditions, YapDatabase can persist an object into the wrong row on disk — an object of one collection's type ends up stored in a row belonging to a different collection. Because the corruption is on disk,
an application that reads that row on every launch (for example, when a registered extension re-scans a collection at startup) crashes on every launch, producing a permanent boot loop.

Background

  • Each YapDatabaseConnection keeps a keyCache: a bidirectional map between a row's integer rowid and its collection/key pair. Since the optimization that made keyCache authoritative, getRowid:forCollectionKey: (the write
    path) trusts the cache's reverse map and collectionKeyForRowid: (the read path) trusts the forward map, rather than resolving through the sqlite unique index.
  • The primary table is declared rowid INTEGER PRIMARY KEY (no AUTOINCREMENT), so sqlite reuses freed rowids.
  • On insert, the new rowid is written into keyCache eagerly, before the transaction's commit or rollback is decided.

The bug: two paths leave a phantom keyCache entry pointing at a rowid whose row was never actually committed. Combined with rowid reuse, a later operation then resolves that phantom to the wrong row.

Root cause — how a phantom entry is stranded

Path A — rollback after an insert. A read-write transaction inserts one or more rows (populating keyCache), then rolls back. On rollback the connection flushed objectCache and metadataCache but not keyCache, so the
eagerly-inserted rowid ⇄ collection/key entries survive for rows that no longer exist.

Path B — a failed COMMIT (no explicit rollback). A normal transaction inserts rows, then the COMMIT fails — e.g. SQLITE_FULL (disk full) or SQLITE_IOERR while writing the WAL at commit time. Per sqlite's documented
behavior, such an error can roll the entire transaction back automatically. YapDatabase logged the error but proceeded down the commit-success path: it kept the phantom keyCache entries, advanced the snapshot, and
broadcast the changeset to peer connections. No rollback flag was set, so the rollback cleanup never ran.

In both cases the phantom persists, and because inserts are broadcast to peer connections only by collection/key (never by rowid), peers never purge it either.

Reproduction sequence — durable corruption and a crash-on-every-launch loop

Consider an application that stores related data under the same key K in two collections — say collection A holds a model object, and a sibling collection B holds a plain dictionary for the same K (a common pattern for
storing side metadata next to a primary object).

  1. Insert + eager cache. A transaction does setObject:aDictionary forKey:K inCollection:B. The insert obtains rowid = 500 from sqlite3_last_insert_rowid and immediately records 500 ⇄ (B, K) in keyCache.
  2. The write is undone but the cache isn't. The transaction rolls back (Path A) or the COMMIT fails and sqlite auto-rolls-back (Path B). Nothing lands on disk — rowid 500 is free again — but keyCache still holds the
    phantom (B, K) → 500 (reverse) and 500 → (B, K) (forward).
  3. Rowid reuse. Later, ordinary activity inserts a genuine object into collection A, and sqlite hands out the freed rowid 500. On disk, rowid 500 is now a valid A object.
  4. The poisoned write hits disk. The app writes K's sibling data again: setObject:someDictionary forKey:K inCollection:B.
    - getRowid:forCollectionKey:(B, K) trusts the stale reverse map → returns rowid 500, with found = YES.
    - So it takes the UPDATE path: UPDATE ... SET data = WHERE rowid = 500.
    - The write-side type check passes, because it validates against the caller's collection (B, where a dictionary is a valid object) — it has no way to know rowid 500 actually belongs to collection A.

Result: the collection-A row at rowid 500 now contains an archived dictionary in its object column, on disk. The corruption is durable; it gets checkpointed out of the WAL into the main database file.
5. Every launch crashes. On the next launch, anything that reads collection A from a cold cache materializes rowid 500 through the object deserializer. If the deserializer (or the caller) expects the A type, it now
receives a dictionary and either asserts on the type mismatch or later sends an A-specific selector to the dictionary — e.g. unrecognized selector sent to instance on an __NSDictionaryI. If the reading code runs at startup
(for instance, an extension that re-scans the collection when it registers), the crash happens before the app is usable, on every relaunch — a permanent boot loop until the row is repaired or the database is rebuilt.

Note there is also a purely in-memory variant of the same defect: within one running process, the phantom forward entry makes a secondary-index enumeration resolve a reused rowid to the wrong collection/key, and
objectForCollectionKey: returns the wrong cached object straight from objectCache (no disk read, no deserializer, no assert). That variant is transient and disappears when the process restarts; the on-disk variant above is
the one that survives relaunch.

Fix

Purge keyCache on both paths that can strand a phantom entry:

  • On rollback, flush keyCache alongside objectCache/metadataCache (and extension caches) in the connection's rollback cleanup.
  • On a failed COMMIT, detect the failure (check sqlite3_get_autocommit() and the COMMIT return code), treat it exactly like a rollback — flush all caches, roll back extension state and memory tables, undo the snapshot
    increment, and retract the pending changeset instead of broadcasting it, so peer connections never observe the failed transaction.

With either fix in place, getRowid:forCollectionKey: and collectionKeyForRowid: never resolve a reused rowid to a stale collection/key, so nothing is written to the wrong row and there is no launch-time crash loop.

Two defense-in-depth measures also help contain an already-corrupt file: ensuring sibling rows can't be orphaned and have their rowids reused, and removing stale -wal/-shm sidecar files during corruption recovery so a
rebuilt database can't replay the old frames.

ksuther and others added 8 commits July 6, 2026 11:31
setObject: populates keyCache (rowid <-> collectionKey) eagerly via
insertForRowidStatement, before commit/rollback is decided. On rollback the
connection flushed objectCache and metadataCache but NOT keyCache, so a
rolled-back INSERT left a phantom rowid <-> collectionKey entry for a row that
no longer exists on disk.

Because the database2 table is `rowid INTEGER PRIMARY KEY` (no AUTOINCREMENT),
sqlite reuses that freed rowid on the next insert. getRowid:forCollectionKey:
(writes) and collectionKeyForRowid: (reads) trust keyCache, so the phantom then
resolves the WRONG row: a write meant for one collection is persisted into a
different collection's row, and reads return the wrong object. In Fantastical
this surfaced as an __NSDictionaryI (a calendar item's internalMetadata sibling,
same key) coming out of the database where an FBCalendarItem was expected.

Flush keyCache (and extension caches, e.g. Relationship's edgeCache which is
populated eagerly and whose edge rowids are likewise reused) in the rollback
branch via _flushMemoryWithFlags:. Adds a deterministic regression test
(testRollbackFlushesKeyCache_rowidReuseCorruption) reproducing the
cross-collection clobber with two connections and a reused rowid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commitTransaction executed COMMIT, logged any error, and proceeded as if the
transaction had committed. But sqlite's documented "response to errors within a
transaction" says a COMMIT failing with SQLITE_FULL / SQLITE_IOERR /
SQLITE_NOMEM (e.g. disk full while appending to the WAL — where the commit
actually writes the transaction's pages) may roll the entire transaction back
automatically. YapDatabase never checked, so the in-memory state (keyCache in
particular) kept describing rows that don't exist on disk, and the pending
changeset was broadcast to peers. On the next write, rowid reuse detonates the
phantom keyCache entry and persists the wrong object into an unrelated row — the
same corruption as a rollback-after-insert, but with no rollback ever called.

Fixes:
- commitTransaction now returns BOOL. On COMMIT failure it checks
  sqlite3_get_autocommit(): if the txn is somehow still open (e.g. BUSY) it
  rolls back explicitly so the outcome is deterministic (a zombie transaction
  would swallow every later BEGIN/COMMIT). It routes extensions to
  didRollbackTransaction (didCommit would, e.g., have Relationship delete files
  from disk for a rolled-back txn) and rolls back the memory tables (they commit
  in preCommit, before the sqlite COMMIT, so nothing else reverts them).
- postReadWriteTransaction checks the return value. On failure it flushes all
  caches + extension state, undoes the snapshot increment, and retracts the
  pending changeset instead of broadcasting it (skipping checkpoint and the
  YapDatabaseModifiedNotification). Leaving the in-memory snapshot ahead of disk
  would let another process claim the same snapshot number for a real commit
  that we'd then never detect.
- Adds YapDatabase retractPendingChangeset:fromConnection:. Safe because a
  pending changeset cannot have been consumed by a peer yet: peers only fetch
  when they observe a LARGER snapshot, and the failed snapshot never becomes
  visible on disk or in memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deleteEdgesWithSourceOrDestination: (run for EVERY deleted node) recorded
deleted edges in the changeset so PEER connections purge their edgeCache, but
never purged the WRITER's own edgeCache. Edge rowids are reused by sqlite just
like main-table rowids, so a stale edgeCache entry later resolves a different
(reused) edge rowid to the OLD edge's nodeDeleteRules. With opposing delete
rules (DeleteDestinationIfSourceDeleted together with
DeleteSourceIfDestinationDeleted), a rule swap flips the cascade direction and
can silently DELETE LIVE ROWS from the main database — no error required, this
happens in normal operation.

Fix: purge the just-deleted edge rowids from parentConnection->edgeCache per
delete; removeAllProtocolEdges (registration/repopulation) drops the whole
edgeCache since it deletes rows without recording them in deletedEdges; and
postRollbackCleanup clears edgeCache too (flush populated it eagerly before the
rollback was decided).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
removeObjectsForKeys: (batch B), removeAllObjectsInCollection: (normal path),
and removeAllObjectsInAllCollections all updated caches and the changeset and
told every extension the rows were gone even when the DELETE statement failed —
the same log-and-pray pattern as the commit-failure bug. This durably desyncs
extension tables (views/indexes) and peer connections from the main table, and
Relationship would then cascade-delete OTHER live rows for a delete that never
happened.

All three now bail out without any bookkeeping when the DELETE fails, matching
the single-row remove path. removeAllObjectsInCollection: additionally
re-purges keyCache/objectCache/metadataCache per batch: an extension hook
reading a row from a later batch can repopulate keyCache mid-wipe, leaving a
stale rowid <-> collectionKey entry that survives the commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sqlite_enum_reset (which finalizes a busy/nested statement) ran INSIDE the
per-collection loop, so a second collection would step a freed statement. The
three sibling enumeration methods reset+clear the statement per iteration and
only finalize once after the loop. Match them: clear/reset inside the loop and
call sqlite_enum_reset once after it.

Also align the throw path across all four multi-collection enumerators. On the
mutation-during-enumeration exception, the loop body has already reset the
statement, but the three siblings @throw without finalizing — leaking the
freshly-prepared private statement (needsFinalize == YES) that a nested
enumeration allocates, if the exception is ever caught. Call sqlite_enum_reset
before @throw in _enumerateKeysAndObjectsInCollections:,
_enumerateKeysAndMetadataInCollections:, and _enumerateRowsInCollections: too,
so the statement handling is identical in all four. Minor (the exception is a
fatal programmer error), purely for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readSnapshotFromDatabase returned 0 on any error and only logged the exact
SQLITE_ERROR code. In multiprocess mode this SELECT is the authoritative
external-modification check, so a silent 0 made preRead/preReadWrite conclude
"nothing changed" and trust stale caches after a transient SQLITE_IOERR /
SQLITE_BUSY / SQLITE_CORRUPT.

readSnapshotFromDatabase: now reports failure via an out param (any status other
than SQLITE_ROW/SQLITE_DONE), and both preReadTransaction and
preReadWriteTransaction treat "cannot verify the snapshot" as an undetected
external modification and force a full cache flush. Also adds a guard for the
one error-free corruption path found: if the on-disk snapshot goes BACKWARDS
relative to memory (file restored from a backup or replaced by another tool),
adopt the disk value and flush everything rather than trusting caches that
describe a different file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a database file is detected as corrupt and renamed away (or deleted), its
-wal and -shm sidecar files were left behind. sqlite then treats the stale -wal
as a hot journal for the NEW (empty) database file and REPLAYS THE OLD
DATABASE'S FRAMES into it, resurrecting the very content just declared corrupt.

Both the Rename and Delete corruptAction branches now move/remove the -wal and
-shm files alongside the main file (the Rename branch falls back to deleting a
sidecar if the move fails — anything is better than the new file adopting the
old WAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mixed add/remove branch of processChangeset ignored getDefaultObjectPolicy /
getDefaultMetadataPolicy and fell back to Containment, so under a Share policy a
cache update from a peer changeset silently became an eviction (stale-but-safe,
but a needless cache miss and inconsistent with the pure-update branch). Fall
back to the database's default policy, matching the pure-update branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ksuther ksuther changed the title Fix/stale keycache corruption Fix corruption from rowid reuse when database transactions aren't committed Jul 6, 2026
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