Skip to main content

rhiza_node/
durability.rs

1use std::{
2    error::Error,
3    fmt, fs,
4    future::Future,
5    io::Write,
6    path::{Path, PathBuf},
7    process,
8    sync::{
9        atomic::{AtomicU64, Ordering},
10        Arc, Mutex, MutexGuard,
11    },
12    time::{Duration, Instant},
13};
14
15use rhiza_archive::{
16    CheckpointIdentity, CheckpointPublisher, CheckpointPublisherOptions, CheckpointTip,
17    ObjectArchiveStore, RestoredCheckpoint,
18};
19#[cfg(any(feature = "graph", feature = "kv"))]
20use rhiza_core::SnapshotIdentity;
21use rhiza_core::{
22    ConfigurationState, ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
23};
24#[cfg(feature = "graph")]
25use rhiza_graph::{
26    decode_snapshot as decode_graph_snapshot, encode_snapshot as encode_graph_snapshot,
27    restore_snapshot_file as restore_graph_snapshot_file,
28};
29#[cfg(feature = "kv")]
30use rhiza_kv::{
31    decode_snapshot as decode_kv_snapshot, encode_snapshot as encode_kv_snapshot,
32    restore_snapshot_file as restore_kv_snapshot_file, RedbStateMachine,
33};
34use rhiza_log::{FileLogStore, IndexRange, LogStore};
35#[cfg(feature = "sql")]
36use rhiza_sql::{restore_recovery_snapshot_file, sql_executor_fingerprint};
37use serde::Serialize;
38
39use crate::{Materializer, NodeConfig, NodeRuntime};
40
41const FLUSH_BATCH_ENTRIES: LogIndex = 32;
42const RESTORE_INTENT_FILE: &str = ".rhiza-restore-v1";
43const RESTORE_INTENT_CONTENTS: &[u8] = b"rhiza restore in progress\n";
44const RESTORE_STAGING_PREFIX: &str = ".restore-stage-";
45const RESTORE_MARKER_TMP_PREFIX: &str = ".restore-marker-tmp-";
46const SUCCESSOR_RESTORE_LOCK_FILE: &str = ".successor-restore.lock";
47const SUCCESSOR_RESTORE_INTENT_FILE: &str = ".successor-restore.intent";
48const SUCCESSOR_RESTORE_COMPLETE_FILE: &str = ".successor-restore.complete";
49static RESTORE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
50
51#[derive(Serialize)]
52struct SuccessorRestoreIdentity<'a> {
53    version: u32,
54    cluster_id: &'a str,
55    epoch: u64,
56    target_config_id: u64,
57    recovery_generation: u64,
58    node_id: &'a str,
59    membership_digest: String,
60    predecessor_config_id: u64,
61    stop_index: LogIndex,
62    stop_hash: String,
63    checkpoint_index: LogIndex,
64    checkpoint_hash: String,
65}
66
67pub struct SuccessorRestorePreparation {
68    tip: CheckpointTip,
69    data_dir: PathBuf,
70    identity: Vec<u8>,
71    requires_recorder_install: bool,
72    _lock: fs::File,
73}
74
75impl SuccessorRestorePreparation {
76    pub const fn tip(&self) -> CheckpointTip {
77        self.tip
78    }
79
80    pub const fn requires_recorder_install(&self) -> bool {
81        self.requires_recorder_install
82    }
83
84    pub fn complete(mut self) -> Result<CheckpointTip, DurabilityError> {
85        if !self.requires_recorder_install {
86            return Ok(self.tip);
87        }
88        let intent = self.data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
89        if fs::read(&intent)? != self.identity {
90            return Err(DurabilityError::SnapshotVerification(
91                "successor restore intent changed before completion".into(),
92            ));
93        }
94        fs::rename(intent, self.data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE))?;
95        sync_directory(&self.data_dir)?;
96        self.requires_recorder_install = false;
97        Ok(self.tip)
98    }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum DurabilityMode {
103    Sync,
104    Bounded { max_lag: Duration },
105    Periodic { interval: Duration },
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum DurabilityHealth {
110    Available,
111    Unavailable,
112}
113
114impl DurabilityMode {
115    pub fn validate(&self) -> Result<(), DurabilityError> {
116        match self {
117            Self::Sync => Ok(()),
118            Self::Bounded { max_lag } if max_lag.is_zero() => {
119                Err(DurabilityError::InvalidDuration { mode: "bounded" })
120            }
121            Self::Periodic { interval } if interval.is_zero() => {
122                Err(DurabilityError::InvalidDuration { mode: "periodic" })
123            }
124            Self::Bounded { .. } | Self::Periodic { .. } => Ok(()),
125        }
126    }
127}
128
129#[derive(Debug)]
130pub enum DurabilityError {
131    InvalidDuration {
132        mode: &'static str,
133    },
134    MissingCheckpoint,
135    Unavailable,
136    LagExceeded {
137        committed_index: LogIndex,
138        durable_index: LogIndex,
139        max_lag: Duration,
140    },
141    ArchiveAheadOfLocal {
142        durable_index: LogIndex,
143        local_index: LogIndex,
144    },
145    SnapshotRequired {
146        anchor: Box<RecoveryAnchor>,
147    },
148    LocalLogGap {
149        expected: LogIndex,
150        actual: Option<LogIndex>,
151    },
152    LocalLogConflict {
153        index: LogIndex,
154    },
155    SnapshotVerification(String),
156    PreconditionFailed,
157    DataDirNotFresh(PathBuf),
158    Archive(rhiza_archive::Error),
159    Log(rhiza_log::Error),
160    Io(std::io::Error),
161}
162
163impl fmt::Display for DurabilityError {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            Self::InvalidDuration { mode } => {
167                write!(f, "{mode} durability duration must be non-zero")
168            }
169            Self::MissingCheckpoint => write!(f, "checkpoint manifest is missing"),
170            Self::Unavailable => write!(f, "sync durability is unavailable"),
171            Self::LagExceeded {
172                committed_index,
173                durable_index,
174                max_lag,
175            } => write!(
176                f,
177                "checkpoint lag exceeded {max_lag:?}: committed index {committed_index}, durable index {durable_index}"
178            ),
179            Self::ArchiveAheadOfLocal {
180                durable_index,
181                local_index,
182            } => write!(
183                f,
184                "checkpoint tip {durable_index} is ahead of local qlog tip {local_index}"
185            ),
186            Self::SnapshotRequired { anchor } => write!(
187                f,
188                "snapshot restore required at qlog anchor {} before checkpoint flush",
189                anchor.compacted().index()
190            ),
191            Self::LocalLogGap { expected, actual } => {
192                write!(f, "local qlog gap: expected index {expected}, got {actual:?}")
193            }
194            Self::LocalLogConflict { index } => {
195                write!(f, "local qlog hash chain conflicts at index {index}")
196            }
197            Self::SnapshotVerification(message) => {
198                write!(f, "checkpoint snapshot verification failed: {message}")
199            }
200            Self::PreconditionFailed => write!(f, "checkpoint precondition failed"),
201            Self::DataDirNotFresh(path) => write!(
202                f,
203                "restore data directory contains existing state: {}",
204                path.display()
205            ),
206            Self::Archive(error) => error.fmt(f),
207            Self::Log(error) => error.fmt(f),
208            Self::Io(error) => error.fmt(f),
209        }
210    }
211}
212
213impl Error for DurabilityError {
214    fn source(&self) -> Option<&(dyn Error + 'static)> {
215        match self {
216            Self::Archive(error) => Some(error),
217            Self::Log(error) => Some(error),
218            Self::Io(error) => Some(error),
219            _ => None,
220        }
221    }
222}
223
224impl From<rhiza_archive::Error> for DurabilityError {
225    fn from(error: rhiza_archive::Error) -> Self {
226        Self::Archive(error)
227    }
228}
229
230impl From<rhiza_log::Error> for DurabilityError {
231    fn from(error: rhiza_log::Error) -> Self {
232        Self::Log(error)
233    }
234}
235
236impl From<std::io::Error> for DurabilityError {
237    fn from(error: std::io::Error) -> Self {
238        Self::Io(error)
239    }
240}
241
242#[derive(Debug)]
243enum PendingLag {
244    New(Instant),
245    Recovered,
246}
247
248#[derive(Debug)]
249struct CoordinatorState {
250    durable_tip: CheckpointTip,
251    committed_index: LogIndex,
252    pending_lag: Option<PendingLag>,
253    health: DurabilityHealth,
254}
255
256pub struct CheckpointCoordinator {
257    store: ObjectArchiveStore,
258    publisher: CheckpointPublisher,
259    mode: DurabilityMode,
260    state: Mutex<CoordinatorState>,
261    publication_attempts: AtomicU64,
262}
263
264struct RuntimeCheckpointSnapshot {
265    anchor: RecoveryAnchor,
266    archive_bytes: Vec<u8>,
267}
268
269#[cfg(any(feature = "graph", feature = "kv"))]
270struct EngineSnapshotIdentity<'a> {
271    cluster_id: &'a str,
272    epoch: u64,
273    config_id: u64,
274    applied_index: LogIndex,
275    applied_hash: LogHash,
276}
277
278impl CheckpointCoordinator {
279    pub async fn open(
280        store: ObjectArchiveStore,
281        mode: DurabilityMode,
282    ) -> Result<Self, DurabilityError> {
283        Self::open_with_holder(store, mode, "anonymous-node").await
284    }
285
286    pub async fn open_with_holder(
287        store: ObjectArchiveStore,
288        mode: DurabilityMode,
289        holder: impl AsRef<str>,
290    ) -> Result<Self, DurabilityError> {
291        Self::open_with_holder_and_options(
292            store,
293            mode,
294            holder,
295            CheckpointPublisherOptions::default(),
296        )
297        .await
298    }
299
300    pub async fn open_with_holder_and_options(
301        store: ObjectArchiveStore,
302        mode: DurabilityMode,
303        holder: impl AsRef<str>,
304        publisher_options: CheckpointPublisherOptions,
305    ) -> Result<Self, DurabilityError> {
306        mode.validate()?;
307        store
308            .load_checkpoint()
309            .await?
310            .ok_or(DurabilityError::MissingCheckpoint)?;
311        let identity = store.checkpoint_identity()?;
312        let holder = format!(
313            "checkpoint-coordinator-{}-{}-{}-{}-{}",
314            identity.cluster_id(),
315            identity.epoch(),
316            identity.config_id(),
317            identity.recovery_generation(),
318            holder.as_ref()
319        );
320        let publisher = store
321            .open_checkpoint_publisher(holder, publisher_options)
322            .await?;
323        let loaded = publisher.cached_checkpoint().await;
324        let durable_tip = *loaded.manifest().tip();
325        let restored = store.restore_checkpoint_v2().await?;
326        let restored_tip = *restored.tip();
327        if restored_tip != durable_tip {
328            return Err(DurabilityError::Archive(
329                rhiza_archive::Error::InvalidCheckpoint(
330                    "restored entries changed while verifying the loaded manifest".into(),
331                ),
332            ));
333        }
334        Ok(Self {
335            store,
336            publisher,
337            mode,
338            state: Mutex::new(CoordinatorState {
339                durable_tip,
340                committed_index: durable_tip.index(),
341                pending_lag: None,
342                health: DurabilityHealth::Available,
343            }),
344            publication_attempts: AtomicU64::new(0),
345        })
346    }
347
348    pub const fn mode(&self) -> &DurabilityMode {
349        &self.mode
350    }
351
352    pub fn durable_tip(&self) -> CheckpointTip {
353        self.lock_state().durable_tip
354    }
355
356    pub async fn refresh_durable_tip(&self) -> Result<CheckpointTip, DurabilityError> {
357        let loaded = self.publisher.observe_checkpoint().await?;
358        let accepted = self.publisher.cache_observed_checkpoint(loaded).await?;
359        observe_durable_tip(&self.state, *accepted.manifest().tip())
360    }
361
362    pub fn health(&self) -> DurabilityHealth {
363        self.lock_state().health
364    }
365
366    #[doc(hidden)]
367    pub fn checkpoint_publication_attempts(&self) -> u64 {
368        self.publication_attempts.load(Ordering::Relaxed)
369    }
370
371    pub fn note_committed(&self, index: LogIndex) {
372        let mut state = self.lock_state();
373        if index <= state.committed_index {
374            return;
375        }
376        state.committed_index = index;
377        if index > state.durable_tip.index() && state.pending_lag.is_none() {
378            state.pending_lag = Some(PendingLag::New(Instant::now()));
379        }
380    }
381
382    pub fn note_recovered_committed(&self, index: LogIndex) {
383        let mut state = self.lock_state();
384        state.committed_index = state.committed_index.max(index);
385        if state.committed_index > state.durable_tip.index() {
386            state.pending_lag = Some(PendingLag::Recovered);
387        }
388    }
389
390    pub fn write_allowed(&self) -> Result<(), DurabilityError> {
391        if matches!(self.mode, DurabilityMode::Sync)
392            && self.health() == DurabilityHealth::Unavailable
393        {
394            return Err(DurabilityError::Unavailable);
395        }
396        let DurabilityMode::Bounded { max_lag } = self.mode else {
397            return Ok(());
398        };
399        let state = self.lock_state();
400        let exceeded = state.committed_index > state.durable_tip.index()
401            && match state.pending_lag {
402                Some(PendingLag::Recovered) => true,
403                Some(PendingLag::New(pending)) => pending.elapsed() >= max_lag,
404                None => false,
405            };
406        if exceeded {
407            return Err(DurabilityError::LagExceeded {
408                committed_index: state.committed_index,
409                durable_index: state.durable_tip.index(),
410                max_lag,
411            });
412        }
413        Ok(())
414    }
415
416    pub async fn flush_runtime(
417        &self,
418        runtime: &NodeRuntime,
419        target_index: LogIndex,
420    ) -> Result<CheckpointTip, DurabilityError> {
421        let result = self.flush_runtime_inner(runtime, target_index).await;
422        if result.is_err() {
423            self.mark_unavailable();
424        }
425        result
426    }
427
428    async fn flush_runtime_inner(
429        &self,
430        runtime: &NodeRuntime,
431        target_index: LogIndex,
432    ) -> Result<CheckpointTip, DurabilityError> {
433        let log_state = runtime.log_store().logical_state()?;
434        let local_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
435        let mut durable_tip = self.durable_tip();
436        if durable_tip.index() > local_index {
437            return Err(DurabilityError::ArchiveAheadOfLocal {
438                durable_index: durable_tip.index(),
439                local_index,
440            });
441        }
442        let target_index = target_index.min(local_index);
443        if target_index <= durable_tip.index() {
444            return Ok(durable_tip);
445        }
446        if let Some(anchor) = log_state.anchor {
447            if durable_tip.index() < anchor.compacted().index() {
448                return Err(DurabilityError::SnapshotRequired {
449                    anchor: Box::new(anchor),
450                });
451            }
452        }
453
454        let mut next =
455            durable_tip
456                .index()
457                .checked_add(1)
458                .ok_or_else(|| DurabilityError::LocalLogGap {
459                    expected: durable_tip.index(),
460                    actual: None,
461                })?;
462        while next <= target_index {
463            let end = next
464                .saturating_add(FLUSH_BATCH_ENTRIES - 1)
465                .min(target_index);
466            let entries = runtime
467                .log_store()
468                .read_range(IndexRange::new(next, end)?)?;
469            validate_local_batch(&entries, next, end, durable_tip)?;
470            self.publication_attempts.fetch_add(1, Ordering::Relaxed);
471            let published = self.publisher.publish_committed(&entries).await?;
472            durable_tip = *published.manifest().tip();
473            self.mark_durable(durable_tip);
474            if durable_tip.index() >= target_index {
475                break;
476            }
477            next =
478                durable_tip
479                    .index()
480                    .checked_add(1)
481                    .ok_or_else(|| DurabilityError::LocalLogGap {
482                        expected: durable_tip.index(),
483                        actual: None,
484                    })?;
485        }
486        Ok(durable_tip)
487    }
488
489    pub async fn checkpoint_compact(
490        &self,
491        runtime: &NodeRuntime,
492    ) -> Result<RecoveryAnchor, DurabilityError> {
493        self.checkpoint_compact_inner(runtime, None).await
494    }
495
496    pub async fn checkpoint_compact_fenced(
497        &self,
498        runtime: &NodeRuntime,
499        expected_config_id: u64,
500        expected_recovery_generation: u64,
501        expected_root: LogAnchor,
502    ) -> Result<RecoveryAnchor, DurabilityError> {
503        self.checkpoint_compact_inner(
504            runtime,
505            Some((
506                expected_config_id,
507                expected_recovery_generation,
508                expected_root,
509            )),
510        )
511        .await
512    }
513
514    async fn checkpoint_compact_inner(
515        &self,
516        runtime: &NodeRuntime,
517        fence: Option<(u64, u64, LogAnchor)>,
518    ) -> Result<RecoveryAnchor, DurabilityError> {
519        let (target, snapshot, _fence) = {
520            let _commit = runtime.commit.lock().map_err(|_| {
521                DurabilityError::SnapshotVerification("commit mutex is poisoned".into())
522            })?;
523            runtime
524                .ensure_ready()
525                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
526            let configuration = runtime
527                .configuration_state()
528                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
529            if !configuration.is_active() && configuration.stop().is_none() {
530                return Err(DurabilityError::SnapshotVerification(
531                    "runtime configuration is not compactable".into(),
532                ));
533            }
534            if let Some((config_id, generation, root)) = fence {
535                let actual_config_id = configuration.config_id();
536                let actual_generation = runtime.config.recovery_generation();
537                let actual_root = runtime.log_root_unlocked().ok();
538                if actual_config_id != config_id
539                    || actual_generation != generation
540                    || actual_root != Some(root)
541                {
542                    eprintln!(
543                        "checkpoint fence mismatch: config {actual_config_id}/{config_id}, \
544                         generation {actual_generation}/{generation}, root {actual_root:?}/{root:?}"
545                    );
546                    return Err(DurabilityError::PreconditionFailed);
547                }
548            }
549            if runtime
550                .checkpointing
551                .swap(true, std::sync::atomic::Ordering::AcqRel)
552            {
553                return Err(DurabilityError::SnapshotVerification(
554                    "checkpoint transition is already in progress".into(),
555                ));
556            }
557            let fence = CheckpointFence(&runtime.checkpointing);
558            let (target, target_hash) = runtime
559                .ensure_materialized_tip()
560                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
561            let snapshot =
562                create_runtime_checkpoint_snapshot(runtime, target, target_hash, &configuration)?;
563            (target, snapshot, fence)
564        };
565        self.flush_runtime(runtime, target).await?;
566        let anchor = snapshot.anchor.clone();
567        self.publisher
568            .publish_checkpoint_snapshot(anchor.clone(), &snapshot.archive_bytes)
569            .await?;
570        let restored = self.store.restore_checkpoint_v2().await?;
571        let published = restored.snapshot().ok_or_else(|| {
572            DurabilityError::SnapshotVerification("published V2 root has no snapshot".into())
573        })?;
574        if published.anchor() != &anchor || published.bytes() != snapshot.archive_bytes {
575            return Err(DurabilityError::SnapshotVerification(
576                "read-back anchor or snapshot bytes differ".into(),
577            ));
578        }
579        runtime.log_store.compact_prefix(&anchor)?;
580        runtime
581            .compact_embedded_log_before(anchor.compacted().index())
582            .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
583        self.mark_durable(CheckpointTip::new(
584            anchor.compacted().index(),
585            anchor.compacted().hash(),
586        ));
587        Ok(anchor)
588    }
589
590    pub async fn run_background<F>(
591        self: Arc<Self>,
592        runtime: Arc<NodeRuntime>,
593        shutdown: F,
594    ) -> Result<(), DurabilityError>
595    where
596        F: Future<Output = ()> + Send,
597    {
598        let cadence = match self.mode {
599            DurabilityMode::Sync => return Ok(()),
600            DurabilityMode::Bounded { max_lag } => {
601                let half = max_lag / 2;
602                half.min(Duration::from_secs(1))
603            }
604            DurabilityMode::Periodic { interval } => interval,
605        };
606        tokio::pin!(shutdown);
607        loop {
608            tokio::select! {
609                () = &mut shutdown => return Ok(()),
610                () = tokio::time::sleep(cadence) => {
611                    match self.flush_runtime(&runtime, LogIndex::MAX).await {
612                        Ok(_) | Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {}
613                        Err(error) => return Err(error),
614                    }
615                }
616            }
617        }
618    }
619
620    fn mark_durable(&self, durable_tip: CheckpointTip) {
621        let mut state = self.lock_state();
622        mark_durable_state(&mut state, durable_tip);
623    }
624
625    fn mark_unavailable(&self) {
626        let mut state = self.lock_state();
627        if state.committed_index > state.durable_tip.index() {
628            state.health = DurabilityHealth::Unavailable;
629        }
630    }
631
632    fn lock_state(&self) -> MutexGuard<'_, CoordinatorState> {
633        self.state
634            .lock()
635            .unwrap_or_else(|poisoned| poisoned.into_inner())
636    }
637}
638
639fn create_runtime_checkpoint_snapshot(
640    runtime: &NodeRuntime,
641    target: LogIndex,
642    target_hash: LogHash,
643    configuration: &ConfigurationState,
644) -> Result<RuntimeCheckpointSnapshot, DurabilityError> {
645    #[cfg(not(any(feature = "graph", feature = "kv")))]
646    let _ = target_hash;
647    let materializer = runtime
648        .lock_materializer()
649        .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
650    match &*materializer {
651        #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
652        Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
653        #[cfg(feature = "sql")]
654        Materializer::Sql(state) => {
655            let snapshot = state
656                .create_recovery_snapshot(runtime.config().recovery_generation())
657                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
658            if snapshot.anchor().compacted().index() != target
659                || snapshot.anchor().configuration_state() != configuration
660            {
661                return Err(DurabilityError::SnapshotVerification(
662                    "SQLite snapshot does not match the compacted runtime state".into(),
663                ));
664            }
665            Ok(RuntimeCheckpointSnapshot {
666                anchor: snapshot.anchor().clone(),
667                archive_bytes: snapshot.db_bytes().to_vec(),
668            })
669        }
670        #[cfg(feature = "graph")]
671        Materializer::Graph(state) => {
672            let snapshot = state
673                .create_snapshot(target)
674                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
675            validate_engine_snapshot_identity(
676                runtime,
677                configuration,
678                EngineSnapshotIdentity {
679                    cluster_id: snapshot.cluster_id(),
680                    epoch: snapshot.epoch(),
681                    config_id: snapshot.config_id(),
682                    applied_index: snapshot.applied_index(),
683                    applied_hash: snapshot.applied_hash(),
684                },
685                target,
686                target_hash,
687            )?;
688            let archive_bytes = encode_graph_snapshot(&snapshot)
689                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
690            Ok(RuntimeCheckpointSnapshot {
691                anchor: engine_recovery_anchor(
692                    runtime,
693                    configuration,
694                    target,
695                    snapshot.applied_hash(),
696                    snapshot.materializer_fingerprint(),
697                    &archive_bytes,
698                )?,
699                archive_bytes,
700            })
701        }
702        #[cfg(feature = "kv")]
703        Materializer::Kv(state) => {
704            let snapshot = state
705                .create_snapshot(target)
706                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
707            validate_engine_snapshot_identity(
708                runtime,
709                configuration,
710                EngineSnapshotIdentity {
711                    cluster_id: snapshot.cluster_id(),
712                    epoch: snapshot.epoch(),
713                    config_id: snapshot.config_id(),
714                    applied_index: snapshot.applied_index(),
715                    applied_hash: snapshot.applied_hash(),
716                },
717                target,
718                target_hash,
719            )?;
720            let archive_bytes = encode_kv_snapshot(&snapshot)
721                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
722            Ok(RuntimeCheckpointSnapshot {
723                anchor: engine_recovery_anchor(
724                    runtime,
725                    configuration,
726                    target,
727                    snapshot.applied_hash(),
728                    snapshot.materializer_fingerprint(),
729                    &archive_bytes,
730                )?,
731                archive_bytes,
732            })
733        }
734    }
735}
736
737#[cfg(any(feature = "graph", feature = "kv"))]
738fn validate_engine_snapshot_identity(
739    runtime: &NodeRuntime,
740    configuration: &ConfigurationState,
741    snapshot: EngineSnapshotIdentity<'_>,
742    expected_index: LogIndex,
743    expected_hash: LogHash,
744) -> Result<(), DurabilityError> {
745    let config = runtime.config();
746    if snapshot.cluster_id != config.cluster_id()
747        || snapshot.epoch != config.epoch()
748        || snapshot.config_id != configuration.config_id()
749        || snapshot.applied_index == 0
750        || snapshot.applied_index != expected_index
751        || snapshot.applied_hash != expected_hash
752    {
753        return Err(DurabilityError::SnapshotVerification(
754            "engine snapshot identity does not match the compacted runtime state".into(),
755        ));
756    }
757    Ok(())
758}
759
760#[cfg(any(feature = "graph", feature = "kv"))]
761fn engine_recovery_anchor(
762    runtime: &NodeRuntime,
763    configuration: &ConfigurationState,
764    applied_index: LogIndex,
765    applied_hash: LogHash,
766    materializer_fingerprint: LogHash,
767    archive_bytes: &[u8],
768) -> Result<RecoveryAnchor, DurabilityError> {
769    let size_bytes = u64::try_from(archive_bytes.len()).map_err(|_| {
770        DurabilityError::SnapshotVerification("snapshot envelope size exceeds u64".into())
771    })?;
772    Ok(RecoveryAnchor::new_with_configuration(
773        runtime.config().cluster_id(),
774        runtime.config().epoch(),
775        configuration.clone(),
776        runtime.config().recovery_generation(),
777        LogAnchor::new(applied_index, applied_hash),
778        SnapshotIdentity::new(
779            format!("snapshot-{applied_index:020}"),
780            LogHash::digest(&[archive_bytes]),
781            size_bytes,
782        )
783        .with_executor_fingerprint(materializer_fingerprint),
784    ))
785}
786
787fn mark_durable_state(state: &mut CoordinatorState, durable_tip: CheckpointTip) {
788    if durable_tip.index() > state.durable_tip.index() {
789        state.durable_tip = durable_tip;
790    }
791    if state.committed_index <= state.durable_tip.index() {
792        state.pending_lag = None;
793        state.health = DurabilityHealth::Available;
794    }
795}
796
797fn observe_durable_tip(
798    state: &Mutex<CoordinatorState>,
799    observed: CheckpointTip,
800) -> Result<CheckpointTip, DurabilityError> {
801    let mut state = state
802        .lock()
803        .unwrap_or_else(|poisoned| poisoned.into_inner());
804    let current = state.durable_tip;
805    if observed.index() < current.index() {
806        return Err(DurabilityError::SnapshotVerification(format!(
807            "checkpoint tip rolled back from index {} to {}",
808            current.index(),
809            observed.index()
810        )));
811    }
812    if observed.index() == current.index() && observed.hash() != current.hash() {
813        return Err(DurabilityError::SnapshotVerification(format!(
814            "checkpoint tip hash changed at index {}",
815            observed.index()
816        )));
817    }
818    mark_durable_state(&mut state, observed);
819    Ok(state.durable_tip)
820}
821
822struct CheckpointFence<'a>(&'a std::sync::atomic::AtomicBool);
823
824impl Drop for CheckpointFence<'_> {
825    fn drop(&mut self) {
826        self.0.store(false, std::sync::atomic::Ordering::Release);
827    }
828}
829
830pub async fn restore_checkpoint_to_fresh_data_dir(
831    store: ObjectArchiveStore,
832    data_dir: impl AsRef<Path>,
833) -> Result<CheckpointTip, DurabilityError> {
834    restore_checkpoint_to_fresh_data_dir_with_target(store, data_dir.as_ref(), None, None).await
835}
836
837pub async fn restore_checkpoint_to_fresh_data_dir_for_node(
838    store: ObjectArchiveStore,
839    data_dir: impl AsRef<Path>,
840    target_node_id: &str,
841) -> Result<CheckpointTip, DurabilityError> {
842    if target_node_id.is_empty() {
843        return Err(DurabilityError::SnapshotVerification(
844            "target node_id is empty".into(),
845        ));
846    }
847    restore_checkpoint_to_fresh_data_dir_with_target(
848        store,
849        data_dir.as_ref(),
850        Some(target_node_id),
851        None,
852    )
853    .await
854}
855
856pub async fn restore_checkpoint_to_fresh_data_dir_for_node_with_marker(
857    store: ObjectArchiveStore,
858    data_dir: impl AsRef<Path>,
859    target_node_id: &str,
860    marker_name: &str,
861    marker_contents: &[u8],
862) -> Result<CheckpointTip, DurabilityError> {
863    if target_node_id.is_empty() {
864        return Err(DurabilityError::SnapshotVerification(
865            "target node_id is empty".into(),
866        ));
867    }
868    validate_restore_marker_name(marker_name)?;
869    restore_checkpoint_to_fresh_data_dir_with_target(
870        store,
871        data_dir.as_ref(),
872        Some(target_node_id),
873        Some((marker_name, marker_contents)),
874    )
875    .await
876}
877
878pub fn checkpoint_restore_in_progress(data_dir: impl AsRef<Path>) -> Result<bool, DurabilityError> {
879    let intent = data_dir.as_ref().join(RESTORE_INTENT_FILE);
880    let metadata = match fs::symlink_metadata(&intent) {
881        Ok(metadata) => metadata,
882        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
883        Err(error) => return Err(error.into()),
884    };
885    if metadata.file_type().is_symlink()
886        || !metadata.is_file()
887        || fs::read(intent)? != RESTORE_INTENT_CONTENTS
888    {
889        return Err(DurabilityError::SnapshotVerification(
890            "local checkpoint restore intent is invalid".into(),
891        ));
892    }
893    Ok(true)
894}
895
896pub fn validate_local_recovery_view(
897    data_dir: impl AsRef<Path>,
898    identity: &CheckpointIdentity,
899    target_node_id: &str,
900    execution_profile: ExecutionProfile,
901    checkpoint_root: LogAnchor,
902) -> Result<(), DurabilityError> {
903    let data_dir = data_dir.as_ref();
904    if checkpoint_restore_in_progress(data_dir)? {
905        return Err(DurabilityError::SnapshotVerification(
906            "local recovery view has an incomplete checkpoint restore intent".into(),
907        ));
908    }
909    #[cfg(not(feature = "kv"))]
910    let _ = target_node_id;
911    if snapshot_profile(identity.cluster_id())? != execution_profile {
912        return Err(DurabilityError::SnapshotVerification(
913            "local recovery view profile does not match checkpoint identity".into(),
914        ));
915    }
916    let validate_qlog = match execution_profile {
917        ExecutionProfile::Sqlite => {
918            #[cfg(feature = "sql")]
919            {
920                let state =
921                    rhiza_sql::SqliteStateMachine::open_existing(data_dir.join("sqlite/db.sqlite"))
922                        .map_err(|error| {
923                            DurabilityError::SnapshotVerification(error.to_string())
924                        })?;
925                validate_materializer_tip(
926                    "SQL",
927                    LogAnchor::new(
928                        state.applied_index_value().map_err(|error| {
929                            DurabilityError::SnapshotVerification(error.to_string())
930                        })?,
931                        state.applied_hash_value().map_err(|error| {
932                            DurabilityError::SnapshotVerification(error.to_string())
933                        })?,
934                    ),
935                    checkpoint_root,
936                )?;
937                true
938            }
939            #[cfg(not(feature = "sql"))]
940            return Err(DurabilityError::SnapshotVerification(
941                "sql execution profile is not compiled in".into(),
942            ));
943        }
944        ExecutionProfile::Kv => {
945            #[cfg(feature = "kv")]
946            {
947                let path = data_dir.join("kv/data.redb");
948                if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
949                    return Err(DurabilityError::SnapshotVerification(
950                        "KV materializer is missing or is not a regular file".into(),
951                    ));
952                }
953                let state = RedbStateMachine::open(
954                    &path,
955                    identity.cluster_id(),
956                    target_node_id,
957                    identity.epoch(),
958                    identity.config_id(),
959                )
960                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
961                validate_materializer_tip(
962                    "KV",
963                    state.applied_tip().map_err(|error| {
964                        DurabilityError::SnapshotVerification(error.to_string())
965                    })?,
966                    checkpoint_root,
967                )?;
968                true
969            }
970            #[cfg(not(feature = "kv"))]
971            return Err(DurabilityError::SnapshotVerification(
972                "kv execution profile is not compiled in".into(),
973            ));
974        }
975        ExecutionProfile::Graph => false,
976    };
977
978    if validate_qlog {
979        validate_local_qlog(data_dir, identity, checkpoint_root)
980    } else {
981        Ok(())
982    }
983}
984
985pub async fn restore_checkpoint_for_rejoin_preserving_recorder(
986    store: ObjectArchiveStore,
987    data_dir: impl AsRef<Path>,
988    target_node_id: &str,
989    execution_profile: ExecutionProfile,
990    marker_name: &str,
991    marker_contents: &[u8],
992) -> Result<CheckpointTip, DurabilityError> {
993    if target_node_id.is_empty() {
994        return Err(DurabilityError::SnapshotVerification(
995            "target node_id is empty".into(),
996        ));
997    }
998    validate_restore_marker_name(marker_name)?;
999    let data_dir = data_dir.as_ref();
1000    let identity = store.checkpoint_identity()?.clone();
1001    if snapshot_profile(identity.cluster_id())? != execution_profile
1002        || execution_profile == ExecutionProfile::Graph
1003    {
1004        return Err(DurabilityError::SnapshotVerification(
1005            "rejoin recovery only replaces matching SQL or KV recovery views".into(),
1006        ));
1007    }
1008    let restored = store.restore_checkpoint_v2().await?;
1009    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, RESTORE_INTENT_CONTENTS)?;
1010    install_restored_checkpoint(
1011        &identity,
1012        &restored,
1013        data_dir,
1014        Some(target_node_id),
1015        true,
1016        true,
1017        Some((marker_name, marker_contents)),
1018    )
1019}
1020
1021async fn restore_checkpoint_to_fresh_data_dir_with_target(
1022    store: ObjectArchiveStore,
1023    data_dir: &Path,
1024    target_node_id: Option<&str>,
1025    completion_marker: Option<(&str, &[u8])>,
1026) -> Result<CheckpointTip, DurabilityError> {
1027    let identity = store.checkpoint_identity()?.clone();
1028    store
1029        .load_checkpoint()
1030        .await?
1031        .ok_or(DurabilityError::MissingCheckpoint)?;
1032    prepare_fresh_restore_data_dir(data_dir, completion_marker.map(|(name, _)| name))?;
1033    let restored = store.restore_checkpoint_v2().await?;
1034    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, RESTORE_INTENT_CONTENTS)?;
1035    install_restored_checkpoint(
1036        &identity,
1037        &restored,
1038        data_dir,
1039        target_node_id,
1040        true,
1041        false,
1042        completion_marker,
1043    )
1044}
1045
1046fn install_restored_checkpoint(
1047    identity: &CheckpointIdentity,
1048    restored: &RestoredCheckpoint,
1049    data_dir: &Path,
1050    target_node_id: Option<&str>,
1051    remove_generic_intent: bool,
1052    replace_rebuildable: bool,
1053    completion_marker: Option<(&str, &[u8])>,
1054) -> Result<CheckpointTip, DurabilityError> {
1055    let tip = *restored.tip();
1056    let profile = snapshot_profile(identity.cluster_id())?;
1057    validate_restored_suffix(profile, restored.suffix())?;
1058    let staging = create_restore_staging_dir(data_dir)?;
1059    let result = (|| -> Result<(), DurabilityError> {
1060        if let Some(snapshot) = restored.snapshot() {
1061            install_profile_snapshot(identity, snapshot, &staging, target_node_id)?;
1062        }
1063
1064        if restored.snapshot().is_some() || !restored.suffix().is_empty() {
1065            let initial_configuration = restored
1066                .snapshot()
1067                .map(|snapshot| snapshot.anchor().configuration_state().clone())
1068                .unwrap_or_else(|| ConfigurationState::active(identity.config_id(), LogHash::ZERO));
1069            let log = FileLogStore::open_with_configuration(
1070                staging.join("consensus/log"),
1071                identity.cluster_id(),
1072                identity.epoch(),
1073                initial_configuration,
1074            )?;
1075            if let Some(snapshot) = restored.snapshot() {
1076                log.install_recovery_anchor(
1077                    snapshot.anchor(),
1078                    identity.recovery_generation(),
1079                    snapshot.anchor().configuration_state(),
1080                )?;
1081            }
1082            for batch in restored.suffix().chunks(FLUSH_BATCH_ENTRIES as usize) {
1083                log.append_batch(batch)?;
1084            }
1085            let installed_tip = log.logical_state()?.tip;
1086            if installed_tip.as_ref().map(|tip| (tip.index(), tip.hash()))
1087                != Some((tip.index(), tip.hash()))
1088            {
1089                return Err(DurabilityError::SnapshotVerification(
1090                    "installed qlog tip does not match checkpoint tip".into(),
1091                ));
1092            }
1093        }
1094        if replace_rebuildable {
1095            quarantine_rebuildable_view(data_dir, profile)?;
1096        }
1097        publish_restore_staging(&staging, data_dir, remove_generic_intent, completion_marker)
1098    })();
1099    if result.is_err() {
1100        let _ = fs::remove_dir_all(&staging);
1101    }
1102    result?;
1103    Ok(tip)
1104}
1105
1106fn validate_restored_suffix(
1107    profile: ExecutionProfile,
1108    suffix: &[LogEntry],
1109) -> Result<(), DurabilityError> {
1110    for entry in suffix {
1111        crate::validate_profile_entry_shape(profile, entry)
1112            .map_err(DurabilityError::SnapshotVerification)?;
1113    }
1114    Ok(())
1115}
1116
1117#[cfg(any(feature = "sql", feature = "kv", test))]
1118fn validate_materializer_tip(
1119    label: &str,
1120    actual: LogAnchor,
1121    checkpoint_root: LogAnchor,
1122) -> Result<(), DurabilityError> {
1123    if actual != checkpoint_root {
1124        return Err(DurabilityError::SnapshotVerification(format!(
1125            "{label} materializer tip {}/{} does not exactly match checkpoint root {}/{}",
1126            actual.index(),
1127            actual.hash().to_hex(),
1128            checkpoint_root.index(),
1129            checkpoint_root.hash().to_hex(),
1130        )));
1131    }
1132    Ok(())
1133}
1134
1135fn validate_local_qlog(
1136    data_dir: &Path,
1137    identity: &CheckpointIdentity,
1138    checkpoint_root: LogAnchor,
1139) -> Result<(), DurabilityError> {
1140    let path = data_dir.join("consensus/log");
1141    if !path_has_state(&path)? {
1142        return Err(DurabilityError::SnapshotVerification(
1143            "local qlog is missing or empty".into(),
1144        ));
1145    }
1146    let log = FileLogStore::open(
1147        path,
1148        identity.cluster_id(),
1149        identity.epoch(),
1150        identity.config_id(),
1151    )?;
1152    let state = log.logical_state()?;
1153    let tip = state
1154        .tip
1155        .ok_or_else(|| DurabilityError::SnapshotVerification("local qlog has no tip".into()))?;
1156    if tip != checkpoint_root {
1157        return Err(DurabilityError::SnapshotVerification(format!(
1158            "local qlog tip {}/{} does not exactly match checkpoint root {}/{}",
1159            tip.index(),
1160            tip.hash().to_hex(),
1161            checkpoint_root.index(),
1162            checkpoint_root.hash().to_hex(),
1163        )));
1164    }
1165    if checkpoint_root.index() == 0 {
1166        if checkpoint_root.hash() != LogHash::ZERO {
1167            return Err(DurabilityError::SnapshotVerification(
1168                "checkpoint genesis hash is not zero".into(),
1169            ));
1170        }
1171        return Ok(());
1172    }
1173    let included_hash = match state.anchor.as_ref() {
1174        Some(anchor) if anchor.compacted().index() == checkpoint_root.index() => {
1175            Some(anchor.compacted().hash())
1176        }
1177        Some(anchor) if anchor.compacted().index() > checkpoint_root.index() => {
1178            return Err(DurabilityError::SnapshotVerification(
1179                "local qlog compacted past checkpoint root without exact inclusion evidence".into(),
1180            ));
1181        }
1182        _ => log.read(checkpoint_root.index())?.map(|entry| entry.hash),
1183    };
1184    if included_hash != Some(checkpoint_root.hash()) {
1185        return Err(DurabilityError::SnapshotVerification(format!(
1186            "local qlog does not include checkpoint root {} with its exact hash",
1187            checkpoint_root.index(),
1188        )));
1189    }
1190    Ok(())
1191}
1192
1193fn install_profile_snapshot(
1194    identity: &CheckpointIdentity,
1195    snapshot: &rhiza_archive::RestoredCheckpointSnapshot,
1196    staging: &Path,
1197    target_node_id: Option<&str>,
1198) -> Result<(), DurabilityError> {
1199    match snapshot_profile(identity.cluster_id())? {
1200        ExecutionProfile::Sqlite => {
1201            #[cfg(feature = "sql")]
1202            {
1203                validate_anchor_fingerprint(
1204                    snapshot.anchor(),
1205                    sql_executor_fingerprint().map_err(|error| {
1206                        DurabilityError::SnapshotVerification(error.to_string())
1207                    })?,
1208                )?;
1209                let path = staging.join("sqlite/db.sqlite");
1210                let node_id = target_node_id.ok_or_else(|| {
1211                    DurabilityError::SnapshotVerification(
1212                        "SQLite QWAL snapshot restore requires a target node_id".into(),
1213                    )
1214                })?;
1215                restore_recovery_snapshot_file(path, snapshot.bytes(), snapshot.anchor(), node_id)
1216                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1217            }
1218            #[cfg(not(feature = "sql"))]
1219            Err(DurabilityError::SnapshotVerification(
1220                "sql execution profile is not compiled in".into(),
1221            ))
1222        }
1223        ExecutionProfile::Graph => {
1224            #[cfg(feature = "graph")]
1225            {
1226                let decoded = decode_graph_snapshot(snapshot.bytes())
1227                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1228                validate_decoded_snapshot_anchor(
1229                    snapshot.anchor(),
1230                    decoded.cluster_id(),
1231                    decoded.epoch(),
1232                    decoded.config_id(),
1233                    decoded.applied_index(),
1234                    decoded.applied_hash(),
1235                    decoded.materializer_fingerprint(),
1236                )?;
1237                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1238                restore_graph_snapshot_file(
1239                    staging.join("ladybug/graph.lbug"),
1240                    &decoded,
1241                    target_node_id,
1242                )
1243                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1244            }
1245            #[cfg(not(feature = "graph"))]
1246            Err(DurabilityError::SnapshotVerification(
1247                "graph recovery support is not compiled in".into(),
1248            ))
1249        }
1250        ExecutionProfile::Kv => {
1251            #[cfg(feature = "kv")]
1252            {
1253                let decoded = decode_kv_snapshot(snapshot.bytes())
1254                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1255                validate_decoded_snapshot_anchor(
1256                    snapshot.anchor(),
1257                    decoded.cluster_id(),
1258                    decoded.epoch(),
1259                    decoded.config_id(),
1260                    decoded.applied_index(),
1261                    decoded.applied_hash(),
1262                    decoded.materializer_fingerprint(),
1263                )?;
1264                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1265                restore_kv_snapshot_file(staging.join("kv/data.redb"), &decoded, target_node_id)
1266                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1267            }
1268            #[cfg(not(feature = "kv"))]
1269            Err(DurabilityError::SnapshotVerification(
1270                "KV recovery support is not compiled in".into(),
1271            ))
1272        }
1273    }
1274}
1275
1276fn snapshot_profile(cluster_id: &str) -> Result<ExecutionProfile, DurabilityError> {
1277    if matches!(cluster_id.strip_prefix("rhiza:graph:"), Some(logical) if !logical.is_empty()) {
1278        Ok(ExecutionProfile::Graph)
1279    } else if matches!(cluster_id.strip_prefix("rhiza:kv:"), Some(logical) if !logical.is_empty()) {
1280        Ok(ExecutionProfile::Kv)
1281    } else if matches!(cluster_id.strip_prefix("rhiza:sql:"), Some(logical) if !logical.is_empty())
1282    {
1283        Ok(ExecutionProfile::Sqlite)
1284    } else {
1285        Err(DurabilityError::SnapshotVerification(
1286            "snapshot checkpoint identity has no canonical execution profile prefix".into(),
1287        ))
1288    }
1289}
1290
1291fn validate_anchor_fingerprint(
1292    anchor: &RecoveryAnchor,
1293    expected: LogHash,
1294) -> Result<(), DurabilityError> {
1295    if anchor.executor_fingerprint() != Some(expected) {
1296        return Err(DurabilityError::SnapshotVerification(
1297            "snapshot executor fingerprint does not match this binary".into(),
1298        ));
1299    }
1300    Ok(())
1301}
1302
1303#[cfg(any(feature = "graph", feature = "kv"))]
1304fn validate_decoded_snapshot_anchor(
1305    anchor: &RecoveryAnchor,
1306    cluster_id: &str,
1307    epoch: u64,
1308    config_id: u64,
1309    applied_index: LogIndex,
1310    applied_hash: LogHash,
1311    materializer_fingerprint: LogHash,
1312) -> Result<(), DurabilityError> {
1313    validate_anchor_fingerprint(anchor, materializer_fingerprint)?;
1314    if anchor.cluster_id() != cluster_id
1315        || anchor.epoch() != epoch
1316        || anchor.config_id() != config_id
1317        || anchor.compacted().index() != applied_index
1318        || anchor.compacted().hash() != applied_hash
1319    {
1320        return Err(DurabilityError::SnapshotVerification(
1321            "decoded snapshot identity does not match its recovery anchor".into(),
1322        ));
1323    }
1324    Ok(())
1325}
1326
1327pub async fn restore_successor_checkpoint_to_fresh_data_dir(
1328    store: ObjectArchiveStore,
1329    config: &NodeConfig,
1330) -> Result<SuccessorRestorePreparation, DurabilityError> {
1331    let identity = store.checkpoint_identity()?;
1332    let loaded = store
1333        .load_checkpoint()
1334        .await?
1335        .ok_or(DurabilityError::MissingCheckpoint)?;
1336    let transition = loaded.manifest().successor_transition().ok_or_else(|| {
1337        DurabilityError::SnapshotVerification(
1338            "successor startup requires transition provenance".into(),
1339        )
1340    })?;
1341    let stop = LogAnchor::new(transition.stop_entry().index, transition.stop_entry().hash);
1342    let expected_stopped = ConfigurationState::stopped(
1343        transition.predecessor().config_id(),
1344        transition.successor().predecessor_config_digest(),
1345        stop,
1346    );
1347    let expected_initial = ConfigurationState::active(
1348        transition.predecessor().config_id(),
1349        transition.successor().predecessor_config_digest(),
1350    );
1351    if identity.cluster_id() != config.cluster_id()
1352        || identity.epoch() != config.epoch()
1353        || identity.config_id() != transition.successor().config_id()
1354        || identity.recovery_generation() != config.recovery_generation()
1355        || transition.successor().cluster_id() != config.cluster_id()
1356        || transition.successor().members() != config.membership().members()
1357        || transition.successor().digest() != config.membership().digest()
1358        || config.configuration_state() != &expected_stopped
1359        || config.log_initial_configuration() != &expected_initial
1360    {
1361        return Err(DurabilityError::SnapshotVerification(
1362            "successor checkpoint does not match target node configuration".into(),
1363        ));
1364    }
1365    let receipt = serde_json::to_vec(&SuccessorRestoreIdentity {
1366        version: 1,
1367        cluster_id: config.cluster_id(),
1368        epoch: config.epoch(),
1369        target_config_id: identity.config_id(),
1370        recovery_generation: config.recovery_generation(),
1371        node_id: config.node_id(),
1372        membership_digest: config.membership().digest().to_hex(),
1373        predecessor_config_id: transition.predecessor().config_id(),
1374        stop_index: transition.stop_entry().index,
1375        stop_hash: transition.stop_entry().hash.to_hex(),
1376        checkpoint_index: loaded.manifest().tip().index(),
1377        checkpoint_hash: loaded.manifest().tip().hash().to_hex(),
1378    })
1379    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1380    let (lock, state) = prepare_successor_restore_root(config.data_dir(), &receipt)?;
1381    if state == SuccessorRestoreRootState::Complete {
1382        return Ok(SuccessorRestorePreparation {
1383            tip: *loaded.manifest().tip(),
1384            data_dir: config.data_dir().clone(),
1385            identity: receipt,
1386            requires_recorder_install: false,
1387            _lock: lock,
1388        });
1389    }
1390
1391    let restored = store.restore_checkpoint_v2().await?;
1392    if restored.tip() != loaded.manifest().tip() {
1393        return Err(DurabilityError::SnapshotVerification(
1394            "successor checkpoint changed during restore".into(),
1395        ));
1396    }
1397    if state == SuccessorRestoreRootState::Fresh {
1398        publish_restore_marker(config.data_dir(), SUCCESSOR_RESTORE_INTENT_FILE, &receipt)?;
1399    }
1400    install_restored_checkpoint(
1401        identity,
1402        &restored,
1403        config.data_dir(),
1404        Some(config.node_id()),
1405        false,
1406        false,
1407        None,
1408    )?;
1409    Ok(SuccessorRestorePreparation {
1410        tip: *restored.tip(),
1411        data_dir: config.data_dir().clone(),
1412        identity: receipt,
1413        requires_recorder_install: true,
1414        _lock: lock,
1415    })
1416}
1417
1418fn create_restore_staging_dir(data_dir: &Path) -> Result<PathBuf, DurabilityError> {
1419    fs::create_dir_all(data_dir)?;
1420    for _ in 0..128 {
1421        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1422        let staging = data_dir.join(format!(
1423            "{RESTORE_STAGING_PREFIX}{}-{sequence}",
1424            process::id()
1425        ));
1426        match fs::create_dir(&staging) {
1427            Ok(()) => return Ok(staging),
1428            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1429            Err(error) => return Err(error.into()),
1430        }
1431    }
1432    Err(std::io::Error::new(
1433        std::io::ErrorKind::AlreadyExists,
1434        "could not allocate restore staging directory",
1435    )
1436    .into())
1437}
1438
1439fn publish_restore_staging(
1440    staging: &Path,
1441    data_dir: &Path,
1442    remove_generic_intent: bool,
1443    completion_marker: Option<(&str, &[u8])>,
1444) -> Result<(), DurabilityError> {
1445    sync_directory(staging)?;
1446    for name in ["sqlite", "ladybug", "kv", "consensus"] {
1447        let source = staging.join(name);
1448        if source.exists() {
1449            fs::rename(&source, data_dir.join(name))?;
1450        }
1451    }
1452    fs::remove_dir(staging)?;
1453    sync_directory(data_dir)?;
1454    if let Some((marker_name, marker_contents)) = completion_marker {
1455        publish_restore_marker(data_dir, marker_name, marker_contents)?;
1456    }
1457    if remove_generic_intent {
1458        fs::remove_file(data_dir.join(RESTORE_INTENT_FILE))?;
1459    }
1460    sync_directory(data_dir)
1461}
1462
1463fn quarantine_rebuildable_view(
1464    data_dir: &Path,
1465    profile: ExecutionProfile,
1466) -> Result<Option<PathBuf>, DurabilityError> {
1467    let materializer = match profile {
1468        ExecutionProfile::Sqlite => "sqlite",
1469        ExecutionProfile::Kv => "kv",
1470        ExecutionProfile::Graph => {
1471            return Err(DurabilityError::SnapshotVerification(
1472                "graph recovery view replacement is outside this recovery path".into(),
1473            ))
1474        }
1475    };
1476    let names = [materializer, "consensus"];
1477    if !names
1478        .iter()
1479        .any(|name| fs::symlink_metadata(data_dir.join(name)).is_ok())
1480    {
1481        return Ok(None);
1482    }
1483    for _ in 0..128 {
1484        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1485        let quarantine = data_dir.join(format!(
1486            ".rebuildable-quarantine-{}-{sequence}",
1487            process::id()
1488        ));
1489        match fs::create_dir(&quarantine) {
1490            Ok(()) => {
1491                for name in names {
1492                    let source = data_dir.join(name);
1493                    if fs::symlink_metadata(&source).is_ok() {
1494                        fs::rename(source, quarantine.join(name))?;
1495                    }
1496                }
1497                sync_directory(&quarantine)?;
1498                sync_directory(data_dir)?;
1499                return Ok(Some(quarantine));
1500            }
1501            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1502            Err(error) => return Err(error.into()),
1503        }
1504    }
1505    Err(std::io::Error::new(
1506        std::io::ErrorKind::AlreadyExists,
1507        "could not allocate rebuildable recovery quarantine",
1508    )
1509    .into())
1510}
1511
1512fn publish_restore_marker(
1513    data_dir: &Path,
1514    marker_name: &str,
1515    contents: &[u8],
1516) -> Result<(), DurabilityError> {
1517    validate_restore_marker_name(marker_name)?;
1518    fs::create_dir_all(data_dir)?;
1519    let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1520    let temporary = data_dir.join(format!(
1521        "{RESTORE_MARKER_TMP_PREFIX}{}-{sequence}",
1522        process::id()
1523    ));
1524    let mut file = fs::OpenOptions::new()
1525        .write(true)
1526        .create_new(true)
1527        .open(&temporary)?;
1528    file.write_all(contents)?;
1529    file.sync_all()?;
1530    fs::rename(temporary, data_dir.join(marker_name))?;
1531    sync_directory(data_dir)
1532}
1533
1534fn validate_restore_marker_name(marker_name: &str) -> Result<(), DurabilityError> {
1535    if marker_name.is_empty()
1536        || matches!(marker_name, "." | "..")
1537        || marker_name.contains(std::path::MAIN_SEPARATOR)
1538    {
1539        return Err(DurabilityError::SnapshotVerification(
1540            "restore marker name must be one local file name".into(),
1541        ));
1542    }
1543    Ok(())
1544}
1545
1546#[derive(Clone, Copy, Eq, PartialEq)]
1547enum SuccessorRestoreRootState {
1548    Fresh,
1549    Intent,
1550    Complete,
1551}
1552
1553fn prepare_successor_restore_root(
1554    data_dir: &Path,
1555    expected_identity: &[u8],
1556) -> Result<(fs::File, SuccessorRestoreRootState), DurabilityError> {
1557    fs::create_dir_all(data_dir)?;
1558    let lock = fs::OpenOptions::new()
1559        .read(true)
1560        .write(true)
1561        .create(true)
1562        .truncate(false)
1563        .open(data_dir.join(SUCCESSOR_RESTORE_LOCK_FILE))?;
1564    lock.lock()?;
1565
1566    let intent = data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
1567    let complete = data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
1568    let state = match (fs::read(&intent), fs::read(&complete)) {
1569        (Ok(actual), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1570            if actual != expected_identity {
1571                return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1572            }
1573            SuccessorRestoreRootState::Intent
1574        }
1575        (Err(error), Ok(actual)) if error.kind() == std::io::ErrorKind::NotFound => {
1576            if !completed_successor_identity_matches(&actual, expected_identity) {
1577                return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1578            }
1579            SuccessorRestoreRootState::Complete
1580        }
1581        (Err(intent_error), Err(complete_error))
1582            if intent_error.kind() == std::io::ErrorKind::NotFound
1583                && complete_error.kind() == std::io::ErrorKind::NotFound =>
1584        {
1585            SuccessorRestoreRootState::Fresh
1586        }
1587        _ => return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf())),
1588    };
1589
1590    for entry in fs::read_dir(data_dir)? {
1591        let entry = entry?;
1592        let name = entry.file_name();
1593        let name = name.to_string_lossy();
1594        let common =
1595            name == SUCCESSOR_RESTORE_LOCK_FILE || name.starts_with(RESTORE_MARKER_TMP_PREFIX);
1596        let allowed = match state {
1597            SuccessorRestoreRootState::Fresh => common,
1598            SuccessorRestoreRootState::Intent => {
1599                common
1600                    || name == SUCCESSOR_RESTORE_INTENT_FILE
1601                    || name == "sqlite"
1602                    || name == "ladybug"
1603                    || name == "kv"
1604                    || name == "consensus"
1605                    || name == "recorder"
1606                    || name.starts_with(RESTORE_STAGING_PREFIX)
1607            }
1608            SuccessorRestoreRootState::Complete => {
1609                common
1610                    || name == SUCCESSOR_RESTORE_COMPLETE_FILE
1611                    || name == ".node.lock"
1612                    || name == "sqlite"
1613                    || name == "ladybug"
1614                    || name == "kv"
1615                    || name == "consensus"
1616                    || name == "recorder"
1617            }
1618        };
1619        if !allowed {
1620            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1621        }
1622    }
1623
1624    for entry in fs::read_dir(data_dir)? {
1625        let entry = entry?;
1626        if entry
1627            .file_name()
1628            .to_string_lossy()
1629            .starts_with(RESTORE_MARKER_TMP_PREFIX)
1630        {
1631            fs::remove_file(entry.path())?;
1632        }
1633    }
1634    if state == SuccessorRestoreRootState::Intent {
1635        for name in ["sqlite", "ladybug", "kv", "consensus", "recorder"] {
1636            let path = data_dir.join(name);
1637            if path.exists() {
1638                fs::remove_dir_all(path)?;
1639            }
1640        }
1641        for entry in fs::read_dir(data_dir)? {
1642            let entry = entry?;
1643            if entry
1644                .file_name()
1645                .to_string_lossy()
1646                .starts_with(RESTORE_STAGING_PREFIX)
1647            {
1648                fs::remove_dir_all(entry.path())?;
1649            }
1650        }
1651        sync_directory(data_dir)?;
1652    }
1653    Ok((lock, state))
1654}
1655
1656fn completed_successor_identity_matches(actual: &[u8], expected: &[u8]) -> bool {
1657    let (Ok(mut actual), Ok(mut expected)) = (
1658        serde_json::from_slice::<serde_json::Value>(actual),
1659        serde_json::from_slice::<serde_json::Value>(expected),
1660    ) else {
1661        return false;
1662    };
1663    let Some(actual_index) = actual["checkpoint_index"].as_u64() else {
1664        return false;
1665    };
1666    let Some(expected_index) = expected["checkpoint_index"].as_u64() else {
1667        return false;
1668    };
1669    let (Some(actual_hash), Some(expected_hash)) = (
1670        actual["checkpoint_hash"].as_str(),
1671        expected["checkpoint_hash"].as_str(),
1672    ) else {
1673        return false;
1674    };
1675    if LogHash::from_hex(actual_hash).is_none() || LogHash::from_hex(expected_hash).is_none() {
1676        return false;
1677    }
1678    if actual_index > expected_index
1679        || (actual_index == expected_index && actual_hash != expected_hash)
1680    {
1681        return false;
1682    }
1683    for receipt in [&mut actual, &mut expected] {
1684        let Some(receipt) = receipt.as_object_mut() else {
1685            return false;
1686        };
1687        receipt.remove("checkpoint_index");
1688        receipt.remove("checkpoint_hash");
1689    }
1690    actual == expected
1691}
1692
1693fn sync_directory(path: &Path) -> Result<(), DurabilityError> {
1694    fs::File::open(path)?.sync_all()?;
1695    Ok(())
1696}
1697
1698fn validate_local_batch(
1699    entries: &[rhiza_core::LogEntry],
1700    start: LogIndex,
1701    end: LogIndex,
1702    durable_tip: CheckpointTip,
1703) -> Result<(), DurabilityError> {
1704    let expected_len =
1705        usize::try_from(end - start + 1).map_err(|_| DurabilityError::LocalLogGap {
1706            expected: start,
1707            actual: entries.first().map(|entry| entry.index),
1708        })?;
1709    if entries.len() != expected_len {
1710        let actual = entries
1711            .iter()
1712            .zip(start..=end)
1713            .find_map(|(entry, expected)| (entry.index != expected).then_some(entry.index));
1714        return Err(DurabilityError::LocalLogGap {
1715            expected: start + entries.len() as u64,
1716            actual,
1717        });
1718    }
1719
1720    let mut expected_hash = durable_tip.hash();
1721    for (expected_index, entry) in (start..).zip(entries) {
1722        if entry.index != expected_index {
1723            return Err(DurabilityError::LocalLogGap {
1724                expected: expected_index,
1725                actual: Some(entry.index),
1726            });
1727        }
1728        if entry.prev_hash != expected_hash || entry.recompute_hash() != entry.hash {
1729            return Err(DurabilityError::LocalLogConflict { index: entry.index });
1730        }
1731        expected_hash = entry.hash;
1732    }
1733    Ok(())
1734}
1735
1736fn prepare_fresh_restore_data_dir(
1737    data_dir: &Path,
1738    completion_marker_name: Option<&str>,
1739) -> Result<(), DurabilityError> {
1740    if !path_has_state(data_dir)? {
1741        return Ok(());
1742    }
1743
1744    let intent = data_dir.join(RESTORE_INTENT_FILE);
1745    if !intent.exists() {
1746        let entries = fs::read_dir(data_dir)?.collect::<Result<Vec<_>, _>>()?;
1747        if entries.iter().all(|entry| {
1748            entry
1749                .file_name()
1750                .to_string_lossy()
1751                .starts_with(RESTORE_MARKER_TMP_PREFIX)
1752        }) {
1753            for entry in entries {
1754                fs::remove_file(entry.path())?;
1755            }
1756            sync_directory(data_dir)?;
1757            return Ok(());
1758        }
1759    }
1760    if !matches!(fs::read(&intent), Ok(contents) if contents == RESTORE_INTENT_CONTENTS) {
1761        return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1762    }
1763
1764    for entry in fs::read_dir(data_dir)? {
1765        let entry = entry?;
1766        let name = entry.file_name();
1767        let name = name.to_string_lossy();
1768        let owned = name == RESTORE_INTENT_FILE
1769            || completion_marker_name.is_some_and(|marker| name == marker)
1770            || name == "sqlite"
1771            || name == "ladybug"
1772            || name == "kv"
1773            || name == "consensus"
1774            || name.starts_with(RESTORE_MARKER_TMP_PREFIX)
1775            || name.starts_with(RESTORE_STAGING_PREFIX);
1776        if !owned {
1777            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1778        }
1779    }
1780
1781    for name in ["sqlite", "ladybug", "kv", "consensus"] {
1782        let path = data_dir.join(name);
1783        if path.exists() {
1784            fs::remove_dir_all(path)?;
1785        }
1786    }
1787    for entry in fs::read_dir(data_dir)? {
1788        let entry = entry?;
1789        let name = entry.file_name();
1790        let name = name.to_string_lossy();
1791        if name.starts_with(RESTORE_STAGING_PREFIX) {
1792            fs::remove_dir_all(entry.path())?;
1793        } else if name.starts_with(RESTORE_MARKER_TMP_PREFIX) {
1794            fs::remove_file(entry.path())?;
1795        }
1796    }
1797    fs::remove_file(intent)?;
1798    sync_directory(data_dir)?;
1799    Ok(())
1800}
1801
1802fn path_has_state(path: &Path) -> Result<bool, std::io::Error> {
1803    let metadata = match fs::symlink_metadata(path) {
1804        Ok(metadata) => metadata,
1805        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1806        Err(error) => return Err(error),
1807    };
1808    if !metadata.is_dir() {
1809        return Ok(true);
1810    }
1811    fs::read_dir(path)?
1812        .next()
1813        .transpose()
1814        .map(|entry| entry.is_some())
1815}
1816
1817#[cfg(test)]
1818mod tests {
1819    use super::{
1820        completed_successor_identity_matches, mark_durable_state, observe_durable_tip,
1821        snapshot_profile, validate_local_qlog, validate_materializer_tip, validate_restored_suffix,
1822        CheckpointTip, CoordinatorState, DurabilityError, DurabilityHealth, ExecutionProfile,
1823        LogHash, PendingLag, RESTORE_INTENT_CONTENTS, RESTORE_INTENT_FILE,
1824    };
1825    #[cfg(feature = "kv")]
1826    use crate::{KvCommandV1, NodeConfig, NodeRuntime};
1827    use rhiza_archive::CheckpointIdentity;
1828    #[cfg(feature = "kv")]
1829    use rhiza_archive::ObjectArchiveStore;
1830    use rhiza_core::{EntryType, LogEntry};
1831    use rhiza_log::{FileLogStore, LogStore};
1832    #[cfg(feature = "kv")]
1833    use rhiza_obj_store::{ObjStore, ObjStoreConfig};
1834    #[cfg(feature = "kv")]
1835    use rhiza_quepaxa::ThreeNodeConsensus;
1836    use std::sync::{Arc, Barrier, Mutex};
1837
1838    #[test]
1839    fn snapshot_profile_requires_a_canonical_effective_cluster_identity() {
1840        assert_eq!(
1841            snapshot_profile("rhiza:sql:cluster-a").unwrap(),
1842            ExecutionProfile::Sqlite
1843        );
1844        assert_eq!(
1845            snapshot_profile("rhiza:graph:cluster-a").unwrap(),
1846            ExecutionProfile::Graph
1847        );
1848        assert_eq!(
1849            snapshot_profile("rhiza:kv:cluster-a").unwrap(),
1850            ExecutionProfile::Kv
1851        );
1852        assert!(matches!(
1853            snapshot_profile("cluster-a"),
1854            Err(DurabilityError::SnapshotVerification(_))
1855        ));
1856        assert!(snapshot_profile("rhiza:graph:").is_err());
1857    }
1858
1859    #[test]
1860    fn sqlite_restore_suffix_rejects_legacy_commands_during_preflight() {
1861        let payload = b"put\tlegacy\tkey\tvalue".to_vec();
1862        let entry = LogEntry {
1863            cluster_id: "rhiza:sql:cluster-a".into(),
1864            epoch: 1,
1865            config_id: 1,
1866            index: 1,
1867            entry_type: EntryType::Command,
1868            prev_hash: LogHash::ZERO,
1869            hash: LogEntry::calculate_hash(
1870                "rhiza:sql:cluster-a",
1871                1,
1872                1,
1873                1,
1874                EntryType::Command,
1875                LogHash::ZERO,
1876                &payload,
1877            ),
1878            payload,
1879        };
1880
1881        assert!(matches!(
1882            validate_restored_suffix(ExecutionProfile::Sqlite, &[entry]),
1883            Err(DurabilityError::SnapshotVerification(message)) if message.contains("QWAL")
1884        ));
1885    }
1886
1887    #[test]
1888    fn materializer_tip_requires_exact_checkpoint_root() {
1889        let checkpoint = rhiza_core::LogAnchor::new(4, LogHash::digest(&[b"checkpoint"]));
1890        let ahead = rhiza_core::LogAnchor::new(5, LogHash::digest(&[b"ahead"]));
1891        let behind = rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"behind"]));
1892        let conflicting = rhiza_core::LogAnchor::new(4, LogHash::digest(&[b"conflicting"]));
1893
1894        assert!(validate_materializer_tip("test", ahead, checkpoint).is_err());
1895        assert!(validate_materializer_tip("test", behind, checkpoint).is_err());
1896        assert!(validate_materializer_tip("test", conflicting, checkpoint).is_err());
1897        assert!(validate_materializer_tip("test", checkpoint, checkpoint).is_ok());
1898    }
1899
1900    #[test]
1901    fn local_qlog_rejects_ahead_tip_even_when_checkpoint_entry_is_retained() {
1902        let root = tempfile::tempdir().unwrap();
1903        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
1904        let log = FileLogStore::open(
1905            root.path().join("consensus/log"),
1906            identity.cluster_id(),
1907            1,
1908            1,
1909        )
1910        .unwrap();
1911        let entry = |index, previous| {
1912            let hash = LogEntry::calculate_hash(
1913                identity.cluster_id(),
1914                index,
1915                1,
1916                1,
1917                EntryType::Noop,
1918                previous,
1919                &[],
1920            );
1921            LogEntry {
1922                cluster_id: identity.cluster_id().into(),
1923                epoch: 1,
1924                config_id: 1,
1925                index,
1926                entry_type: EntryType::Noop,
1927                payload: Vec::new(),
1928                prev_hash: previous,
1929                hash,
1930            }
1931        };
1932        let first = entry(1, LogHash::ZERO);
1933        let second = entry(2, first.hash);
1934        log.append_batch(&[first.clone(), second]).unwrap();
1935
1936        assert!(validate_local_qlog(
1937            root.path(),
1938            &identity,
1939            rhiza_core::LogAnchor::new(first.index, first.hash),
1940        )
1941        .is_err());
1942        assert!(validate_local_qlog(
1943            root.path(),
1944            &identity,
1945            rhiza_core::LogAnchor::new(2, LogHash::digest(&[b"conflicting"])),
1946        )
1947        .is_err());
1948        assert!(validate_local_qlog(
1949            root.path(),
1950            &identity,
1951            rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"ahead checkpoint"])),
1952        )
1953        .is_err());
1954    }
1955
1956    #[test]
1957    fn restore_intent_remains_until_completion_marker_is_durable_and_retryable() {
1958        let root = tempfile::tempdir().unwrap();
1959        let data_dir = root.path().join("data");
1960        std::fs::create_dir_all(&data_dir).unwrap();
1961        std::fs::write(data_dir.join(RESTORE_INTENT_FILE), RESTORE_INTENT_CONTENTS).unwrap();
1962        std::fs::create_dir(data_dir.join("identity.json")).unwrap();
1963        let staging = super::create_restore_staging_dir(&data_dir).unwrap();
1964
1965        assert!(super::publish_restore_staging(
1966            &staging,
1967            &data_dir,
1968            true,
1969            Some(("identity.json", b"identity-v1")),
1970        )
1971        .is_err());
1972        assert_eq!(
1973            std::fs::read(data_dir.join(RESTORE_INTENT_FILE)).unwrap(),
1974            RESTORE_INTENT_CONTENTS
1975        );
1976
1977        std::fs::remove_dir(data_dir.join("identity.json")).unwrap();
1978        let retry_staging = super::create_restore_staging_dir(&data_dir).unwrap();
1979        super::publish_restore_staging(
1980            &retry_staging,
1981            &data_dir,
1982            true,
1983            Some(("identity.json", b"identity-v1")),
1984        )
1985        .unwrap();
1986        assert_eq!(
1987            std::fs::read(data_dir.join("identity.json")).unwrap(),
1988            b"identity-v1"
1989        );
1990        assert!(!data_dir.join(RESTORE_INTENT_FILE).exists());
1991    }
1992
1993    #[cfg(feature = "kv")]
1994    #[tokio::test]
1995    async fn kv_compacted_rejoin_restores_missing_or_corrupt_views_without_touching_recorder() {
1996        let root = tempfile::tempdir().unwrap();
1997        let identity = CheckpointIdentity::new("rhiza:kv:cluster-a", 1, 1, 1);
1998        let archive = ObjectArchiveStore::new_checkpoint_for_single_process(
1999            ObjStore::new(ObjStoreConfig::Local {
2000                root: root.path().join("archive"),
2001            })
2002            .unwrap(),
2003            identity.clone(),
2004        );
2005        archive.initialize_checkpoint().await.unwrap();
2006        let source_dir = root.path().join("source");
2007        let config = NodeConfig::new_embedded(
2008            "cluster-a",
2009            "node-1",
2010            source_dir,
2011            1,
2012            1,
2013            ["node-1", "node-2", "node-3"],
2014        )
2015        .unwrap()
2016        .with_execution_profile(ExecutionProfile::Kv)
2017        .unwrap()
2018        .with_recovery_generation(1)
2019        .unwrap();
2020        let consensus = Arc::new(
2021            ThreeNodeConsensus::from_recovered_tip(
2022                "rhiza:kv:cluster-a",
2023                "node-1",
2024                1,
2025                1,
2026                [
2027                    root.path().join("recorders/node-1"),
2028                    root.path().join("recorders/node-2"),
2029                    root.path().join("recorders/node-3"),
2030                ],
2031                1,
2032                LogHash::ZERO,
2033            )
2034            .unwrap(),
2035        );
2036        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
2037        let coordinator = CheckpointCoordinator::open(archive.clone(), DurabilityMode::Sync)
2038            .await
2039            .unwrap();
2040        let committed = runtime
2041            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"value".to_vec()).unwrap())
2042            .unwrap();
2043        coordinator.note_committed(committed.applied_index());
2044        coordinator
2045            .flush_runtime(&runtime, committed.applied_index())
2046            .await
2047            .unwrap();
2048        let checkpoint_root = runtime.checkpoint_compact(&coordinator).await.unwrap();
2049
2050        let target = root.path().join("target");
2051        restore_checkpoint_to_fresh_data_dir_for_node(archive.clone(), &target, "node-1")
2052            .await
2053            .unwrap();
2054        validate_local_recovery_view(
2055            &target,
2056            &identity,
2057            "node-1",
2058            ExecutionProfile::Kv,
2059            *checkpoint_root.compacted(),
2060        )
2061        .unwrap();
2062        std::fs::create_dir_all(target.join("recorder")).unwrap();
2063        std::fs::write(target.join("recorder/sentinel"), b"keep-me").unwrap();
2064
2065        std::fs::remove_dir_all(target.join("consensus")).unwrap();
2066        assert!(validate_local_recovery_view(
2067            &target,
2068            &identity,
2069            "node-1",
2070            ExecutionProfile::Kv,
2071            *checkpoint_root.compacted(),
2072        )
2073        .is_err());
2074        restore_checkpoint_for_rejoin_preserving_recorder(
2075            archive.clone(),
2076            &target,
2077            "node-1",
2078            ExecutionProfile::Kv,
2079            "identity.json",
2080            b"identity-v1",
2081        )
2082        .await
2083        .unwrap();
2084
2085        std::fs::write(target.join("kv/data.redb"), b"corrupt").unwrap();
2086        assert!(validate_local_recovery_view(
2087            &target,
2088            &identity,
2089            "node-1",
2090            ExecutionProfile::Kv,
2091            *checkpoint_root.compacted(),
2092        )
2093        .is_err());
2094        restore_checkpoint_for_rejoin_preserving_recorder(
2095            archive,
2096            &target,
2097            "node-1",
2098            ExecutionProfile::Kv,
2099            "identity.json",
2100            b"identity-v1",
2101        )
2102        .await
2103        .unwrap();
2104        assert_eq!(
2105            std::fs::read(target.join("recorder/sentinel")).unwrap(),
2106            b"keep-me"
2107        );
2108    }
2109
2110    fn successor_receipt(index: u64, hash_byte: char, generation: u64) -> Vec<u8> {
2111        serde_json::to_vec(&serde_json::json!({
2112            "version": 1,
2113            "cluster_id": "cluster-a",
2114            "epoch": 1,
2115            "target_config_id": 2,
2116            "recovery_generation": generation,
2117            "node_id": "node-1",
2118            "membership_digest": "digest",
2119            "predecessor_config_id": 1,
2120            "stop_index": 4,
2121            "stop_hash": "stop",
2122            "checkpoint_index": index,
2123            "checkpoint_hash": hash_byte.to_string().repeat(64),
2124        }))
2125        .unwrap()
2126    }
2127
2128    #[test]
2129    fn completed_successor_receipt_allows_only_forward_checkpoint_progress() {
2130        let expected = successor_receipt(8, '8', 1);
2131
2132        assert!(completed_successor_identity_matches(
2133            &successor_receipt(4, '4', 1),
2134            &expected
2135        ));
2136        assert!(!completed_successor_identity_matches(
2137            &successor_receipt(8, '9', 1),
2138            &expected
2139        ));
2140        assert!(!completed_successor_identity_matches(
2141            &successor_receipt(9, '9', 1),
2142            &expected
2143        ));
2144        assert!(!completed_successor_identity_matches(
2145            &successor_receipt(4, '4', 2),
2146            &expected
2147        ));
2148        let mut malformed =
2149            serde_json::from_slice::<serde_json::Value>(&successor_receipt(4, '4', 1)).unwrap();
2150        malformed["checkpoint_hash"] = serde_json::json!(7);
2151        assert!(!completed_successor_identity_matches(
2152            &serde_json::to_vec(&malformed).unwrap(),
2153            &expected
2154        ));
2155    }
2156
2157    #[test]
2158    fn concurrent_flush_completion_cannot_regress_the_durable_tip() {
2159        let newer = CheckpointTip::new(8, LogHash::digest(&[b"newer"]));
2160        let older = CheckpointTip::new(4, LogHash::digest(&[b"older"]));
2161        let mut state = CoordinatorState {
2162            durable_tip: newer,
2163            committed_index: 8,
2164            pending_lag: None,
2165            health: DurabilityHealth::Available,
2166        };
2167
2168        mark_durable_state(&mut state, older);
2169
2170        assert_eq!(state.durable_tip, newer);
2171    }
2172
2173    #[test]
2174    fn checkpoint_observation_rejects_same_index_hash_conflict_without_mutation() {
2175        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
2176        let conflicting = CheckpointTip::new(8, LogHash::digest(&[b"conflicting"]));
2177        let state = Mutex::new(CoordinatorState {
2178            durable_tip: current,
2179            committed_index: 8,
2180            pending_lag: None,
2181            health: DurabilityHealth::Available,
2182        });
2183
2184        assert!(matches!(
2185            observe_durable_tip(&state, conflicting),
2186            Err(DurabilityError::SnapshotVerification(_))
2187        ));
2188        assert_eq!(state.lock().unwrap().durable_tip, current);
2189    }
2190
2191    #[test]
2192    fn checkpoint_observation_rejects_remote_rollback_without_mutation() {
2193        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
2194        let older = CheckpointTip::new(7, LogHash::digest(&[b"older"]));
2195        let state = Mutex::new(CoordinatorState {
2196            durable_tip: current,
2197            committed_index: 8,
2198            pending_lag: None,
2199            health: DurabilityHealth::Available,
2200        });
2201
2202        assert!(matches!(
2203            observe_durable_tip(&state, older),
2204            Err(DurabilityError::SnapshotVerification(_))
2205        ));
2206        assert_eq!(state.lock().unwrap().durable_tip, current);
2207    }
2208
2209    #[test]
2210    fn concurrent_conflicting_checkpoint_observations_accept_exactly_one_tip() {
2211        let state = Arc::new(Mutex::new(CoordinatorState {
2212            durable_tip: CheckpointTip::new(0, LogHash::ZERO),
2213            committed_index: 0,
2214            pending_lag: None,
2215            health: DurabilityHealth::Available,
2216        }));
2217        let start = Arc::new(Barrier::new(3));
2218        let results = [b"first".as_slice(), b"second".as_slice()]
2219            .into_iter()
2220            .map(|label| {
2221                let state = Arc::clone(&state);
2222                let start = Arc::clone(&start);
2223                std::thread::spawn(move || {
2224                    let tip = CheckpointTip::new(1, LogHash::digest(&[label]));
2225                    start.wait();
2226                    (tip, observe_durable_tip(&state, tip))
2227                })
2228            })
2229            .collect::<Vec<_>>();
2230        start.wait();
2231        let results = results
2232            .into_iter()
2233            .map(|thread| thread.join().unwrap())
2234            .collect::<Vec<_>>();
2235
2236        assert_eq!(
2237            results.iter().filter(|(_, result)| result.is_ok()).count(),
2238            1
2239        );
2240        assert_eq!(
2241            results.iter().filter(|(_, result)| result.is_err()).count(),
2242            1
2243        );
2244        let accepted = results
2245            .iter()
2246            .find_map(|(tip, result)| result.is_ok().then_some(*tip))
2247            .unwrap();
2248        assert_eq!(state.lock().unwrap().durable_tip, accepted);
2249    }
2250
2251    #[test]
2252    fn durable_progress_clears_lag_only_after_reaching_the_committed_index() {
2253        let mut state = CoordinatorState {
2254            durable_tip: CheckpointTip::new(2, LogHash::digest(&[b"two"])),
2255            committed_index: 4,
2256            pending_lag: Some(PendingLag::Recovered),
2257            health: DurabilityHealth::Unavailable,
2258        };
2259        let partial = CheckpointTip::new(3, LogHash::digest(&[b"three"]));
2260        mark_durable_state(&mut state, partial);
2261        assert!(state.pending_lag.is_some());
2262        assert_eq!(state.health, DurabilityHealth::Unavailable);
2263
2264        let complete = CheckpointTip::new(4, LogHash::digest(&[b"four"]));
2265        mark_durable_state(&mut state, complete);
2266
2267        assert_eq!(state.durable_tip, complete);
2268        assert!(state.pending_lag.is_none());
2269        assert_eq!(state.health, DurabilityHealth::Available);
2270    }
2271}