fix(persistence): checkpoint publishes a redo point only over durable heap pages (#452) - #1075
Merged
Merged
Conversation
… heap pages The disk-offload fuzzy checkpoint pwrote dirty heap pages, never fsynced a heap file, and then published redo_lsn in the control file and recycled every WAL segment below it, FullPageImages included. A power loss after that recycle rolls the page back with nothing left in the WAL to redo it. Three changes close the protocol: - Finalize fsyncs every heap file the flush wrote before it appends the WAL checkpoint record. The fsync runs on a helper thread, and the shard thread waits for it with the same bounded wait (WAIT_DURABLE_TIMEOUT) it already uses for WAL durability. If a file cannot be opened, or the wait times out, Finalize retries later with backoff. If an fsync returns an error, the shard never publishes a redo point again (fsyncgate: a retried fsync can report success for pages the kernel dropped). The WAL above the last good redo point is kept. - A page's FullPageImage is appended to the WAL and made durable before the page is overwritten in place. The flush used to collect the images and append them only after the whole batch had been pwritten, into the WAL writer's memory buffer, so a crash during the pwrite left a torn page with no image of it. - A page that fails to flush makes the checkpoint flush again, keeping its redo point. Before, the page was counted as flushed and Finalize published over a change that was on disk in neither the heap nor the WAL. No production path marks a heap page dirty today (PageCache::mark_dirty has no callers outside tests), so this closes the hazard before the first in-place cold-page write can reach it. Red before the fix: the FPI, heap-fsync and failed-flush tests in shard::persistence_tick all failed on origin/main. Fixes #452 author: Tin Dang
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
|
Warning Review limit reachedNext included review available in 54 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (10)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Brings the branch up to date with main 6b964fa. Main's changes do not touch src/persistence/checkpoint.rs or src/shard/persistence_tick.rs. The only conflict was CHANGELOG.md, where both sides added entries to Fixed. Both are kept. The merged tree passes: - cargo fmt --check; - clippy with -D warnings on monoio (all targets), runtime-tokio,jemalloc (all targets) and runtime-tokio,graph,text-index; - the persistence_tick and checkpoint lib tests on both runtimes (40 each). Refs #452 author: Tin Dang
…piles up helpers The heap data-file fsync that makes a redo point safe had four ways to hurt a shard, each now closed and pinned by a test that fails without it. A heap file that no longer exists no longer holds the redo point back. The cold-tier GC unlinks a file once its last live key is gone; the fsync helper's open then failed with NotFound on every attempt, the file never left the pending set, Finalize failed forever and the WAL grew until the disk was full. A dirty page of such a file looped the flush phase the same way. The checkpoint now stops waiting on a vanished file, clears its pages' dirty bits (they stay cached for reads), counts it and logs it. Safe because the only unlinkers remove files nothing references (zero live refs, or never registered), file ids are never reused within a process, and the FPI records above the redo point are not recycled. A vanished file the manifest still lists as live is counted separately and logged as an error: something else removed it and its keys are lost. The fsync never blocks the shard thread and never runs twice. Finalize starts one helper per shard and polls it with try_recv on later ticks; a second batch is refused while one is outstanding. Only the forced checkpoint (BGSAVE, shutdown, WAL ceiling), synchronous by design, waits for that helper, bounded by WAIT_DURABLE_TIMEOUT counted from the helper's start. A result applies only to the write generation it synced. One WAL durability wait per flush batch instead of one per page: every FullPageImage in the batch is appended, the WAL is made durable once through the batch's highest LSN, then the pages are written. A page whose FullPageImage failed now fails the flush and is re-flushed before Finalize, like a failed WAL wait or page write. The checkpoint tests move to a child module and the Finalize durability step to its own module, since persistence_tick.rs is past the size cap. Refs #452 author: Tin Dang
Bring the branch up to date with main before pushing the checkpoint fsync fixes. No conflicts. Refs #452 author: Tin Dang
…file fsync
force_checkpoint has two kinds of caller with different contracts, and
the ceiling one was treated like shutdown. Graceful shutdown (both event
loops) serves no clients any more and may wait for the outstanding heap
data-file fsync, bounded by WAIT_DURABLE_TIMEOUT from the helper's start.
The P6 WAL-ceiling trigger runs on the shard thread while it serves
clients, and blocked the event loop for up to 5 s on a hung disk.
force_checkpoint now takes ForcedCheckpoint::{Shutdown, WalCeiling}. In
WalCeiling mode, once Finalize reports the fsync pending, it returns at
once and leaves the checkpoint to the periodic tick, which polls it. A
later ceiling trigger finds the checkpoint active and returns without
waiting or starting a second helper. BGSAVE was named as a caller in the
doc comment but only force_begins; the doc now names the real callers.
The ceiling's emergency recycle cuts below control.last_checkpoint_lsn,
the in-memory copy of the redo point. Finalize set that copy before
writing the control file and left it set when the write failed, so the
recycle could delete WAL that recovery still starts from. Finalize now
restores the published values when the control-file write fails.
The syncer counts blocking waits, so the new ceiling test asserts
logically that none happened, with the hung fsync released only after
both triggers returned.
Refs #452
author: Tin Dang
…Windows The checkpoint tick tests lived in src/shard/checkpoint_tick_tests.rs, declared through a #[path] attribute. scripts/audit-unwrap.sh exempts a test-only file only when its parent declares `mod <stem>;` directly under #[cfg(test)], so the Lint job counted every unwrap in it as unannotated. The file moves to src/shard/persistence_tick/ and is declared as a plain test module. The WAL-ceiling test passed `Instant::now() - 1h` as the time of the last checkpoint. On Windows `Instant` counts from boot, so on a CI host up for less than an hour the subtraction panicked. The trigger's lag is 0 ms, so `Instant::now()` satisfies the guard just as well. Refs #452 author: Tin Dang
…kpoint-data-fsync Main moved by #1100 (log a write before it awaits) and #1109 (log a served blocking pop on the shard that popped it). Neither touches the checkpoint path. The only file both sides changed is src/shard/event_loop.rs, where #1109 installs the per-shard pop log next to the slice init, away from this branch's checkpoint tick; git merged it cleanly. GitHub reported the PR as conflicting only because main had moved. The merged tree passes cargo fmt --check, the unwrap/unsafe/tempdir/ encoding-limit audits, and clippy -D warnings on monoio --all-targets, runtime-tokio,jemalloc --all-targets and runtime-tokio,graph,text-index. Lib tests persistence::, shard::persistence_tick, shard::event_loop and blocking:: pass on monoio (772) and tokio (775). recovery_matrix_w1 and blocking_pop_owner_log_1056 pass against a release-fast monoio binary of the merged tree. Refs #452 author: Tin Dang
…s) into fix/452-checkpoint-data-fsync #1107 changes the tracking plane and the connection handlers; it does not touch the checkpoint path, and the only file both sides changed is CHANGELOG.md, which git merged cleanly. The merged tree passes cargo fmt --check, the unwrap/unsafe/tempdir/ encoding-limit audits, and clippy -D warnings on monoio --all-targets, runtime-tokio,jemalloc --all-targets and runtime-tokio,graph,text-index. Lib tests persistence::, shard::persistence_tick, shard::event_loop, blocking:: and tracking:: pass on monoio (846) and tokio (849). Refs #452 author: Tin Dang
CHANGELOG.md was the only conflict. It is resolved as main's file plus this branch's own [Unreleased] entries; no entry is duplicated and none is lost. No code file conflicted. author: Tin Dang
TinDang97
added a commit
that referenced
this pull request
Sep 19, 2026
Brings in #1110 (COLD segment install), #1085 (AOF rewrite fold epoch), #1075 (checkpoint redo point), #1118 (WAIT in MULTI, ZRANGESTORE) and #1093 (routed-write AOF admission). The only conflict was in the CHANGELOG Fixed section, where both sides added entries at the top. Both sides are kept. author: Tin Dang
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.
Summary
This closes #452. Four of its five items were already fixed on
main. This PR adds the one that was still open: item 3, the checkpoint data-file fsync. Two follow-up commits fix what adversarial reviews found in the first version. The first fixes four problems: the checkpoint could wedge, fsync helper threads could pile up, the WAL was fsynced once per page, and an FPI failure was not counted as a failed flush. The second stops the WAL-ceiling checkpoint from blocking a serving shard on the fsync. It also stops a failed control-file write from leaving an unpublished redo point in memory.#452 per-item status on
origin/main(checked in the code, not taken from the August notes)RewriteOverflowspill buffer (src/persistence/aof/rewrite_overflow.rs:try_spill,finish_inner,arm_scoped) is armed in all six fold arms. Tests:pool_bounded_blocking_spills_instead_of_dropping_during_rewriteand the otherrewrite_overflow::tests, plus the e2etests/recovery_matrix_w1.rs::rewrite_under_pipelined_load_loses_no_acked_writes.src/persistence/wal_v3/replay.rs:is_mid_chain_tearandreplay_wal_v3_dir_until_with_salvage, overrideMOON_WAL_SALVAGE=1. Boot aborts throughabort_boot_on_mid_chain_tear. Tests:test_v3_dir_mid_chain_tear_fails_loud_by_default,test_v3_dir_mid_chain_tear_salvage_continues.AOF_LAST_APPEND_OKlatch (every drop goes throughrecord_append_dropped), reported as INFOaof_last_append_status/aof_reason_del_dropped. Reason-DELs get the 500 ms bound, and the drop is counted and logged. "Never drop" was considered and rejected: a dead writer must not hang the shard, and a retry queue would replay the DEL before its SET. Tests:dropped_append_latches_degraded_status,record_reason_del_drop_is_counted_and_latches_degraded.invalidate_server_removedis called from active and lazy expiry, eviction,kv_ops.rsandcold_index.rs. Tests:active_expiry_invalidates_tracking_clients,lazy_expiry_drain_invalidates_tracking_clients,plain_eviction_invalidates_tracking_clients, e2etests/tracking_expiry_invalidation_1013.rs.Item 3: what was wrong
handle_checkpoint_tickhad three problems:FlushPagespwrote dirty pages.Finalizethen wrote the control file, which makesredo_lsnthe replay start, and recycled the WAL below that point. After a power loss the page could roll back, and the WAL that could have redone it was already gone.Item 3: the fix
CheckpointManagertracks the heap files each flush wrote, each with a write generation. Finalize fsyncs them before it appends the checkpoint record.persistence::data_file_sync::DataFileSyncer, owned by the manager). Finalize starts a batch and returns "pending". Later ticks poll it withtry_recv, the same off-loop pattern as the WAL'sWalSyncAgent. The periodic tick has norecv_timeout, no sleep and no fsync.force_checkpointhas two contracts, now explicit (ForcedCheckpoint::{Shutdown, WalCeiling}). BGSAVE is not a caller; it only callsforce_begin, and the periodic tick drives that checkpoint.Shutdown(the tokio event loop atevent_loop.rs~1901, monoio ~2104): the shard serves no clients any more. It waits for the one outstanding helper, for at mostWAIT_DURABLE_TIMEOUTcounted from the helper's start, so a hung disk delays shutdown by 5 s once. Without this wait, the tick loop gives up before the fsync reports; a mutation test confirms it.WalCeiling(the P6 trigger,maybe_force_checkpoint_on_wal_overflow): it runs on the shard thread while it serves clients, so it never waits on the fsync. When Finalize reports the fsync pending, it logs at info and returns at once, and the periodic tick finishes the checkpoint. A later ceiling trigger finds the checkpoint active and returns without waiting and without a second helper.control.last_checkpoint_lsn.min(control.graph_floor_lsn)from the in-memory control copy. With the checkpoint pending, that copy is unchanged, so the recycle stays below the published redo point. Reading this code turned up a bug that was already onmain. Finalize set the in-memory copy before writing the control file, and left it set when the write failed. The recycle could then delete WAL above the redo point recovery would start from. Finalize now restores the published values when the write fails.NotFoundthe checkpoint stops waiting on the file. It clears the dirty and FPI-pending bits of the file's cached pages; they stay cached, so reads the cache can serve keep working. It counts the file inVanishedDataFilesand logs it. It never re-creates the file. See the next section for why this is safe.PageCache::flush_dirty_pages_with_fpinow appends the FPI of every page in the batch. It then calls the WAL barrier once, through the batch's highest page LSN and highest FPI LSN, and only then pwrites the pages. The order per page is unchanged, and each batch pays one WAL fsync instead of one per page. It returns aFlushOutcome { flushed, failed }.redo_lsn. That covers a failed FPI append, a failed WAL barrier, and a failed page write. An FPI failure used to be dropped silently, and the checkpoint then published over the unwritten page.FlushOutcome::failednow counts it.Why dropping a vanished heap file is safe
Before trusting "a file that no longer exists holds no data the redo point depends on", I checked every way a page of such a file could still be referenced. The full argument is in the doc comment of
stop_waiting_on_vanished_heap_file(src/shard/checkpoint_heap_files.rs).Unlink sites. Heap files are unlinked in exactly two places:
ColdIndex::drain_pending_unlinkdeletes a file only once its live-reference count is zero, and re-checks that at drain time. It tombstones the manifest entry in the sameShardManifestthat Finalize commits (step 3) before the control file publishes the redo point (step 4).kv_spill::classify_orphan_heap_files) deletes only files the manifest never registered, which were never indexed.Neither can remove a file the cold index points into.
Readers. Heap pages are read only through the cold index, by
file_id. A zero-ref or unregistered file has no index entry, so no reader can reach it.Same name, new file. File ids come from one monotonic per-shard counter. It is seeded past every id on disk and in the manifest, and pinned by
test_cold_file_id_counter_has_one_home_and_never_moves_back. A spill always writes a freshly minted id, so an in-flight spill cannot be the vanished file. An fsync by path cannot land on a different inode that has the same name.WAL. A page's FPI is appended after the checkpoint began, so it lies above the redo point and this checkpoint does not recycle it. Recovery replays it into a re-created file that no live manifest entry lists. Nothing reads that file; at worst a small file is left over (see Residuals). The page's own change record below the redo point is recycled. Nothing needs it, because the only file it applies to is gone and unreferenced.
The case this does not cover. Something other than those two paths removed a file the manifest still lists as live: an operator, or disk tooling. Here a page was still referenced, and its keys are lost whatever the checkpoint does, because no retry can fsync the unlinked inode. Holding the redo point back would only grow the WAL until the disk is full, turning one lost file into a whole-shard outage. So the checkpoint stops waiting in this case too, but it logs an error and counts the file separately (
VanishedDataFiles::still_registered). Cold reads of those keys report the file missing. They do not read as absent.Reachability.
PageCache::mark_dirtystill has no production callers, so no heap page reaches this path today. This closes the hazards before the first in-place cold-page write can.RED → GREEN
The RED run used head
ee152dfaplus these tests and three test-only seams, with no fixes: a settable fsync fn on the manager, a thread-local FPI-failure hook, and a stubbedvanished_data_files()that returns zero. The RED result was the same on both runtimes: 7 passed, 6 failed. Failing assertions (monoio; the tokio lines are identical apart from thread ids):WAL-ceiling follow-up. The RED run used head
4a06517dplus the two tests below, with no fix. The only seam was a counter of blocking waits in the syncer. Both tests failed on both runtimes (tokio shown; monoio identical):The ceiling test is logical rather than timed, so CPU load cannot make it flaky. It counts blocking waits, and it releases the hung fsync only after both triggers have returned. It also asserts that the helper was still outstanding at that point, that
helpers_started == 1, and that the in-memory and published redo points did not move. Finally, the periodic tick completes the checkpoint.GREEN, after the fix and after merging
main:These tests are new, and the mechanisms they pin did not exist before:
data_file_sync::tests::{outcomes_distinguish_synced_vanished_and_unopenable, an_fsync_error_poisons_and_stops_the_batch, a_second_batch_is_refused_while_one_is_outstanding, poll_is_non_blocking_and_wait_is_bounded_from_the_batch_start},page_cache::tests::{fpi_flush_reports_every_page_it_did_not_write, abandon_dirty_pages_of_file_touches_only_that_file},checkpoint_tick_tests::{a_heap_file_the_gc_tombstoned_and_unlinked_is_counted_as_retired, forced_checkpoint_completes_through_the_off_loop_data_sync, a_data_file_fsync_error_poisons_the_checkpoint}.Mutation check. With the forced checkpoint's wait removed,
forced_checkpoint_completes_through_the_off_loop_data_syncfails withassertion failed: !shard.checkpoint_mgr.is_active(). An earlier version of that test passed under the same mutation because the fsync finished too fast. It now uses a 400 ms fsync.Changed tests. Two existing tests used to simulate "cannot open" by renaming the file away, which is
NotFound:checkpoint_does_not_publish_redo_over_a_heap_file_it_cannot_fsyncandcheckpoint_does_not_finalize_over_a_page_that_failed_to_flush. Under the new semantics that means the file vanished. They now put a directory where the file was, so the open fails with EISDIR, or access denied on Windows, which is the retryable class. The two older FPI tick tests now tick at about 1 ms instead of spinning 100 times, because Finalize completes on a later tick.Windows fix. Dispatch run 35438324468 failed
Check (Windows 2/3)on this PR's own new test,the_wal_ceiling_trigger_never_waits_on_the_data_file_fsync, which panicked atstd/src/time.rs:445. It computedInstant::now() - 1h, and a WindowsInstantcounts from boot, so the subtraction underflows on a CI host that has been up for less than an hour. The trigger's lag is 0 ms, so the test now passesInstant::now(). The other two failures in that job,channel_acl_holds_4_shardsandtest_ssm4a_aof_fold_cooperative, passed when nextest retried them and were reported as flaky. The single hard failure was this test.Gates (head after merging
main)cargo fmt --checkscripts/audit-unwrap.sh,audit-unsafe.sh,audit-test-tempdirs.sh,audit-encoding-limits.shcargo clippy --all-targets -- -D warnings(monoio)cargo clippy --all-targets --no-default-features --features runtime-tokio,jemalloc -- -D warningscargo clippy --no-default-features --features runtime-tokio,graph,text-index -- -D warningscargo doc --no-deps --lib: no warnings in the touched files[Unreleased] / Fixedis updated to matchMerge of main through #1109 (0092c73)
Main moved by #1100 and #1109; neither touches the checkpoint path, and the only shared file (
src/shard/event_loop.rs, where #1109 installs the per-shard pop log) merged cleanly. Gates on 0092c73:cargo fmt --check; the four audits; clippy-D warningson monoio--all-targets,runtime-tokio,jemalloc --all-targetsandruntime-tokio,graph,text-index; lib testspersistence::,shard::persistence_tick,shard::event_loop,blocking::: monoio 772 passed, tokio 775 passed;recovery_matrix_w1andblocking_pop_owner_log_1056pass against a release-fast monoio binary of the merged tree.Merge of main through #1107 (f331b97)
Clean merge; only
CHANGELOG.mdis shared. Gates on f331b97:cargo fmt --check; the four audits; clippy-D warningson the three configurations; lib testspersistence::,shard::persistence_tick,shard::event_loop,blocking::,tracking::: monoio 846 passed, tokio 849 passed.Residuals, not fixed here
WalWriterV3::wait_durableis a bounded condvar wait (5 s) on the shard thread. It was already onmain, both for the log-before-data barrier and for Finalize step 2. It now runs once per flush batch instead of once per page. Making it non-blocking would mean carrying page snapshots across ticks, which is out of scope. It is the only blocking wait left in the non-shutdown paths this PR touches. The data-file fsync is waited on only at shutdown.create(true). An FPI above the redo point for a file the GC later unlinked re-creates a small file. No live manifest entry lists it, so nothing reads it. It is deleted at boot once its tombstone has been pruned from the manifest, and kept until then. This behaviour was already onmainand this PR does not change it.VanishedDataFileslives on the manager and is logged. It is not yet in INFO or metrics.persistence_tick.rsis still over the size cap. It was 2576 lines onmainand is 2730 here. This PR moves the checkpoint tests intosrc/shard/persistence_tick/checkpoint_tick_tests.rs, declared as a plain#[cfg(test)] modsoscripts/audit-unwrap.shtreats it as test code, and the Finalize durability step intosrc/shard/checkpoint_heap_files.rs. A full directory split would be a large move that other open PRs conflict with, so it is left for a separate change.Follow-up carried out of #452
The
AOF_LAST_APPEND_OKdoc comment names "reset the latch after a clean rewrite" as follow-up work "tracked in #452". That item is not one of #452's five, and this PR does not do it. It is now tracked in #1094, so closing #452 does not orphan it.Fixes #452