-
Notifications
You must be signed in to change notification settings - Fork 58
fix(bootstrap): recover state from WAL when WAL is ahead after crash #1048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
3c9550b
a3ee096
af02a33
8949480
5426bf0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ use tracing::{error, info, warn}; | |
|
|
||
| use crate::{ | ||
| sync::drain_pending_work, ArchiveStore, ArchiveWriter as _, ChainLogic, ChainPoint, Domain, | ||
| DomainError, IndexStore, IndexWriter as _, StateStore, WalStore, | ||
| DomainError, EntityMap, IndexStore, IndexWriter as _, StateStore, StateWriter as _, WalStore, | ||
| }; | ||
|
|
||
| /// Extension trait for domain bootstrapping operations. | ||
|
|
@@ -54,9 +54,10 @@ impl<D: Domain> BootstrapExt for D { | |
|
|
||
| /// Check that WAL is consistent with the state store. | ||
| /// | ||
| /// WAL at or ahead of state is normal — the existing `catch_up_stores` handles | ||
| /// replaying WAL entries to bring other stores up. State ahead of WAL is an | ||
| /// error that requires explicit repair via `dolos doctor reset-wal`. | ||
| /// WAL at or ahead of state is normal — `catch_up_stores` replays WAL entries | ||
| /// to bring every other store (state included) up to the WAL tip. State ahead | ||
| /// of WAL is an error that requires explicit repair via `dolos doctor | ||
| /// reset-wal`. | ||
| fn check_wal_in_sync_with_state<D: Domain>(domain: &D) -> Result<(), DomainError> { | ||
| let wal = domain.wal().find_tip()?.map(|(point, _)| point); | ||
| let state = domain.state().read_cursor()?; | ||
|
|
@@ -138,34 +139,127 @@ fn check_archive_in_sync_with_state<D: Domain>(domain: &D) -> Result<(), DomainE | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Catch up archive and index stores by replaying WAL entries. | ||
| /// Catch up state, archive and index stores by replaying WAL entries. | ||
| /// | ||
| /// After `check_wal_in_sync_with_state`, the WAL is at or ahead of the state. | ||
| /// If archive or index stores are behind (e.g., crash between state commit | ||
| /// and archive/index commit), this function replays the missing WAL entries | ||
| /// to bring them back in sync. | ||
| /// The WAL commits first in the work-unit lifecycle, so after a crash it is | ||
| /// the most advanced store. Every other store reconciles forward to the WAL | ||
| /// tip: state first (covering a crash between `commit_wal` and | ||
| /// `commit_state`), then archive and indexes (covering a crash between the | ||
| /// state commit and the archive/index commits). | ||
| fn catch_up_stores<D: Domain>(domain: &D) -> Result<(), DomainError> { | ||
| let state_cursor = match domain.state().read_cursor()? { | ||
| let target = match domain.wal().find_tip()? { | ||
| // nothing to catch up | ||
| None => return Ok(()), | ||
| // Origin means no blocks have been processed yet — archive and indexes | ||
| // are correctly empty, so there is nothing to replay. | ||
| Some(ChainPoint::Origin) => return Ok(()), | ||
| Some(cursor) => cursor, | ||
| // Origin means no blocks have been processed yet — state, archive and | ||
| // indexes are correctly empty, so there is nothing to replay. | ||
| Some((ChainPoint::Origin, _)) => return Ok(()), | ||
| Some((point, _)) => point, | ||
| }; | ||
|
|
||
| catch_up_archive(domain, &state_cursor)?; | ||
| catch_up_indexes(domain, &state_cursor)?; | ||
| catch_up_state(domain, &target)?; | ||
| catch_up_archive(domain, &target)?; | ||
| catch_up_indexes(domain, &target)?; | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Catch up the state store by replaying WAL entries. | ||
| /// | ||
| /// A crash between `commit_wal` and `commit_state` leaves the WAL holding | ||
| /// blocks whose effects never reached the state store. Only roll work units | ||
| /// write WAL entries, and each entry fully captures its state mutation | ||
| /// (entity deltas + block + resolved inputs), so forward-replaying them here | ||
| /// is lossless. Boundary work units never write the WAL, so they can't leave | ||
| /// the WAL ahead of state; recovering a crash *during* a boundary is a | ||
| /// separate concern (#1018). | ||
| fn catch_up_state<D: Domain>(domain: &D, target: &ChainPoint) -> Result<(), DomainError> { | ||
| let state_cursor = domain.state().read_cursor()?; | ||
|
|
||
| if state_cursor.as_ref() == Some(target) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Origin (or no cursor) means nothing has been applied yet — replay the | ||
| // whole WAL. | ||
| let state_slot = match &state_cursor { | ||
| None | Some(ChainPoint::Origin) => None, | ||
| Some(point) => Some(point.slot()), | ||
| }; | ||
|
|
||
| // Find the WAL start point from the state cursor | ||
| let start = match state_slot { | ||
| Some(slot) => domain.wal().locate_point(slot)?, | ||
| None => None, | ||
| }; | ||
|
|
||
| let logs = domain.wal().iter_logs(start, Some(target.clone()))?; | ||
|
|
||
| let mut count = 0u64; | ||
|
|
||
| for (point, mut log) in logs { | ||
| // Skip entries at or before the current state cursor | ||
| if Some(point.slot()) <= state_slot { | ||
| continue; | ||
| } | ||
|
|
||
| // Skip synthetic entries (from reset_to) — they carry no effects | ||
| if log.block.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| let writer = domain.state().start_writer()?; | ||
|
|
||
| // Forward mirror of the rollback loop in `sync.rs`: load each entity | ||
| // at its pre-block value, apply the deltas in block order, persist. | ||
| let mut entities = EntityMap::default(); | ||
| crate::state::apply_delta_chunk::<D>(&mut entities, domain.state(), &mut log.delta)?; | ||
| crate::state::save_entities::<D>(&writer, &entities)?; | ||
|
|
||
| let catchup = D::Chain::compute_catchup(&log.block, &log.inputs, point.clone())?; | ||
|
|
||
| writer.apply_utxoset(&catchup.utxo_delta)?; | ||
|
|
||
| writer.set_cursor(point.clone())?; | ||
|
|
||
| // Commit per entry so a later block touching the same entity reloads | ||
| // the value this block just wrote. | ||
| writer.commit()?; | ||
|
|
||
| count += 1; | ||
| } | ||
|
|
||
| if count > 0 { | ||
| info!(count, "state caught up from WAL"); | ||
| } | ||
|
|
||
| // Post-condition: the replay must actually reach the target. A WAL that | ||
| // claims a tip the state can't reach (e.g. entries wiped by `reset_to`) | ||
| // is unrecoverable here — fail loudly instead of leaving a silent gap. | ||
| // Compared by slot: a Slot-only cursor at the target's slot (post-ESTART) | ||
| // is already at the target even though the points differ. | ||
| let cursor = domain.state().read_cursor()?; | ||
|
|
||
| let reached = cursor | ||
| .as_ref() | ||
| .is_some_and(|cursor| cursor.slot() >= target.slot()); | ||
|
|
||
| if !reached { | ||
| error!(?cursor, %target, "state catch-up could not reach the WAL target"); | ||
| return Err(DomainError::InconsistentState { | ||
| wal: Some(target.clone()), | ||
| state: cursor, | ||
| }); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this code should be very similar to what the roll-forward does during normal operation. Is there an opportunity to abstract this in a function somewhere and make it more DRY?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted in a3ee096 — with one correction to the framing: the true twin of this code isn't roll-forward but rollback ( New shared kernels in The refactor also surfaced and fixed a latent bug (was tracked in a local note): rollback undid entities in memory but never saved them back — post-rollback entity state still reflected the undone blocks. Rollback now threads one entity map across all undone entries (so consecutive blocks touching the same entity unwind from the in-memory value, not the stale uncommitted store) and persists it before commit. The rollback lifecycle test now asserts the accounts namespace is byte-identical to its snapshot at the rollback target. |
||
|
|
||
| /// Catch up archive store by replaying WAL blocks. | ||
| fn catch_up_archive<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result<(), DomainError> { | ||
| fn catch_up_archive<D: Domain>(domain: &D, target: &ChainPoint) -> Result<(), DomainError> { | ||
| let archive_tip = domain.archive().get_tip()?.map(|(slot, _)| slot); | ||
| let state_slot = state_cursor.slot(); | ||
| let target_slot = target.slot(); | ||
|
|
||
| if archive_tip == Some(state_slot) { | ||
| if archive_tip == Some(target_slot) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
|
|
@@ -176,9 +270,7 @@ fn catch_up_archive<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| None => None, | ||
| }; | ||
|
|
||
| let blocks = domain | ||
| .wal() | ||
| .iter_blocks(start, Some(state_cursor.clone()))?; | ||
| let blocks = domain.wal().iter_blocks(start, Some(target.clone()))?; | ||
|
|
||
| let writer = domain.archive().start_writer()?; | ||
| let mut count = 0u64; | ||
|
|
@@ -189,6 +281,11 @@ fn catch_up_archive<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| continue; | ||
| } | ||
|
|
||
| // Skip synthetic entries (from reset_to) — they carry no block | ||
| if block.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| writer.apply(&point, &block)?; | ||
| count += 1; | ||
| } | ||
|
|
@@ -198,14 +295,27 @@ fn catch_up_archive<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| info!(count, "archive caught up from WAL"); | ||
| } | ||
|
|
||
| // Archive can legitimately remain behind when the WAL no longer holds the | ||
| // missing blocks (e.g. after `reset_to`). Flag it instead of failing — | ||
| // archive completeness is not consensus-critical, matching the lenient | ||
| // handling in `check_archive_in_sync_with_state`. | ||
| let archive_tip = domain.archive().get_tip()?.map(|(slot, _)| slot); | ||
|
|
||
| if archive_tip < Some(target_slot) { | ||
| error!( | ||
| ?archive_tip, | ||
| target_slot, "archive still behind WAL target after catch-up" | ||
| ); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Catch up index store by replaying WAL log entries. | ||
| fn catch_up_indexes<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result<(), DomainError> { | ||
| fn catch_up_indexes<D: Domain>(domain: &D, target: &ChainPoint) -> Result<(), DomainError> { | ||
| let index_cursor = domain.indexes().cursor()?; | ||
|
|
||
| if index_cursor.as_ref() == Some(state_cursor) { | ||
| if index_cursor.as_ref() == Some(target) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
|
|
@@ -217,7 +327,7 @@ fn catch_up_indexes<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| None => None, | ||
| }; | ||
|
|
||
| let logs = domain.wal().iter_logs(start, Some(state_cursor.clone()))?; | ||
| let logs = domain.wal().iter_logs(start, Some(target.clone()))?; | ||
|
|
||
| let writer = domain.indexes().start_writer()?; | ||
| let mut count = 0u64; | ||
|
|
@@ -228,6 +338,11 @@ fn catch_up_indexes<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| continue; | ||
| } | ||
|
|
||
| // Skip synthetic entries (from reset_to) — they carry no effects | ||
| if log.block.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| let catchup = D::Chain::compute_catchup(&log.block, &log.inputs, point)?; | ||
|
|
||
| writer.apply(&catchup.index_delta)?; | ||
|
|
@@ -239,6 +354,15 @@ fn catch_up_indexes<D: Domain>(domain: &D, state_cursor: &ChainPoint) -> Result< | |
| info!(count, "indexes caught up from WAL"); | ||
| } | ||
|
|
||
| // Same lenient handling as archive: flag a residual lag instead of | ||
| // failing the boot. | ||
| let index_cursor = domain.indexes().cursor()?; | ||
| let index_slot = index_cursor.as_ref().map(|p| p.slot()); | ||
|
|
||
| if index_slot < Some(target.slot()) { | ||
| error!(?index_cursor, %target, "indexes still behind WAL target after catch-up"); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.