Skip to content

fix(storage): recover pinned checkpoints that a crash interrupts - #1378

Draft
zahariash wants to merge 3 commits into
LadybugDB:mainfrom
zahariash:fix/pinned-checkpoint-crash-recovery
Draft

zahariash wants to merge 3 commits into
LadybugDB:mainfrom
zahariash:fix/pinned-checkpoint-crash-recovery

Conversation

@zahariash

@zahariash zahariash commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

This work was produced with the help of language models.

Part of #1304.

After a CHECKPOINT taken while another connection had a read-only transaction open, a crash could lose commits or block reopening. With a commit after it and a crash during the next checkpoint, reopening applied the earlier transactions twice and dropped every later one (Found duplicated primary key value 1, …, Catalog exception: P already exists in catalog., or 2 rows of 100 in #1304's failed-checkpoint repro). Without a commit in between, the next checkpoint's unfinished shadow failed the reopen with Cannot replay shadow file db.shadow: file is too small., as did a crash while cleanup removed the applied shadow; one just after it gave shadow file db.shadow is missing with no pinned-checkpoint marker.. A partitioned table failed with partition shadow file db.L_p0.shadow is missing. after the pinned checkpoint alone.

Fix. Such a checkpoint keeps its CHECKPOINT record in the active WAL, and recovery read any shadow beside it as a checkpoint in progress. A transaction committed after the record proves the checkpoint completed: the write gate is held until cleanup ends, and a failure after the record poisons the WAL. Recovery now skips through the last CHECKPOINT when a whole transaction follows it, in the active and the frozen WAL, and removes the leftover shadow; a lone COMMIT there, which the engine never writes, keeps the prefix replay, and records that end in no COMMIT are a torn tail of the CHECKPOINT. A WAL with nothing committed after its last CHECKPOINT is retired instead of kept.

The db.pinned.tmp marker now means that every CHECKPOINT record in the active WAL is applied: WAL::logAndFlushCheckpoint removes it before logging one, and pinned cleanup writes it before removing any shadow. When the WAL ends in a CHECKPOINT, recovery with the marker discards leftover main and partition shadows, and without it replays a remaining shadow as before. docs/checkpoint_recovery.md gets a section on these rules.

Read-only opens. Recovering a WAL that ends in a CHECKPOINT writes, so a read-only open refuses it with Cannot open database in read-only mode while checkpoint recovery is pending, as on main. Counting a torn commit after a pinned CHECKPOINT as part of that ending extends the refusal to a crash in the middle of a commit, where main replayed the records before the first CHECKPOINT a second time; opening read-write once recovers such a database. The refusal could be relaxed: when the marker says the checkpoint is applied, or no shadow is left, the data file already holds it and no transaction follows, so a read-only open could load the checkpoint and leave the WAL, shadow and marker files for the next read-write open. That takes a read-only branch in replayPinnedActiveCheckpoint, which now truncates the WAL and removes those files, about ten lines and a test, and would also open a clean trailing CHECKPOINT read-only in those cases, which main refuses. It is left out to keep the change to recovery small; it can follow if read-only opens of crashed databases matter.

Compatibility. The marker now holds 2. An older build's 1 only proves that a missing shadow was applied, since older builds kept it while logging the next record; older builds only check that the marker exists.

Not fixed here. A database with a named graph (CREATE GRAPH) still fails to reopen after such a crash (graph shadow file db.g.shadow is missing.), since recovery still requires the graph's shadow that cleanup removed. It needs the partition rule plus handling for a graph WAL left by a standalone session, and follows in #1379.

Tests. PinnedCheckpointCrashTest (test/transaction/checkpoint_test.cpp) has 13 crash-and-reopen tests; 8 fail on main with the errors above. They leave a later checkpoint's shadow after commits (also in a frozen WAL, restored from a mid-checkpoint copy of the files), a cut-short or empty shadow, a partitioned table, and a lone BEGIN after the CHECKPOINT. A new beforeShadowRemoval hook checks that the marker is durable first. Two tests crash between logging a record and applying its shadow, under an earlier and a legacy marker, and fail when the marker removal or the content check is taken out; the rest keep what main does with legacy and missing markers. Three more kill the next checkpoint after it removed the marker and before it logged its record: with nothing committed in between, with later commits, and with a partitioned table whose shadows that checkpoint never wrote; each reopens with every committed row. Full ctest: 3197/3197; storage-compat suite, every release: same results as main.

A checkpoint taken while a read-only transaction is open keeps its CHECKPOINT
record in the active WAL. Recovery treated a shadow file left next to such a
WAL as a checkpoint in progress: it replayed only the records before the first
CHECKPOINT (applying them twice and dropping every later commit), replayed the
next checkpoint's unfinished shadow, or required partition shadows that
cleanup had already removed.

- A whole transaction after the last CHECKPOINT record proves that checkpoint
  completed. Recovery then skips through the record and removes the leftover
  shadow, in the active and the frozen WAL. Other records after a CHECKPOINT
  keep the prefix replay.
- The pinned-checkpoint marker now means that every CHECKPOINT record in the
  active WAL is applied: WAL::logAndFlushCheckpoint removes it before logging
  a record, and pinned cleanup writes it (content 2) before removing shadows.
  With it, a WAL ending in a CHECKPOINT discards leftover main and partition
  shadows instead of replaying or requiring them.
- A WAL with nothing committed after its last CHECKPOINT is recovered as one
  ending in it, so it is retired instead of kept.
- A marker from an older build (content 1) only proves that a missing shadow
  was applied.

Named graphs are not covered: pinned checkpoints keep their own WALs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zahariash and others added 2 commits October 25, 2026 12:53
…read-only refusal

Only a COMMIT without its BEGIN keeps the prefix replay; records that end in no COMMIT are a
torn tail of the last CHECKPOINT. A read-only open refuses a WAL that ends in a CHECKPOINT
record, now also one followed by a torn commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he CHECKPOINT

A kill after WAL::logAndFlushCheckpoint removes the pinned-checkpoint marker and before it
writes the CHECKPOINT record leaves the WAL ending in the earlier, applied CHECKPOINT, no
marker, and the next checkpoint's complete shadow. Three tests cover it: nothing committed in
between, commits in between, and a partitioned table whose shadows the dying checkpoint never
wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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