Skip to main content

rhiza_node/
durability.rs

1#[cfg(unix)]
2use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
3use std::{
4    error::Error,
5    fmt, fs,
6    future::Future,
7    io::{Read, Write},
8    path::{Path, PathBuf},
9    process,
10    sync::{
11        atomic::{AtomicU64, Ordering},
12        Arc, Mutex, MutexGuard,
13    },
14    time::{Duration, Instant},
15};
16
17use rhiza_archive::{
18    CheckpointIdentity, CheckpointPublisher, CheckpointPublisherOptions, CheckpointTip,
19    ObjectArchiveStore, RestoredCheckpoint,
20};
21#[cfg(any(feature = "graph", feature = "kv"))]
22use rhiza_core::SnapshotIdentity;
23use rhiza_core::{
24    ConfigurationState, ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
25};
26#[cfg(feature = "graph")]
27use rhiza_graph::{
28    decode_snapshot as decode_graph_snapshot, encode_snapshot as encode_graph_snapshot,
29    restore_snapshot_file as restore_graph_snapshot_file,
30};
31#[cfg(feature = "kv")]
32use rhiza_kv::{
33    decode_snapshot as decode_kv_snapshot, encode_snapshot as encode_kv_snapshot,
34    restore_snapshot_file as restore_kv_snapshot_file, RedbStateMachine,
35};
36use rhiza_log::{FileLogStore, IndexRange, LogStore};
37#[cfg(feature = "sql")]
38use rhiza_sql::{restore_recovery_snapshot_file, sql_executor_fingerprint};
39use serde::{Deserialize, Serialize};
40
41use crate::{Materializer, NodeConfig, NodeRuntime};
42
43const FLUSH_BATCH_ENTRIES: LogIndex = 32;
44const RESTORE_INTENT_FILE: &str = ".rhiza-restore-v2.json";
45const LEGACY_RESTORE_INTENT_FILE: &str = ".rhiza-restore-v1";
46const RESTORE_STAGING_PREFIX: &str = ".restore-stage-";
47const RESTORE_MARKER_TMP_PREFIX: &str = ".restore-marker-tmp-";
48const SUCCESSOR_RESTORE_LOCK_FILE: &str = ".successor-restore.lock";
49const SUCCESSOR_RESTORE_INTENT_FILE: &str = ".successor-restore.intent";
50const SUCCESSOR_RESTORE_COMPLETE_FILE: &str = ".successor-restore.complete";
51const REPAIR_ARTIFACT_OWNER_FILE: &str = ".rhiza-recovery-owner.json";
52pub const LOCAL_CHECKPOINT_IDENTITY_FILE: &str = ".rhiza-checkpoint-identity-v2.json";
53static RESTORE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
54
55#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(deny_unknown_fields)]
57struct RestoreIntentIdentity {
58    version: u32,
59    cluster_id: String,
60    node_id: String,
61    execution_profile: ExecutionProfile,
62    epoch: u64,
63    config_id: u64,
64    recovery_generation: u64,
65    checkpoint_index: LogIndex,
66    checkpoint_hash: String,
67}
68
69#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
70#[serde(tag = "kind", content = "identity", rename_all = "snake_case")]
71enum RecoveryArtifactIdentity {
72    Successor(SuccessorRestoreReceipt),
73    Restore(RestoreIntentIdentity),
74}
75
76#[derive(Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
77#[serde(rename_all = "snake_case")]
78enum RepairArtifactRole {
79    Staging,
80    Quarantine,
81}
82
83#[derive(Deserialize, Serialize)]
84#[serde(deny_unknown_fields)]
85struct RepairArtifactOwnership {
86    format_version: u32,
87    role: RepairArtifactRole,
88    name: String,
89    identity: RecoveryArtifactIdentity,
90}
91
92#[derive(Serialize)]
93struct SuccessorRestoreIdentity<'a> {
94    version: u32,
95    cluster_id: &'a str,
96    epoch: u64,
97    target_config_id: u64,
98    recovery_generation: u64,
99    node_id: &'a str,
100    membership_digest: String,
101    predecessor_config_id: u64,
102    stop_index: LogIndex,
103    stop_hash: String,
104    checkpoint_index: LogIndex,
105    checkpoint_hash: String,
106}
107
108#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(deny_unknown_fields)]
110struct SuccessorRestoreReceipt {
111    version: u32,
112    cluster_id: String,
113    epoch: u64,
114    target_config_id: u64,
115    recovery_generation: u64,
116    node_id: String,
117    membership_digest: String,
118    predecessor_config_id: u64,
119    stop_index: LogIndex,
120    stop_hash: String,
121    checkpoint_index: LogIndex,
122    checkpoint_hash: String,
123}
124
125pub struct SuccessorRestorePreparation {
126    tip: CheckpointTip,
127    data_dir: PathBuf,
128    identity: Vec<u8>,
129    requires_recorder_install: bool,
130    _lock: fs::File,
131}
132
133impl SuccessorRestorePreparation {
134    pub const fn tip(&self) -> CheckpointTip {
135        self.tip
136    }
137
138    pub const fn requires_recorder_install(&self) -> bool {
139        self.requires_recorder_install
140    }
141
142    pub fn complete(mut self) -> Result<CheckpointTip, DurabilityError> {
143        if !self.requires_recorder_install {
144            return Ok(self.tip);
145        }
146        let intent = self.data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
147        let actual = read_regular_successor_control_file(&intent)?.ok_or_else(|| {
148            DurabilityError::SnapshotVerification("successor restore intent is missing".into())
149        })?;
150        if parse_successor_restore_receipt(&actual).is_none()
151            || parse_successor_restore_receipt(&self.identity).is_none()
152            || actual != self.identity
153        {
154            return Err(DurabilityError::SnapshotVerification(
155                "successor restore intent changed before completion".into(),
156            ));
157        }
158        let complete = self.data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
159        match fs::symlink_metadata(&complete) {
160            Ok(_) => {
161                return Err(DurabilityError::SnapshotVerification(
162                    "successor restore completion target already exists".into(),
163                ));
164            }
165            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
166            Err(error) => return Err(error.into()),
167        }
168        fs::rename(intent, complete)?;
169        sync_directory(&self.data_dir)?;
170        self.requires_recorder_install = false;
171        Ok(self.tip)
172    }
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub enum DurabilityMode {
177    Sync,
178    Bounded { max_lag: Duration },
179    Periodic { interval: Duration },
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub enum DurabilityHealth {
184    Available,
185    Unavailable,
186}
187
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189pub enum CheckpointRestoreState {
190    None,
191    IdentityBoundV2,
192    LegacyV1,
193}
194
195impl DurabilityMode {
196    pub fn validate(&self) -> Result<(), DurabilityError> {
197        match self {
198            Self::Sync => Ok(()),
199            Self::Bounded { max_lag } if max_lag.is_zero() => {
200                Err(DurabilityError::InvalidDuration { mode: "bounded" })
201            }
202            Self::Periodic { interval } if interval.is_zero() => {
203                Err(DurabilityError::InvalidDuration { mode: "periodic" })
204            }
205            Self::Bounded { .. } | Self::Periodic { .. } => Ok(()),
206        }
207    }
208}
209
210#[derive(Debug)]
211pub enum DurabilityError {
212    InvalidDuration {
213        mode: &'static str,
214    },
215    MissingCheckpoint,
216    Unavailable,
217    LagExceeded {
218        committed_index: LogIndex,
219        durable_index: LogIndex,
220        max_lag: Duration,
221    },
222    ArchiveAheadOfLocal {
223        durable_index: LogIndex,
224        local_index: LogIndex,
225    },
226    SnapshotRequired {
227        anchor: Box<RecoveryAnchor>,
228    },
229    LocalLogGap {
230        expected: LogIndex,
231        actual: Option<LogIndex>,
232    },
233    LocalLogConflict {
234        index: LogIndex,
235    },
236    SnapshotVerification(String),
237    PreconditionFailed,
238    DataDirNotFresh(PathBuf),
239    Archive(rhiza_archive::Error),
240    Log(rhiza_log::Error),
241    Io(std::io::Error),
242}
243
244impl fmt::Display for DurabilityError {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::InvalidDuration { mode } => {
248                write!(f, "{mode} durability duration must be non-zero")
249            }
250            Self::MissingCheckpoint => write!(f, "checkpoint manifest is missing"),
251            Self::Unavailable => write!(f, "sync durability is unavailable"),
252            Self::LagExceeded {
253                committed_index,
254                durable_index,
255                max_lag,
256            } => write!(
257                f,
258                "checkpoint lag exceeded {max_lag:?}: committed index {committed_index}, durable index {durable_index}"
259            ),
260            Self::ArchiveAheadOfLocal {
261                durable_index,
262                local_index,
263            } => write!(
264                f,
265                "checkpoint tip {durable_index} is ahead of local qlog tip {local_index}"
266            ),
267            Self::SnapshotRequired { anchor } => write!(
268                f,
269                "snapshot restore required at qlog anchor {} before checkpoint flush",
270                anchor.compacted().index()
271            ),
272            Self::LocalLogGap { expected, actual } => {
273                write!(f, "local qlog gap: expected index {expected}, got {actual:?}")
274            }
275            Self::LocalLogConflict { index } => {
276                write!(f, "local qlog hash chain conflicts at index {index}")
277            }
278            Self::SnapshotVerification(message) => {
279                write!(f, "checkpoint snapshot verification failed: {message}")
280            }
281            Self::PreconditionFailed => write!(f, "checkpoint precondition failed"),
282            Self::DataDirNotFresh(path) => write!(
283                f,
284                "restore data directory contains existing state: {}",
285                path.display()
286            ),
287            Self::Archive(error) => error.fmt(f),
288            Self::Log(error) => error.fmt(f),
289            Self::Io(error) => error.fmt(f),
290        }
291    }
292}
293
294impl Error for DurabilityError {
295    fn source(&self) -> Option<&(dyn Error + 'static)> {
296        match self {
297            Self::Archive(error) => Some(error),
298            Self::Log(error) => Some(error),
299            Self::Io(error) => Some(error),
300            _ => None,
301        }
302    }
303}
304
305impl From<rhiza_archive::Error> for DurabilityError {
306    fn from(error: rhiza_archive::Error) -> Self {
307        Self::Archive(error)
308    }
309}
310
311impl From<rhiza_log::Error> for DurabilityError {
312    fn from(error: rhiza_log::Error) -> Self {
313        Self::Log(error)
314    }
315}
316
317impl From<std::io::Error> for DurabilityError {
318    fn from(error: std::io::Error) -> Self {
319        Self::Io(error)
320    }
321}
322
323#[derive(Debug)]
324enum PendingLag {
325    New(Instant),
326    Recovered,
327}
328
329#[derive(Debug)]
330struct CoordinatorState {
331    durable_tip: CheckpointTip,
332    committed_index: LogIndex,
333    pending_lag: Option<PendingLag>,
334    health: DurabilityHealth,
335}
336
337pub struct CheckpointCoordinator {
338    store: ObjectArchiveStore,
339    publisher: CheckpointPublisher,
340    mode: DurabilityMode,
341    state: Mutex<CoordinatorState>,
342    publication_attempts: AtomicU64,
343}
344
345struct RuntimeCheckpointSnapshot {
346    anchor: RecoveryAnchor,
347    archive_bytes: Vec<u8>,
348}
349
350#[cfg(any(feature = "graph", feature = "kv"))]
351struct EngineSnapshotIdentity<'a> {
352    cluster_id: &'a str,
353    epoch: u64,
354    config_id: u64,
355    applied_index: LogIndex,
356    applied_hash: LogHash,
357}
358
359impl CheckpointCoordinator {
360    pub async fn open(
361        store: ObjectArchiveStore,
362        mode: DurabilityMode,
363    ) -> Result<Self, DurabilityError> {
364        Self::open_with_holder(store, mode, "anonymous-node").await
365    }
366
367    pub async fn open_with_holder(
368        store: ObjectArchiveStore,
369        mode: DurabilityMode,
370        holder: impl AsRef<str>,
371    ) -> Result<Self, DurabilityError> {
372        Self::open_with_holder_and_options(
373            store,
374            mode,
375            holder,
376            CheckpointPublisherOptions::default(),
377        )
378        .await
379    }
380
381    pub async fn open_with_holder_and_options(
382        store: ObjectArchiveStore,
383        mode: DurabilityMode,
384        holder: impl AsRef<str>,
385        publisher_options: CheckpointPublisherOptions,
386    ) -> Result<Self, DurabilityError> {
387        mode.validate()?;
388        store
389            .load_checkpoint()
390            .await?
391            .ok_or(DurabilityError::MissingCheckpoint)?;
392        let identity = store.checkpoint_identity()?;
393        let holder = format!(
394            "checkpoint-coordinator-{}-{}-{}-{}-{}",
395            identity.cluster_id(),
396            identity.epoch(),
397            identity.config_id(),
398            identity.recovery_generation(),
399            holder.as_ref()
400        );
401        let publisher = store
402            .open_checkpoint_publisher(holder, publisher_options)
403            .await?;
404        let loaded = publisher.cached_checkpoint().await;
405        let durable_tip = *loaded.manifest().tip();
406        let restored = store.restore_checkpoint_v2().await?;
407        let restored_tip = *restored.tip();
408        if restored_tip != durable_tip {
409            return Err(DurabilityError::Archive(
410                rhiza_archive::Error::InvalidCheckpoint(
411                    "restored entries changed while verifying the loaded manifest".into(),
412                ),
413            ));
414        }
415        Ok(Self {
416            store,
417            publisher,
418            mode,
419            state: Mutex::new(CoordinatorState {
420                durable_tip,
421                committed_index: durable_tip.index(),
422                pending_lag: None,
423                health: DurabilityHealth::Available,
424            }),
425            publication_attempts: AtomicU64::new(0),
426        })
427    }
428
429    pub const fn mode(&self) -> &DurabilityMode {
430        &self.mode
431    }
432
433    pub fn durable_tip(&self) -> CheckpointTip {
434        self.lock_state().durable_tip
435    }
436
437    pub async fn refresh_durable_tip(&self) -> Result<CheckpointTip, DurabilityError> {
438        let loaded = self.publisher.observe_checkpoint().await?;
439        let accepted = self.publisher.cache_observed_checkpoint(loaded).await?;
440        observe_durable_tip(&self.state, *accepted.manifest().tip())
441    }
442
443    pub fn health(&self) -> DurabilityHealth {
444        self.lock_state().health
445    }
446
447    #[doc(hidden)]
448    pub fn checkpoint_publication_attempts(&self) -> u64 {
449        self.publication_attempts.load(Ordering::Relaxed)
450    }
451
452    pub fn note_committed(&self, index: LogIndex) {
453        let mut state = self.lock_state();
454        if index <= state.committed_index {
455            return;
456        }
457        state.committed_index = index;
458        if index > state.durable_tip.index() && state.pending_lag.is_none() {
459            state.pending_lag = Some(PendingLag::New(Instant::now()));
460        }
461    }
462
463    pub fn note_recovered_committed(&self, index: LogIndex) {
464        let mut state = self.lock_state();
465        state.committed_index = state.committed_index.max(index);
466        if state.committed_index > state.durable_tip.index() {
467            state.pending_lag = Some(PendingLag::Recovered);
468        }
469    }
470
471    pub fn write_allowed(&self) -> Result<(), DurabilityError> {
472        if matches!(self.mode, DurabilityMode::Sync)
473            && self.health() == DurabilityHealth::Unavailable
474        {
475            return Err(DurabilityError::Unavailable);
476        }
477        let DurabilityMode::Bounded { max_lag } = self.mode else {
478            return Ok(());
479        };
480        let state = self.lock_state();
481        let exceeded = state.committed_index > state.durable_tip.index()
482            && match state.pending_lag {
483                Some(PendingLag::Recovered) => true,
484                Some(PendingLag::New(pending)) => pending.elapsed() >= max_lag,
485                None => false,
486            };
487        if exceeded {
488            return Err(DurabilityError::LagExceeded {
489                committed_index: state.committed_index,
490                durable_index: state.durable_tip.index(),
491                max_lag,
492            });
493        }
494        Ok(())
495    }
496
497    pub async fn flush_runtime(
498        &self,
499        runtime: &NodeRuntime,
500        target_index: LogIndex,
501    ) -> Result<CheckpointTip, DurabilityError> {
502        let result = self.flush_runtime_inner(runtime, target_index).await;
503        if result.is_err() {
504            self.mark_unavailable();
505        }
506        result
507    }
508
509    async fn flush_runtime_inner(
510        &self,
511        runtime: &NodeRuntime,
512        target_index: LogIndex,
513    ) -> Result<CheckpointTip, DurabilityError> {
514        let log_state = runtime.log_store().logical_state()?;
515        let local_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
516        let mut durable_tip = self.durable_tip();
517        if durable_tip.index() > local_index {
518            return Err(DurabilityError::ArchiveAheadOfLocal {
519                durable_index: durable_tip.index(),
520                local_index,
521            });
522        }
523        let target_index = target_index.min(local_index);
524        if target_index <= durable_tip.index() {
525            return Ok(durable_tip);
526        }
527        if let Some(anchor) = log_state.anchor {
528            if durable_tip.index() < anchor.compacted().index() {
529                return Err(DurabilityError::SnapshotRequired {
530                    anchor: Box::new(anchor),
531                });
532            }
533        }
534
535        let mut next =
536            durable_tip
537                .index()
538                .checked_add(1)
539                .ok_or_else(|| DurabilityError::LocalLogGap {
540                    expected: durable_tip.index(),
541                    actual: None,
542                })?;
543        while next <= target_index {
544            let end = next
545                .saturating_add(FLUSH_BATCH_ENTRIES - 1)
546                .min(target_index);
547            let entries = runtime
548                .log_store()
549                .read_range(IndexRange::new(next, end)?)?;
550            validate_local_batch(&entries, next, end, durable_tip)?;
551            self.publication_attempts.fetch_add(1, Ordering::Relaxed);
552            let published = self.publisher.publish_committed(&entries).await?;
553            durable_tip = *published.manifest().tip();
554            self.mark_durable(durable_tip);
555            if durable_tip.index() >= target_index {
556                break;
557            }
558            next =
559                durable_tip
560                    .index()
561                    .checked_add(1)
562                    .ok_or_else(|| DurabilityError::LocalLogGap {
563                        expected: durable_tip.index(),
564                        actual: None,
565                    })?;
566        }
567        Ok(durable_tip)
568    }
569
570    pub async fn checkpoint_compact(
571        &self,
572        runtime: &NodeRuntime,
573    ) -> Result<RecoveryAnchor, DurabilityError> {
574        self.checkpoint_compact_inner(runtime, None).await
575    }
576
577    pub async fn checkpoint_compact_fenced(
578        &self,
579        runtime: &NodeRuntime,
580        expected_config_id: u64,
581        expected_recovery_generation: u64,
582        expected_root: LogAnchor,
583    ) -> Result<RecoveryAnchor, DurabilityError> {
584        self.checkpoint_compact_inner(
585            runtime,
586            Some((
587                expected_config_id,
588                expected_recovery_generation,
589                expected_root,
590            )),
591        )
592        .await
593    }
594
595    async fn checkpoint_compact_inner(
596        &self,
597        runtime: &NodeRuntime,
598        fence: Option<(u64, u64, LogAnchor)>,
599    ) -> Result<RecoveryAnchor, DurabilityError> {
600        let (target, snapshot, _fence) = {
601            let _commit = runtime.commit.lock().map_err(|_| {
602                DurabilityError::SnapshotVerification("commit mutex is poisoned".into())
603            })?;
604            runtime
605                .ensure_ready()
606                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
607            let configuration = runtime
608                .configuration_state()
609                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
610            if !configuration.is_active() && configuration.stop().is_none() {
611                return Err(DurabilityError::SnapshotVerification(
612                    "runtime configuration is not compactable".into(),
613                ));
614            }
615            if let Some((config_id, generation, root)) = fence {
616                let actual_config_id = configuration.config_id();
617                let actual_generation = runtime.config.recovery_generation();
618                let actual_root = runtime.log_root_unlocked().ok();
619                if actual_config_id != config_id
620                    || actual_generation != generation
621                    || actual_root != Some(root)
622                {
623                    eprintln!(
624                        "checkpoint fence mismatch: config {actual_config_id}/{config_id}, \
625                         generation {actual_generation}/{generation}, root {actual_root:?}/{root:?}"
626                    );
627                    return Err(DurabilityError::PreconditionFailed);
628                }
629            }
630            if runtime
631                .checkpointing
632                .swap(true, std::sync::atomic::Ordering::AcqRel)
633            {
634                return Err(DurabilityError::SnapshotVerification(
635                    "checkpoint transition is already in progress".into(),
636                ));
637            }
638            let fence = CheckpointFence(&runtime.checkpointing);
639            let (target, target_hash) = runtime
640                .ensure_materialized_tip()
641                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
642            let snapshot =
643                create_runtime_checkpoint_snapshot(runtime, target, target_hash, &configuration)?;
644            (target, snapshot, fence)
645        };
646        self.flush_runtime(runtime, target).await?;
647        let anchor = snapshot.anchor.clone();
648        self.publisher
649            .publish_checkpoint_snapshot(anchor.clone(), &snapshot.archive_bytes)
650            .await?;
651        let restored = self.store.restore_checkpoint_v2().await?;
652        let published = restored.snapshot().ok_or_else(|| {
653            DurabilityError::SnapshotVerification("published V2 root has no snapshot".into())
654        })?;
655        if published.anchor() != &anchor || published.bytes() != snapshot.archive_bytes {
656            return Err(DurabilityError::SnapshotVerification(
657                "read-back anchor or snapshot bytes differ".into(),
658            ));
659        }
660        runtime.log_store.compact_prefix(&anchor)?;
661        runtime
662            .compact_embedded_log_before(anchor.compacted().index())
663            .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
664        self.mark_durable(CheckpointTip::new(
665            anchor.compacted().index(),
666            anchor.compacted().hash(),
667        ));
668        Ok(anchor)
669    }
670
671    pub async fn run_background<F>(
672        self: Arc<Self>,
673        runtime: Arc<NodeRuntime>,
674        shutdown: F,
675    ) -> Result<(), DurabilityError>
676    where
677        F: Future<Output = ()> + Send,
678    {
679        let cadence = match self.mode {
680            DurabilityMode::Sync => return Ok(()),
681            DurabilityMode::Bounded { max_lag } => {
682                let half = max_lag / 2;
683                half.min(Duration::from_secs(1))
684            }
685            DurabilityMode::Periodic { interval } => interval,
686        };
687        tokio::pin!(shutdown);
688        loop {
689            tokio::select! {
690                () = &mut shutdown => return Ok(()),
691                () = tokio::time::sleep(cadence) => {
692                    match self.flush_runtime(&runtime, LogIndex::MAX).await {
693                        Ok(_) | Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {}
694                        Err(error) => return Err(error),
695                    }
696                }
697            }
698        }
699    }
700
701    fn mark_durable(&self, durable_tip: CheckpointTip) {
702        let mut state = self.lock_state();
703        mark_durable_state(&mut state, durable_tip);
704    }
705
706    fn mark_unavailable(&self) {
707        let mut state = self.lock_state();
708        if state.committed_index > state.durable_tip.index() {
709            state.health = DurabilityHealth::Unavailable;
710        }
711    }
712
713    fn lock_state(&self) -> MutexGuard<'_, CoordinatorState> {
714        self.state
715            .lock()
716            .unwrap_or_else(|poisoned| poisoned.into_inner())
717    }
718}
719
720fn create_runtime_checkpoint_snapshot(
721    runtime: &NodeRuntime,
722    target: LogIndex,
723    target_hash: LogHash,
724    configuration: &ConfigurationState,
725) -> Result<RuntimeCheckpointSnapshot, DurabilityError> {
726    #[cfg(not(any(feature = "graph", feature = "kv")))]
727    let _ = target_hash;
728    let materializer = runtime
729        .lock_materializer()
730        .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
731    match &*materializer {
732        #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
733        Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
734        #[cfg(feature = "sql")]
735        Materializer::Sql(state) => {
736            let snapshot = state
737                .create_recovery_snapshot(runtime.config().recovery_generation())
738                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
739            if snapshot.anchor().compacted().index() != target
740                || snapshot.anchor().configuration_state() != configuration
741            {
742                return Err(DurabilityError::SnapshotVerification(
743                    "SQLite snapshot does not match the compacted runtime state".into(),
744                ));
745            }
746            Ok(RuntimeCheckpointSnapshot {
747                anchor: snapshot.anchor().clone(),
748                archive_bytes: snapshot.db_bytes().to_vec(),
749            })
750        }
751        #[cfg(feature = "graph")]
752        Materializer::Graph(state) => {
753            let snapshot = state
754                .create_snapshot(target)
755                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
756            validate_engine_snapshot_identity(
757                runtime,
758                configuration,
759                EngineSnapshotIdentity {
760                    cluster_id: snapshot.cluster_id(),
761                    epoch: snapshot.epoch(),
762                    config_id: snapshot.config_id(),
763                    applied_index: snapshot.applied_index(),
764                    applied_hash: snapshot.applied_hash(),
765                },
766                target,
767                target_hash,
768            )?;
769            let archive_bytes = encode_graph_snapshot(&snapshot)
770                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
771            Ok(RuntimeCheckpointSnapshot {
772                anchor: engine_recovery_anchor(
773                    runtime,
774                    configuration,
775                    target,
776                    snapshot.applied_hash(),
777                    snapshot.materializer_fingerprint(),
778                    &archive_bytes,
779                )?,
780                archive_bytes,
781            })
782        }
783        #[cfg(feature = "kv")]
784        Materializer::Kv(state) => {
785            let snapshot = state
786                .create_snapshot(target)
787                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
788            validate_engine_snapshot_identity(
789                runtime,
790                configuration,
791                EngineSnapshotIdentity {
792                    cluster_id: snapshot.cluster_id(),
793                    epoch: snapshot.epoch(),
794                    config_id: snapshot.config_id(),
795                    applied_index: snapshot.applied_index(),
796                    applied_hash: snapshot.applied_hash(),
797                },
798                target,
799                target_hash,
800            )?;
801            let archive_bytes = encode_kv_snapshot(&snapshot)
802                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
803            Ok(RuntimeCheckpointSnapshot {
804                anchor: engine_recovery_anchor(
805                    runtime,
806                    configuration,
807                    target,
808                    snapshot.applied_hash(),
809                    snapshot.materializer_fingerprint(),
810                    &archive_bytes,
811                )?,
812                archive_bytes,
813            })
814        }
815    }
816}
817
818#[cfg(any(feature = "graph", feature = "kv"))]
819fn validate_engine_snapshot_identity(
820    runtime: &NodeRuntime,
821    configuration: &ConfigurationState,
822    snapshot: EngineSnapshotIdentity<'_>,
823    expected_index: LogIndex,
824    expected_hash: LogHash,
825) -> Result<(), DurabilityError> {
826    let config = runtime.config();
827    if snapshot.cluster_id != config.cluster_id()
828        || snapshot.epoch != config.epoch()
829        || snapshot.config_id != configuration.config_id()
830        || snapshot.applied_index == 0
831        || snapshot.applied_index != expected_index
832        || snapshot.applied_hash != expected_hash
833    {
834        return Err(DurabilityError::SnapshotVerification(
835            "engine snapshot identity does not match the compacted runtime state".into(),
836        ));
837    }
838    Ok(())
839}
840
841#[cfg(any(feature = "graph", feature = "kv"))]
842fn engine_recovery_anchor(
843    runtime: &NodeRuntime,
844    configuration: &ConfigurationState,
845    applied_index: LogIndex,
846    applied_hash: LogHash,
847    materializer_fingerprint: LogHash,
848    archive_bytes: &[u8],
849) -> Result<RecoveryAnchor, DurabilityError> {
850    let size_bytes = u64::try_from(archive_bytes.len()).map_err(|_| {
851        DurabilityError::SnapshotVerification("snapshot envelope size exceeds u64".into())
852    })?;
853    Ok(RecoveryAnchor::new_with_configuration(
854        runtime.config().cluster_id(),
855        runtime.config().epoch(),
856        configuration.clone(),
857        runtime.config().recovery_generation(),
858        LogAnchor::new(applied_index, applied_hash),
859        SnapshotIdentity::new(
860            format!("snapshot-{applied_index:020}"),
861            LogHash::digest(&[archive_bytes]),
862            size_bytes,
863        )
864        .with_executor_fingerprint(materializer_fingerprint),
865    ))
866}
867
868fn mark_durable_state(state: &mut CoordinatorState, durable_tip: CheckpointTip) {
869    if durable_tip.index() > state.durable_tip.index() {
870        state.durable_tip = durable_tip;
871    }
872    if state.committed_index <= state.durable_tip.index() {
873        state.pending_lag = None;
874        state.health = DurabilityHealth::Available;
875    }
876}
877
878fn observe_durable_tip(
879    state: &Mutex<CoordinatorState>,
880    observed: CheckpointTip,
881) -> Result<CheckpointTip, DurabilityError> {
882    let mut state = state
883        .lock()
884        .unwrap_or_else(|poisoned| poisoned.into_inner());
885    let current = state.durable_tip;
886    if observed.index() < current.index() {
887        return Err(DurabilityError::SnapshotVerification(format!(
888            "checkpoint tip rolled back from index {} to {}",
889            current.index(),
890            observed.index()
891        )));
892    }
893    if observed.index() == current.index() && observed.hash() != current.hash() {
894        return Err(DurabilityError::SnapshotVerification(format!(
895            "checkpoint tip hash changed at index {}",
896            observed.index()
897        )));
898    }
899    mark_durable_state(&mut state, observed);
900    Ok(state.durable_tip)
901}
902
903struct CheckpointFence<'a>(&'a std::sync::atomic::AtomicBool);
904
905impl Drop for CheckpointFence<'_> {
906    fn drop(&mut self) {
907        self.0.store(false, std::sync::atomic::Ordering::Release);
908    }
909}
910
911pub async fn restore_checkpoint_to_fresh_data_dir(
912    store: ObjectArchiveStore,
913    data_dir: impl AsRef<Path>,
914) -> Result<CheckpointTip, DurabilityError> {
915    restore_checkpoint_to_fresh_data_dir_with_target(store, data_dir.as_ref(), None, None, false)
916        .await
917}
918
919pub async fn restore_checkpoint_to_fresh_data_dir_for_node(
920    store: ObjectArchiveStore,
921    data_dir: impl AsRef<Path>,
922    target_node_id: &str,
923) -> Result<CheckpointTip, DurabilityError> {
924    if target_node_id.is_empty() {
925        return Err(DurabilityError::SnapshotVerification(
926            "target node_id is empty".into(),
927        ));
928    }
929    restore_checkpoint_to_fresh_data_dir_with_target(
930        store,
931        data_dir.as_ref(),
932        Some(target_node_id),
933        None,
934        false,
935    )
936    .await
937}
938
939pub async fn restore_checkpoint_to_fresh_data_dir_for_node_with_marker(
940    store: ObjectArchiveStore,
941    data_dir: impl AsRef<Path>,
942    target_node_id: &str,
943    marker_name: &str,
944    marker_contents: &[u8],
945    resume_legacy_v1_intent: bool,
946) -> Result<CheckpointTip, DurabilityError> {
947    if target_node_id.is_empty() {
948        return Err(DurabilityError::SnapshotVerification(
949            "target node_id is empty".into(),
950        ));
951    }
952    validate_restore_marker_name(marker_name)?;
953    restore_checkpoint_to_fresh_data_dir_with_target(
954        store,
955        data_dir.as_ref(),
956        Some(target_node_id),
957        Some((marker_name, marker_contents)),
958        resume_legacy_v1_intent,
959    )
960    .await
961}
962
963fn restore_intent_identity(
964    identity: &CheckpointIdentity,
965    node_id: &str,
966    execution_profile: ExecutionProfile,
967    checkpoint_root: LogAnchor,
968) -> RestoreIntentIdentity {
969    RestoreIntentIdentity {
970        version: 2,
971        cluster_id: identity.cluster_id().to_owned(),
972        node_id: node_id.to_owned(),
973        execution_profile,
974        epoch: identity.epoch(),
975        config_id: identity.config_id(),
976        recovery_generation: identity.recovery_generation(),
977        checkpoint_index: checkpoint_root.index(),
978        checkpoint_hash: checkpoint_root.hash().to_hex(),
979    }
980}
981
982fn encode_restore_intent(
983    identity: &CheckpointIdentity,
984    node_id: &str,
985    execution_profile: ExecutionProfile,
986    checkpoint_root: LogAnchor,
987) -> Result<Vec<u8>, DurabilityError> {
988    serde_json::to_vec(&restore_intent_identity(
989        identity,
990        node_id,
991        execution_profile,
992        checkpoint_root,
993    ))
994    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
995}
996
997fn parse_restore_intent_identity(bytes: &[u8]) -> Option<RestoreIntentIdentity> {
998    let intent = serde_json::from_slice::<RestoreIntentIdentity>(bytes).ok()?;
999    (intent.version == 2
1000        && !intent.cluster_id.is_empty()
1001        && !intent.node_id.is_empty()
1002        && LogHash::from_hex(&intent.checkpoint_hash).is_some())
1003    .then_some(intent)
1004}
1005
1006pub fn checkpoint_restore_in_progress(
1007    data_dir: impl AsRef<Path>,
1008    identity: &CheckpointIdentity,
1009    node_id: &str,
1010    execution_profile: ExecutionProfile,
1011    checkpoint_root: LogAnchor,
1012    legacy_v1_authorized_by_exact_marker: bool,
1013) -> Result<CheckpointRestoreState, DurabilityError> {
1014    let data_dir = data_dir.as_ref();
1015    let legacy = data_dir.join(LEGACY_RESTORE_INTENT_FILE);
1016    let intent = data_dir.join(RESTORE_INTENT_FILE);
1017    let legacy_metadata = match fs::symlink_metadata(&legacy) {
1018        Ok(metadata) => Some(metadata),
1019        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1020        Err(error) => return Err(error.into()),
1021    };
1022    let metadata = match fs::symlink_metadata(&intent) {
1023        Ok(metadata) => Some(metadata),
1024        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1025        Err(error) => return Err(error.into()),
1026    };
1027    if legacy_metadata.is_some() && metadata.is_some() {
1028        return Err(DurabilityError::SnapshotVerification(
1029            "both legacy and identity-bound checkpoint restore intents exist".into(),
1030        ));
1031    }
1032    if let Some(metadata) = legacy_metadata {
1033        if metadata.file_type().is_symlink()
1034            || !metadata.is_file()
1035            || read_bounded_regular_file(&legacy, 4096)?.as_deref()
1036                != Some(b"rhiza restore in progress\n")
1037        {
1038            return Err(DurabilityError::SnapshotVerification(
1039                "legacy local checkpoint restore intent is invalid".into(),
1040            ));
1041        }
1042        if !legacy_v1_authorized_by_exact_marker {
1043            return Err(DurabilityError::SnapshotVerification(
1044                "legacy local checkpoint restore intent requires an exact node-bound v2 identity marker".into(),
1045            ));
1046        }
1047        return Ok(CheckpointRestoreState::LegacyV1);
1048    }
1049    let Some(metadata) = metadata else {
1050        return Ok(CheckpointRestoreState::None);
1051    };
1052    if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 4096 {
1053        return Err(DurabilityError::SnapshotVerification(
1054            "local checkpoint restore intent is invalid".into(),
1055        ));
1056    }
1057    let bytes = read_bounded_regular_file(&intent, 4096)?.ok_or_else(|| {
1058        DurabilityError::SnapshotVerification("local checkpoint restore intent disappeared".into())
1059    })?;
1060    let actual: RestoreIntentIdentity = serde_json::from_slice(&bytes).map_err(|_| {
1061        DurabilityError::SnapshotVerification("local checkpoint restore intent is invalid".into())
1062    })?;
1063    let expected = restore_intent_identity(identity, node_id, execution_profile, checkpoint_root);
1064    if actual.version != expected.version
1065        || actual.cluster_id != expected.cluster_id
1066        || actual.node_id != expected.node_id
1067        || actual.execution_profile != expected.execution_profile
1068        || actual.epoch != expected.epoch
1069        || actual.config_id != expected.config_id
1070        || actual.recovery_generation != expected.recovery_generation
1071        || actual.checkpoint_index != expected.checkpoint_index
1072        || actual.checkpoint_hash != expected.checkpoint_hash
1073    {
1074        return Err(DurabilityError::SnapshotVerification(
1075            "local checkpoint restore intent does not exactly match this node and checkpoint"
1076                .into(),
1077        ));
1078    }
1079    Ok(CheckpointRestoreState::IdentityBoundV2)
1080}
1081
1082pub fn validate_local_recovery_view(
1083    data_dir: impl AsRef<Path>,
1084    identity: &CheckpointIdentity,
1085    target_node_id: &str,
1086    execution_profile: ExecutionProfile,
1087    checkpoint_root: LogAnchor,
1088) -> Result<(), DurabilityError> {
1089    let data_dir = data_dir.as_ref();
1090    if checkpoint_restore_in_progress(
1091        data_dir,
1092        identity,
1093        target_node_id,
1094        execution_profile,
1095        checkpoint_root,
1096        false,
1097    )? != CheckpointRestoreState::None
1098    {
1099        return Err(DurabilityError::SnapshotVerification(
1100            "local recovery view has an incomplete checkpoint restore intent".into(),
1101        ));
1102    }
1103    #[cfg(not(feature = "kv"))]
1104    let _ = target_node_id;
1105    if snapshot_profile(identity.cluster_id())? != execution_profile {
1106        return Err(DurabilityError::SnapshotVerification(
1107            "local recovery view profile does not match checkpoint identity".into(),
1108        ));
1109    }
1110    let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1111        identity,
1112        target_node_id,
1113        execution_profile,
1114        checkpoint_root,
1115    ));
1116    cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
1117    let validate_qlog = validate_local_materializer_identity(
1118        data_dir,
1119        identity,
1120        target_node_id,
1121        execution_profile,
1122    )?;
1123
1124    if validate_qlog {
1125        // NodeRuntime reconciles valid materializer/qlog crash skew. This preflight only opens the
1126        // expected local identity and fences startup to an exactly included authoritative root.
1127        validate_local_qlog(data_dir, identity, checkpoint_root)?;
1128    }
1129    Ok(())
1130}
1131
1132fn validate_local_materializer_identity(
1133    data_dir: &Path,
1134    identity: &CheckpointIdentity,
1135    target_node_id: &str,
1136    execution_profile: ExecutionProfile,
1137) -> Result<bool, DurabilityError> {
1138    Ok(match execution_profile {
1139        ExecutionProfile::Sqlite => {
1140            #[cfg(feature = "sql")]
1141            {
1142                let path = data_dir.join("sqlite/db.sqlite");
1143                if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
1144                    return Err(DurabilityError::SnapshotVerification(
1145                        "SQL materializer is missing or is not a regular file".into(),
1146                    ));
1147                }
1148                let _state = rhiza_sql::SqliteStateMachine::open(
1149                    path,
1150                    identity.cluster_id(),
1151                    target_node_id,
1152                    identity.epoch(),
1153                    identity.config_id(),
1154                )
1155                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1156                true
1157            }
1158            #[cfg(not(feature = "sql"))]
1159            return Err(DurabilityError::SnapshotVerification(
1160                "sql execution profile is not compiled in".into(),
1161            ));
1162        }
1163        ExecutionProfile::Kv => {
1164            #[cfg(feature = "kv")]
1165            {
1166                let path = data_dir.join("kv/data.redb");
1167                if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
1168                    return Err(DurabilityError::SnapshotVerification(
1169                        "KV materializer is missing or is not a regular file".into(),
1170                    ));
1171                }
1172                let _state = RedbStateMachine::open(
1173                    &path,
1174                    identity.cluster_id(),
1175                    target_node_id,
1176                    identity.epoch(),
1177                    identity.config_id(),
1178                )
1179                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1180                true
1181            }
1182            #[cfg(not(feature = "kv"))]
1183            return Err(DurabilityError::SnapshotVerification(
1184                "kv execution profile is not compiled in".into(),
1185            ));
1186        }
1187        ExecutionProfile::Graph => false,
1188    })
1189}
1190
1191pub async fn restore_checkpoint_for_rejoin_preserving_recorder(
1192    store: ObjectArchiveStore,
1193    data_dir: impl AsRef<Path>,
1194    target_node_id: &str,
1195    execution_profile: ExecutionProfile,
1196    marker_name: &str,
1197    marker_contents: &[u8],
1198    resume_legacy_v1_intent: bool,
1199) -> Result<CheckpointTip, DurabilityError> {
1200    if target_node_id.is_empty() {
1201        return Err(DurabilityError::SnapshotVerification(
1202            "target node_id is empty".into(),
1203        ));
1204    }
1205    validate_restore_marker_name(marker_name)?;
1206    let data_dir = data_dir.as_ref();
1207    let identity = store.checkpoint_identity()?.clone();
1208    if snapshot_profile(identity.cluster_id())? != execution_profile
1209        || execution_profile == ExecutionProfile::Graph
1210    {
1211        return Err(DurabilityError::SnapshotVerification(
1212            "rejoin recovery only replaces matching SQL or KV recovery views".into(),
1213        ));
1214    }
1215    let restored = store.restore_checkpoint_v2().await?;
1216    let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1217    let intent = encode_restore_intent(
1218        &identity,
1219        target_node_id,
1220        execution_profile,
1221        checkpoint_root,
1222    )?;
1223    let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1224        &identity,
1225        target_node_id,
1226        execution_profile,
1227        checkpoint_root,
1228    ));
1229    cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
1230    if resume_legacy_v1_intent {
1231        fs::remove_file(data_dir.join(LEGACY_RESTORE_INTENT_FILE))?;
1232        sync_directory(data_dir)?;
1233    }
1234    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1235    install_restored_checkpoint(
1236        &identity,
1237        &restored,
1238        data_dir,
1239        RestoreInstallOptions {
1240            target_node_id: Some(target_node_id),
1241            remove_generic_intent: true,
1242            replace_rebuildable: true,
1243            recovery_identity: Some(&recovery_identity),
1244            completion_marker: Some((marker_name, marker_contents)),
1245        },
1246    )
1247}
1248
1249async fn restore_checkpoint_to_fresh_data_dir_with_target(
1250    store: ObjectArchiveStore,
1251    data_dir: &Path,
1252    target_node_id: Option<&str>,
1253    completion_marker: Option<(&str, &[u8])>,
1254    resume_legacy_v1_intent: bool,
1255) -> Result<CheckpointTip, DurabilityError> {
1256    let identity = store.checkpoint_identity()?.clone();
1257    store
1258        .load_checkpoint()
1259        .await?
1260        .ok_or(DurabilityError::MissingCheckpoint)?;
1261    let restored = store.restore_checkpoint_v2().await?;
1262    let target_node_id = target_node_id.unwrap_or("<unbound-restore>");
1263    let profile = snapshot_profile(identity.cluster_id())?;
1264    let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1265    let intent = encode_restore_intent(&identity, target_node_id, profile, checkpoint_root)?;
1266    let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1267        &identity,
1268        target_node_id,
1269        profile,
1270        checkpoint_root,
1271    ));
1272    prepare_fresh_restore_data_dir(
1273        data_dir,
1274        completion_marker.map(|(name, _)| name),
1275        &intent,
1276        resume_legacy_v1_intent,
1277    )?;
1278    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1279    install_restored_checkpoint(
1280        &identity,
1281        &restored,
1282        data_dir,
1283        RestoreInstallOptions {
1284            target_node_id: Some(target_node_id),
1285            remove_generic_intent: true,
1286            replace_rebuildable: false,
1287            recovery_identity: Some(&recovery_identity),
1288            completion_marker,
1289        },
1290    )
1291}
1292
1293struct RestoreInstallOptions<'a> {
1294    target_node_id: Option<&'a str>,
1295    remove_generic_intent: bool,
1296    replace_rebuildable: bool,
1297    recovery_identity: Option<&'a RecoveryArtifactIdentity>,
1298    completion_marker: Option<(&'a str, &'a [u8])>,
1299}
1300
1301fn install_restored_checkpoint(
1302    identity: &CheckpointIdentity,
1303    restored: &RestoredCheckpoint,
1304    data_dir: &Path,
1305    options: RestoreInstallOptions<'_>,
1306) -> Result<CheckpointTip, DurabilityError> {
1307    let tip = *restored.tip();
1308    let profile = snapshot_profile(identity.cluster_id())?;
1309    validate_restored_suffix(profile, restored.suffix())?;
1310    let staging = create_restore_staging_dir(data_dir, options.recovery_identity)?;
1311    let result = (|| -> Result<(), DurabilityError> {
1312        if let Some(snapshot) = restored.snapshot() {
1313            install_profile_snapshot(identity, snapshot, &staging, options.target_node_id)?;
1314        }
1315
1316        if restored.snapshot().is_some() || !restored.suffix().is_empty() {
1317            let initial_configuration = restored
1318                .snapshot()
1319                .map(|snapshot| snapshot.anchor().configuration_state().clone())
1320                .unwrap_or_else(|| ConfigurationState::active(identity.config_id(), LogHash::ZERO));
1321            let log = FileLogStore::open_with_configuration(
1322                staging.join("consensus/log"),
1323                identity.cluster_id(),
1324                identity.epoch(),
1325                initial_configuration,
1326            )?;
1327            if let Some(snapshot) = restored.snapshot() {
1328                log.install_recovery_anchor(
1329                    snapshot.anchor(),
1330                    identity.recovery_generation(),
1331                    snapshot.anchor().configuration_state(),
1332                )?;
1333            }
1334            for batch in restored.suffix().chunks(FLUSH_BATCH_ENTRIES as usize) {
1335                log.append_batch(batch)?;
1336            }
1337            let installed_tip = log.logical_state()?.tip;
1338            if installed_tip.as_ref().map(|tip| (tip.index(), tip.hash()))
1339                != Some((tip.index(), tip.hash()))
1340            {
1341                return Err(DurabilityError::SnapshotVerification(
1342                    "installed qlog tip does not match checkpoint tip".into(),
1343                ));
1344            }
1345        }
1346        if options.replace_rebuildable {
1347            quarantine_rebuildable_view(data_dir, profile, options.recovery_identity)?;
1348        }
1349        publish_restore_staging(
1350            &staging,
1351            data_dir,
1352            options.remove_generic_intent,
1353            options.completion_marker,
1354        )
1355    })();
1356    if result.is_err() {
1357        let _ = fs::remove_dir_all(&staging);
1358    }
1359    result?;
1360    Ok(tip)
1361}
1362
1363fn validate_restored_suffix(
1364    profile: ExecutionProfile,
1365    suffix: &[LogEntry],
1366) -> Result<(), DurabilityError> {
1367    for entry in suffix {
1368        crate::validate_profile_entry_shape(profile, entry)
1369            .map_err(DurabilityError::SnapshotVerification)?;
1370    }
1371    Ok(())
1372}
1373
1374fn validate_local_qlog(
1375    data_dir: &Path,
1376    identity: &CheckpointIdentity,
1377    checkpoint_root: LogAnchor,
1378) -> Result<LogAnchor, DurabilityError> {
1379    let path = data_dir.join("consensus/log");
1380    if !path_has_state(&path)? {
1381        if checkpoint_root == LogAnchor::new(0, LogHash::ZERO) {
1382            return Ok(checkpoint_root);
1383        }
1384        return Err(DurabilityError::SnapshotVerification(
1385            "local qlog is missing or empty".into(),
1386        ));
1387    }
1388    let log = FileLogStore::open(
1389        path,
1390        identity.cluster_id(),
1391        identity.epoch(),
1392        identity.config_id(),
1393    )?;
1394    let state = log.logical_state()?;
1395    let tip = state
1396        .tip
1397        .ok_or_else(|| DurabilityError::SnapshotVerification("local qlog has no tip".into()))?;
1398    if tip.index() < checkpoint_root.index() {
1399        return Err(DurabilityError::SnapshotVerification(format!(
1400            "local qlog tip {}/{} is behind checkpoint root {}/{}",
1401            tip.index(),
1402            tip.hash().to_hex(),
1403            checkpoint_root.index(),
1404            checkpoint_root.hash().to_hex(),
1405        )));
1406    }
1407    if checkpoint_root.index() == 0 {
1408        if checkpoint_root.hash() != LogHash::ZERO {
1409            return Err(DurabilityError::SnapshotVerification(
1410                "checkpoint genesis hash is not zero".into(),
1411            ));
1412        }
1413        return Ok(tip);
1414    }
1415    let included_hash = match state.anchor.as_ref() {
1416        Some(anchor) if anchor.compacted().index() == checkpoint_root.index() => {
1417            Some(anchor.compacted().hash())
1418        }
1419        Some(anchor) if anchor.compacted().index() > checkpoint_root.index() => {
1420            return Err(DurabilityError::SnapshotVerification(
1421                "local qlog compacted past checkpoint root without exact inclusion evidence".into(),
1422            ));
1423        }
1424        _ => log.read(checkpoint_root.index())?.map(|entry| entry.hash),
1425    };
1426    if included_hash != Some(checkpoint_root.hash()) {
1427        return Err(DurabilityError::SnapshotVerification(format!(
1428            "local qlog does not include checkpoint root {} with its exact hash",
1429            checkpoint_root.index(),
1430        )));
1431    }
1432    Ok(tip)
1433}
1434
1435fn install_profile_snapshot(
1436    identity: &CheckpointIdentity,
1437    snapshot: &rhiza_archive::RestoredCheckpointSnapshot,
1438    staging: &Path,
1439    target_node_id: Option<&str>,
1440) -> Result<(), DurabilityError> {
1441    match snapshot_profile(identity.cluster_id())? {
1442        ExecutionProfile::Sqlite => {
1443            #[cfg(feature = "sql")]
1444            {
1445                validate_anchor_fingerprint(
1446                    snapshot.anchor(),
1447                    sql_executor_fingerprint().map_err(|error| {
1448                        DurabilityError::SnapshotVerification(error.to_string())
1449                    })?,
1450                )?;
1451                let path = staging.join("sqlite/db.sqlite");
1452                let node_id = target_node_id.ok_or_else(|| {
1453                    DurabilityError::SnapshotVerification(
1454                        "SQLite QWAL snapshot restore requires a target node_id".into(),
1455                    )
1456                })?;
1457                restore_recovery_snapshot_file(path, snapshot.bytes(), snapshot.anchor(), node_id)
1458                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1459            }
1460            #[cfg(not(feature = "sql"))]
1461            Err(DurabilityError::SnapshotVerification(
1462                "sql execution profile is not compiled in".into(),
1463            ))
1464        }
1465        ExecutionProfile::Graph => {
1466            #[cfg(feature = "graph")]
1467            {
1468                let decoded = decode_graph_snapshot(snapshot.bytes())
1469                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1470                validate_decoded_snapshot_anchor(
1471                    snapshot.anchor(),
1472                    decoded.cluster_id(),
1473                    decoded.epoch(),
1474                    decoded.config_id(),
1475                    decoded.applied_index(),
1476                    decoded.applied_hash(),
1477                    decoded.materializer_fingerprint(),
1478                )?;
1479                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1480                restore_graph_snapshot_file(
1481                    staging.join("ladybug/graph.lbug"),
1482                    &decoded,
1483                    target_node_id,
1484                )
1485                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1486            }
1487            #[cfg(not(feature = "graph"))]
1488            Err(DurabilityError::SnapshotVerification(
1489                "graph recovery support is not compiled in".into(),
1490            ))
1491        }
1492        ExecutionProfile::Kv => {
1493            #[cfg(feature = "kv")]
1494            {
1495                let decoded = decode_kv_snapshot(snapshot.bytes())
1496                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1497                validate_decoded_snapshot_anchor(
1498                    snapshot.anchor(),
1499                    decoded.cluster_id(),
1500                    decoded.epoch(),
1501                    decoded.config_id(),
1502                    decoded.applied_index(),
1503                    decoded.applied_hash(),
1504                    decoded.materializer_fingerprint(),
1505                )?;
1506                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1507                restore_kv_snapshot_file(staging.join("kv/data.redb"), &decoded, target_node_id)
1508                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1509            }
1510            #[cfg(not(feature = "kv"))]
1511            Err(DurabilityError::SnapshotVerification(
1512                "KV recovery support is not compiled in".into(),
1513            ))
1514        }
1515    }
1516}
1517
1518fn snapshot_profile(cluster_id: &str) -> Result<ExecutionProfile, DurabilityError> {
1519    if matches!(cluster_id.strip_prefix("rhiza:graph:"), Some(logical) if !logical.is_empty()) {
1520        Ok(ExecutionProfile::Graph)
1521    } else if matches!(cluster_id.strip_prefix("rhiza:kv:"), Some(logical) if !logical.is_empty()) {
1522        Ok(ExecutionProfile::Kv)
1523    } else if matches!(cluster_id.strip_prefix("rhiza:sql:"), Some(logical) if !logical.is_empty())
1524    {
1525        Ok(ExecutionProfile::Sqlite)
1526    } else {
1527        Err(DurabilityError::SnapshotVerification(
1528            "snapshot checkpoint identity has no canonical execution profile prefix".into(),
1529        ))
1530    }
1531}
1532
1533fn validate_anchor_fingerprint(
1534    anchor: &RecoveryAnchor,
1535    expected: LogHash,
1536) -> Result<(), DurabilityError> {
1537    if anchor.executor_fingerprint() != Some(expected) {
1538        return Err(DurabilityError::SnapshotVerification(
1539            "snapshot executor fingerprint does not match this binary".into(),
1540        ));
1541    }
1542    Ok(())
1543}
1544
1545#[cfg(any(feature = "graph", feature = "kv"))]
1546fn validate_decoded_snapshot_anchor(
1547    anchor: &RecoveryAnchor,
1548    cluster_id: &str,
1549    epoch: u64,
1550    config_id: u64,
1551    applied_index: LogIndex,
1552    applied_hash: LogHash,
1553    materializer_fingerprint: LogHash,
1554) -> Result<(), DurabilityError> {
1555    validate_anchor_fingerprint(anchor, materializer_fingerprint)?;
1556    if anchor.cluster_id() != cluster_id
1557        || anchor.epoch() != epoch
1558        || anchor.config_id() != config_id
1559        || anchor.compacted().index() != applied_index
1560        || anchor.compacted().hash() != applied_hash
1561    {
1562        return Err(DurabilityError::SnapshotVerification(
1563            "decoded snapshot identity does not match its recovery anchor".into(),
1564        ));
1565    }
1566    Ok(())
1567}
1568
1569pub async fn restore_successor_checkpoint_to_fresh_data_dir(
1570    store: ObjectArchiveStore,
1571    config: &NodeConfig,
1572) -> Result<SuccessorRestorePreparation, DurabilityError> {
1573    let identity = store.checkpoint_identity()?;
1574    let loaded = store
1575        .load_checkpoint()
1576        .await?
1577        .ok_or(DurabilityError::MissingCheckpoint)?;
1578    let transition = loaded.manifest().successor_transition().ok_or_else(|| {
1579        DurabilityError::SnapshotVerification(
1580            "successor startup requires transition provenance".into(),
1581        )
1582    })?;
1583    let stop = LogAnchor::new(transition.stop_entry().index, transition.stop_entry().hash);
1584    let expected_stopped = ConfigurationState::stopped(
1585        transition.predecessor().config_id(),
1586        transition.successor().predecessor_config_digest(),
1587        stop,
1588    );
1589    let expected_initial = ConfigurationState::active(
1590        transition.predecessor().config_id(),
1591        transition.successor().predecessor_config_digest(),
1592    );
1593    if identity.cluster_id() != config.cluster_id()
1594        || identity.epoch() != config.epoch()
1595        || identity.config_id() != transition.successor().config_id()
1596        || identity.recovery_generation() != config.recovery_generation()
1597        || transition.successor().cluster_id() != config.cluster_id()
1598        || transition.successor().members() != config.membership().members()
1599        || transition.successor().digest() != config.membership().digest()
1600        || config.configuration_state() != &expected_stopped
1601        || config.log_initial_configuration() != &expected_initial
1602    {
1603        return Err(DurabilityError::SnapshotVerification(
1604            "successor checkpoint does not match target node configuration".into(),
1605        ));
1606    }
1607    let receipt = serde_json::to_vec(&SuccessorRestoreIdentity {
1608        version: 1,
1609        cluster_id: config.cluster_id(),
1610        epoch: config.epoch(),
1611        target_config_id: identity.config_id(),
1612        recovery_generation: config.recovery_generation(),
1613        node_id: config.node_id(),
1614        membership_digest: config.membership().digest().to_hex(),
1615        predecessor_config_id: transition.predecessor().config_id(),
1616        stop_index: transition.stop_entry().index,
1617        stop_hash: transition.stop_entry().hash.to_hex(),
1618        checkpoint_index: loaded.manifest().tip().index(),
1619        checkpoint_hash: loaded.manifest().tip().hash().to_hex(),
1620    })
1621    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1622    let intent_identity =
1623        RecoveryArtifactIdentity::Successor(parse_successor_restore_receipt(&receipt).ok_or_else(
1624            || DurabilityError::SnapshotVerification("successor restore receipt is invalid".into()),
1625        )?);
1626    let (lock, state, complete_marker) =
1627        prepare_successor_restore_root(config.data_dir(), &receipt)?;
1628    if state == SuccessorRestoreRootState::Complete {
1629        let checkpoint_root = LogAnchor::new(
1630            loaded.manifest().tip().index(),
1631            loaded.manifest().tip().hash(),
1632        );
1633        if let Err(error) = validate_local_recovery_view(
1634            config.data_dir(),
1635            identity,
1636            config.node_id(),
1637            config.execution_profile(),
1638            checkpoint_root,
1639        ) {
1640            if config.execution_profile() == ExecutionProfile::Graph {
1641                return Err(error);
1642            }
1643            let restored = store.restore_checkpoint_v2().await?;
1644            if restored.tip() != loaded.manifest().tip() {
1645                return Err(DurabilityError::SnapshotVerification(
1646                    "successor checkpoint changed during repair".into(),
1647                ));
1648            }
1649            install_restored_checkpoint(
1650                identity,
1651                &restored,
1652                config.data_dir(),
1653                RestoreInstallOptions {
1654                    target_node_id: Some(config.node_id()),
1655                    remove_generic_intent: false,
1656                    replace_rebuildable: true,
1657                    recovery_identity: Some(&RecoveryArtifactIdentity::Successor(
1658                        complete_marker
1659                            .as_ref()
1660                            .expect("Complete state has a validated identity")
1661                            .clone(),
1662                    )),
1663                    completion_marker: None,
1664                },
1665            )?;
1666        }
1667        return Ok(SuccessorRestorePreparation {
1668            tip: *loaded.manifest().tip(),
1669            data_dir: config.data_dir().clone(),
1670            identity: receipt,
1671            requires_recorder_install: false,
1672            _lock: lock,
1673        });
1674    }
1675
1676    let restored = store.restore_checkpoint_v2().await?;
1677    if restored.tip() != loaded.manifest().tip() {
1678        return Err(DurabilityError::SnapshotVerification(
1679            "successor checkpoint changed during restore".into(),
1680        ));
1681    }
1682    if state == SuccessorRestoreRootState::Fresh {
1683        publish_restore_marker(config.data_dir(), SUCCESSOR_RESTORE_INTENT_FILE, &receipt)?;
1684    }
1685    install_restored_checkpoint(
1686        identity,
1687        &restored,
1688        config.data_dir(),
1689        RestoreInstallOptions {
1690            target_node_id: Some(config.node_id()),
1691            remove_generic_intent: false,
1692            replace_rebuildable: false,
1693            recovery_identity: Some(&intent_identity),
1694            completion_marker: None,
1695        },
1696    )?;
1697    Ok(SuccessorRestorePreparation {
1698        tip: *restored.tip(),
1699        data_dir: config.data_dir().clone(),
1700        identity: receipt,
1701        requires_recorder_install: true,
1702        _lock: lock,
1703    })
1704}
1705
1706fn write_repair_artifact_ownership(
1707    artifact: &Path,
1708    role: RepairArtifactRole,
1709    identity: &RecoveryArtifactIdentity,
1710) -> Result<(), DurabilityError> {
1711    let name = artifact
1712        .file_name()
1713        .and_then(|name| name.to_str())
1714        .ok_or_else(|| {
1715            DurabilityError::SnapshotVerification(
1716                "repair artifact path must have a UTF-8 final component".into(),
1717            )
1718        })?
1719        .to_owned();
1720    let contents = serde_json::to_vec(&RepairArtifactOwnership {
1721        format_version: 1,
1722        role,
1723        name,
1724        identity: identity.clone(),
1725    })
1726    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1727    let owner = artifact.join(REPAIR_ARTIFACT_OWNER_FILE);
1728    let mut file = fs::OpenOptions::new()
1729        .write(true)
1730        .create_new(true)
1731        .open(owner)?;
1732    file.write_all(&contents)?;
1733    file.sync_all()?;
1734    sync_directory(artifact)
1735}
1736
1737fn create_restore_staging_dir(
1738    data_dir: &Path,
1739    recovery_identity: Option<&RecoveryArtifactIdentity>,
1740) -> Result<PathBuf, DurabilityError> {
1741    fs::create_dir_all(data_dir)?;
1742    for _ in 0..128 {
1743        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1744        let staging = data_dir.join(format!(
1745            "{RESTORE_STAGING_PREFIX}{}-{sequence}",
1746            process::id()
1747        ));
1748        match fs::create_dir(&staging) {
1749            Ok(()) => {
1750                if let Some(identity) = recovery_identity {
1751                    if let Err(error) = write_repair_artifact_ownership(
1752                        &staging,
1753                        RepairArtifactRole::Staging,
1754                        identity,
1755                    ) {
1756                        let _ = fs::remove_dir_all(&staging);
1757                        return Err(error);
1758                    }
1759                }
1760                return Ok(staging);
1761            }
1762            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1763            Err(error) => return Err(error.into()),
1764        }
1765    }
1766    Err(std::io::Error::new(
1767        std::io::ErrorKind::AlreadyExists,
1768        "could not allocate restore staging directory",
1769    )
1770    .into())
1771}
1772
1773fn publish_restore_staging(
1774    staging: &Path,
1775    data_dir: &Path,
1776    remove_generic_intent: bool,
1777    completion_marker: Option<(&str, &[u8])>,
1778) -> Result<(), DurabilityError> {
1779    sync_directory(staging)?;
1780    for name in ["sqlite", "ladybug", "kv", "consensus"] {
1781        let source = staging.join(name);
1782        if source.exists() {
1783            fs::rename(&source, data_dir.join(name))?;
1784        }
1785    }
1786    // A recovery-owned staging directory carries its ownership record inside the staging root.
1787    // Remove that sidecar together with the now-empty staging root; it must never be promoted
1788    // into the live data directory.
1789    fs::remove_dir_all(staging)?;
1790    sync_directory(data_dir)?;
1791    if let Some((marker_name, marker_contents)) = completion_marker {
1792        publish_restore_marker(data_dir, marker_name, marker_contents)?;
1793    }
1794    if remove_generic_intent {
1795        fs::remove_file(data_dir.join(RESTORE_INTENT_FILE))?;
1796    }
1797    sync_directory(data_dir)
1798}
1799
1800fn quarantine_rebuildable_view(
1801    data_dir: &Path,
1802    profile: ExecutionProfile,
1803    recovery_identity: Option<&RecoveryArtifactIdentity>,
1804) -> Result<Option<PathBuf>, DurabilityError> {
1805    let materializer = match profile {
1806        ExecutionProfile::Sqlite => "sqlite",
1807        ExecutionProfile::Kv => "kv",
1808        ExecutionProfile::Graph => {
1809            return Err(DurabilityError::SnapshotVerification(
1810                "graph recovery view replacement is outside this recovery path".into(),
1811            ))
1812        }
1813    };
1814    let names = [materializer, "consensus"];
1815    let mut has_rebuildable_view = false;
1816    for name in names {
1817        match fs::symlink_metadata(data_dir.join(name)) {
1818            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1819                return Err(DurabilityError::SnapshotVerification(
1820                    "rebuildable recovery view is not a regular directory".into(),
1821                ));
1822            }
1823            Ok(_) => has_rebuildable_view = true,
1824            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1825            Err(error) => return Err(error.into()),
1826        }
1827    }
1828    if !has_rebuildable_view {
1829        return Ok(None);
1830    }
1831    for _ in 0..128 {
1832        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1833        let quarantine = data_dir.join(format!(
1834            ".rebuildable-quarantine-{}-{sequence}",
1835            process::id()
1836        ));
1837        match fs::create_dir(&quarantine) {
1838            Ok(()) => {
1839                if let Some(identity) = recovery_identity {
1840                    if let Err(error) = write_repair_artifact_ownership(
1841                        &quarantine,
1842                        RepairArtifactRole::Quarantine,
1843                        identity,
1844                    ) {
1845                        let _ = fs::remove_dir_all(&quarantine);
1846                        return Err(error);
1847                    }
1848                }
1849                for name in names {
1850                    let source = data_dir.join(name);
1851                    match fs::symlink_metadata(&source) {
1852                        Ok(_) => fs::rename(source, quarantine.join(name))?,
1853                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1854                        Err(error) => return Err(error.into()),
1855                    }
1856                }
1857                sync_directory(&quarantine)?;
1858                sync_directory(data_dir)?;
1859                return Ok(Some(quarantine));
1860            }
1861            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1862            Err(error) => return Err(error.into()),
1863        }
1864    }
1865    Err(std::io::Error::new(
1866        std::io::ErrorKind::AlreadyExists,
1867        "could not allocate rebuildable recovery quarantine",
1868    )
1869    .into())
1870}
1871
1872fn publish_restore_marker(
1873    data_dir: &Path,
1874    marker_name: &str,
1875    contents: &[u8],
1876) -> Result<(), DurabilityError> {
1877    validate_restore_marker_name(marker_name)?;
1878    fs::create_dir_all(data_dir)?;
1879    let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1880    let temporary = data_dir.join(format!(
1881        "{RESTORE_MARKER_TMP_PREFIX}{}-{sequence}",
1882        process::id()
1883    ));
1884    let mut file = fs::OpenOptions::new()
1885        .write(true)
1886        .create_new(true)
1887        .open(&temporary)?;
1888    file.write_all(contents)?;
1889    file.sync_all()?;
1890    fs::rename(temporary, data_dir.join(marker_name))?;
1891    sync_directory(data_dir)
1892}
1893
1894fn validate_restore_marker_name(marker_name: &str) -> Result<(), DurabilityError> {
1895    if marker_name.is_empty()
1896        || matches!(marker_name, "." | "..")
1897        || marker_name.contains(std::path::MAIN_SEPARATOR)
1898    {
1899        return Err(DurabilityError::SnapshotVerification(
1900            "restore marker name must be one local file name".into(),
1901        ));
1902    }
1903    Ok(())
1904}
1905
1906#[derive(Clone, Copy, Eq, PartialEq)]
1907enum SuccessorRestoreRootState {
1908    Fresh,
1909    Intent,
1910    Complete,
1911}
1912
1913fn is_owned_generated_recovery_name(name: &str, prefix: &str) -> bool {
1914    let Some(suffix) = name.strip_prefix(prefix) else {
1915        return false;
1916    };
1917    let mut parts = suffix.split('-');
1918    let (Some(process_id), Some(sequence), None) = (parts.next(), parts.next(), parts.next())
1919    else {
1920        return false;
1921    };
1922    process_id.parse::<u32>().is_ok_and(|id| id > 0) && sequence.parse::<u64>().is_ok()
1923}
1924
1925fn is_safe_restore_marker_tmp(path: &Path, name: &str) -> Result<bool, DurabilityError> {
1926    if !is_owned_generated_recovery_name(name, RESTORE_MARKER_TMP_PREFIX) {
1927        return Ok(false);
1928    }
1929    Ok(read_bounded_regular_file(path, 16384)?.is_some())
1930}
1931
1932fn is_owned_recovery_directory(
1933    path: &Path,
1934    allowed_children: &[&str],
1935    expected_role: RepairArtifactRole,
1936    expected_identity: &RecoveryArtifactIdentity,
1937) -> Result<bool, DurabilityError> {
1938    let metadata = fs::symlink_metadata(path)?;
1939    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1940        return Ok(false);
1941    }
1942    let owner = path.join(REPAIR_ARTIFACT_OWNER_FILE);
1943    let Some(owner_bytes) = read_bounded_regular_file(&owner, 16384)? else {
1944        return Ok(false);
1945    };
1946    let Ok(ownership) = serde_json::from_slice::<RepairArtifactOwnership>(&owner_bytes) else {
1947        return Ok(false);
1948    };
1949    if ownership.format_version != 1
1950        || ownership.role != expected_role
1951        || ownership.identity != *expected_identity
1952        || path.file_name().and_then(|name| name.to_str()) != Some(ownership.name.as_str())
1953    {
1954        return Ok(false);
1955    }
1956    for entry in fs::read_dir(path)? {
1957        let entry = entry?;
1958        let name = entry.file_name();
1959        let name = name.to_string_lossy();
1960        if name == REPAIR_ARTIFACT_OWNER_FILE {
1961            continue;
1962        }
1963        let metadata = fs::symlink_metadata(entry.path())?;
1964        if !allowed_children.contains(&name.as_ref())
1965            || metadata.file_type().is_symlink()
1966            || !metadata.is_dir()
1967        {
1968            return Ok(false);
1969        }
1970    }
1971    Ok(true)
1972}
1973
1974fn cleanup_owned_recovery_artifacts(
1975    data_dir: &Path,
1976    identity: &RecoveryArtifactIdentity,
1977) -> Result<(), DurabilityError> {
1978    let mut cleanup = Vec::new();
1979    for entry in fs::read_dir(data_dir)? {
1980        let entry = entry?;
1981        let name = entry.file_name();
1982        let name = name.to_string_lossy();
1983        let has_staging_prefix = name.starts_with(RESTORE_STAGING_PREFIX);
1984        let has_quarantine_prefix = name.starts_with(".rebuildable-quarantine-");
1985        if (has_staging_prefix && !is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX))
1986            || (has_quarantine_prefix
1987                && !is_owned_generated_recovery_name(&name, ".rebuildable-quarantine-"))
1988        {
1989            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1990        }
1991        let artifact = if has_staging_prefix {
1992            Some((
1993                ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
1994                RepairArtifactRole::Staging,
1995            ))
1996        } else if has_quarantine_prefix {
1997            Some((
1998                ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
1999                RepairArtifactRole::Quarantine,
2000            ))
2001        } else {
2002            None
2003        };
2004        let Some((allowed_children, role)) = artifact else {
2005            continue;
2006        };
2007        if !is_owned_recovery_directory(&entry.path(), allowed_children, role, identity)? {
2008            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2009        }
2010        cleanup.push(entry.path());
2011    }
2012    for path in cleanup.iter() {
2013        fs::remove_dir_all(path)?;
2014    }
2015    if !cleanup.is_empty() {
2016        sync_directory(data_dir)?;
2017    }
2018    Ok(())
2019}
2020
2021#[cfg(any(target_os = "linux", target_os = "android"))]
2022const O_NOFOLLOW_FLAG: i32 = 0o400000;
2023#[cfg(any(
2024    target_os = "macos",
2025    target_os = "ios",
2026    target_os = "freebsd",
2027    target_os = "openbsd",
2028    target_os = "netbsd",
2029    target_os = "dragonfly"
2030))]
2031const O_NOFOLLOW_FLAG: i32 = 0x0100;
2032
2033fn open_recovery_file_no_follow(path: &Path) -> Result<fs::File, DurabilityError> {
2034    let mut options = fs::OpenOptions::new();
2035    options.read(true);
2036    #[cfg(any(
2037        target_os = "linux",
2038        target_os = "android",
2039        target_os = "macos",
2040        target_os = "ios",
2041        target_os = "freebsd",
2042        target_os = "openbsd",
2043        target_os = "netbsd",
2044        target_os = "dragonfly"
2045    ))]
2046    options.custom_flags(O_NOFOLLOW_FLAG);
2047    Ok(options.open(path)?)
2048}
2049
2050fn read_bounded_regular_file(
2051    path: &Path,
2052    max_bytes: u64,
2053) -> Result<Option<Vec<u8>>, DurabilityError> {
2054    let metadata = match fs::symlink_metadata(path) {
2055        Ok(metadata) => metadata,
2056        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2057        Err(error) => return Err(error.into()),
2058    };
2059    if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes {
2060        return Err(DurabilityError::SnapshotVerification(
2061            "recovery file is not a bounded regular file".into(),
2062        ));
2063    }
2064    let mut file = open_recovery_file_no_follow(path)?;
2065    let opened = file.metadata()?;
2066    if !opened.is_file() || opened.len() > max_bytes {
2067        return Err(DurabilityError::SnapshotVerification(
2068            "recovery file changed to an invalid form before read".into(),
2069        ));
2070    }
2071    #[cfg(unix)]
2072    if metadata.dev() != opened.dev()
2073        || metadata.ino() != opened.ino()
2074        || metadata.len() != opened.len()
2075    {
2076        return Err(DurabilityError::SnapshotVerification(
2077            "recovery file changed before no-follow open".into(),
2078        ));
2079    }
2080    let mut contents = Vec::with_capacity(usize::try_from(opened.len()).unwrap_or(0));
2081    Read::by_ref(&mut file)
2082        .take(max_bytes + 1)
2083        .read_to_end(&mut contents)?;
2084    if contents.len() as u64 > max_bytes || contents.len() as u64 != opened.len() {
2085        return Err(DurabilityError::SnapshotVerification(
2086            "recovery file changed during bounded read".into(),
2087        ));
2088    }
2089    Ok(Some(contents))
2090}
2091
2092fn read_regular_successor_control_file(path: &Path) -> Result<Option<Vec<u8>>, DurabilityError> {
2093    read_bounded_regular_file(path, 16384)
2094}
2095
2096fn parse_successor_restore_receipt(bytes: &[u8]) -> Option<SuccessorRestoreReceipt> {
2097    let receipt = serde_json::from_slice::<SuccessorRestoreReceipt>(bytes).ok()?;
2098    (receipt.version == 1
2099        && !receipt.cluster_id.is_empty()
2100        && !receipt.node_id.is_empty()
2101        && LogHash::from_hex(&receipt.membership_digest).is_some()
2102        && LogHash::from_hex(&receipt.stop_hash).is_some()
2103        && LogHash::from_hex(&receipt.checkpoint_hash).is_some())
2104    .then_some(receipt)
2105}
2106
2107fn prepare_successor_restore_root(
2108    data_dir: &Path,
2109    expected_identity: &[u8],
2110) -> Result<
2111    (
2112        fs::File,
2113        SuccessorRestoreRootState,
2114        Option<SuccessorRestoreReceipt>,
2115    ),
2116    DurabilityError,
2117> {
2118    fs::create_dir_all(data_dir)?;
2119    let lock = fs::OpenOptions::new()
2120        .read(true)
2121        .write(true)
2122        .create(true)
2123        .truncate(false)
2124        .open(data_dir.join(SUCCESSOR_RESTORE_LOCK_FILE))?;
2125    lock.lock()?;
2126
2127    let intent = data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
2128    let complete = data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
2129    let intent_contents = read_regular_successor_control_file(&intent)
2130        .map_err(|_| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?;
2131    let complete_contents = read_regular_successor_control_file(&complete)
2132        .map_err(|_| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?;
2133    let (state, complete_marker) = match (intent_contents, complete_contents) {
2134        (Some(actual), None) => {
2135            if actual != expected_identity || parse_successor_restore_receipt(&actual).is_none() {
2136                return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2137            }
2138            (SuccessorRestoreRootState::Intent, None)
2139        }
2140        (None, Some(actual)) => {
2141            if !completed_successor_identity_matches(&actual, expected_identity) {
2142                return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2143            }
2144            let receipt = parse_successor_restore_receipt(&actual)
2145                .ok_or_else(|| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?;
2146            (SuccessorRestoreRootState::Complete, Some(receipt))
2147        }
2148        (None, None) => (SuccessorRestoreRootState::Fresh, None),
2149        _ => return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf())),
2150    };
2151
2152    if state == SuccessorRestoreRootState::Complete {
2153        let recovery_identity = RecoveryArtifactIdentity::Successor(
2154            complete_marker
2155                .as_ref()
2156                .expect("Complete state has a validated identity")
2157                .clone(),
2158        );
2159        cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
2160    } else if state == SuccessorRestoreRootState::Intent {
2161        let recovery_identity = RecoveryArtifactIdentity::Successor(
2162            parse_successor_restore_receipt(expected_identity)
2163                .ok_or_else(|| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?,
2164        );
2165        cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
2166    }
2167
2168    for entry in fs::read_dir(data_dir)? {
2169        let entry = entry?;
2170        let name = entry.file_name();
2171        let name = name.to_string_lossy();
2172        let marker_tmp = is_safe_restore_marker_tmp(&entry.path(), &name)?;
2173        if name.starts_with(RESTORE_MARKER_TMP_PREFIX) && !marker_tmp {
2174            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2175        }
2176        let common = name == SUCCESSOR_RESTORE_LOCK_FILE || marker_tmp;
2177        let allowed = match state {
2178            SuccessorRestoreRootState::Fresh => common,
2179            SuccessorRestoreRootState::Intent => {
2180                common
2181                    || name == SUCCESSOR_RESTORE_INTENT_FILE
2182                    || name == LOCAL_CHECKPOINT_IDENTITY_FILE
2183                    || name == "sqlite"
2184                    || name == "ladybug"
2185                    || name == "kv"
2186                    || name == "consensus"
2187                    || name == "recorder"
2188            }
2189            SuccessorRestoreRootState::Complete => {
2190                common
2191                    || name == SUCCESSOR_RESTORE_COMPLETE_FILE
2192                    || name == LOCAL_CHECKPOINT_IDENTITY_FILE
2193                    || name == ".node.lock"
2194                    || name == "sqlite"
2195                    || name == "ladybug"
2196                    || name == "kv"
2197                    || name == "consensus"
2198                    || name == "recorder"
2199            }
2200        };
2201        if !allowed {
2202            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2203        }
2204    }
2205
2206    for entry in fs::read_dir(data_dir)? {
2207        let entry = entry?;
2208        let name = entry.file_name();
2209        let name = name.to_string_lossy();
2210        if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2211            fs::remove_file(entry.path())?;
2212        }
2213    }
2214    if state == SuccessorRestoreRootState::Intent {
2215        for name in ["sqlite", "ladybug", "kv", "consensus", "recorder"] {
2216            let path = data_dir.join(name);
2217            if path.exists() {
2218                fs::remove_dir_all(path)?;
2219            }
2220        }
2221        for entry in fs::read_dir(data_dir)? {
2222            let entry = entry?;
2223            if entry
2224                .file_name()
2225                .to_string_lossy()
2226                .starts_with(RESTORE_STAGING_PREFIX)
2227            {
2228                fs::remove_dir_all(entry.path())?;
2229            }
2230        }
2231        sync_directory(data_dir)?;
2232    }
2233    Ok((lock, state, complete_marker))
2234}
2235
2236fn completed_successor_identity_matches(actual: &[u8], expected: &[u8]) -> bool {
2237    let (Ok(mut actual), Ok(mut expected)) = (
2238        serde_json::from_slice::<serde_json::Value>(actual),
2239        serde_json::from_slice::<serde_json::Value>(expected),
2240    ) else {
2241        return false;
2242    };
2243    let Some(actual_index) = actual["checkpoint_index"].as_u64() else {
2244        return false;
2245    };
2246    let Some(expected_index) = expected["checkpoint_index"].as_u64() else {
2247        return false;
2248    };
2249    let (Some(actual_hash), Some(expected_hash)) = (
2250        actual["checkpoint_hash"].as_str(),
2251        expected["checkpoint_hash"].as_str(),
2252    ) else {
2253        return false;
2254    };
2255    if LogHash::from_hex(actual_hash).is_none() || LogHash::from_hex(expected_hash).is_none() {
2256        return false;
2257    }
2258    if actual_index > expected_index
2259        || (actual_index == expected_index && actual_hash != expected_hash)
2260    {
2261        return false;
2262    }
2263    for receipt in [&mut actual, &mut expected] {
2264        let Some(receipt) = receipt.as_object_mut() else {
2265            return false;
2266        };
2267        receipt.remove("checkpoint_index");
2268        receipt.remove("checkpoint_hash");
2269    }
2270    actual == expected
2271}
2272
2273fn sync_directory(path: &Path) -> Result<(), DurabilityError> {
2274    fs::File::open(path)?.sync_all()?;
2275    Ok(())
2276}
2277
2278fn validate_local_batch(
2279    entries: &[rhiza_core::LogEntry],
2280    start: LogIndex,
2281    end: LogIndex,
2282    durable_tip: CheckpointTip,
2283) -> Result<(), DurabilityError> {
2284    let expected_len =
2285        usize::try_from(end - start + 1).map_err(|_| DurabilityError::LocalLogGap {
2286            expected: start,
2287            actual: entries.first().map(|entry| entry.index),
2288        })?;
2289    if entries.len() != expected_len {
2290        let actual = entries
2291            .iter()
2292            .zip(start..=end)
2293            .find_map(|(entry, expected)| (entry.index != expected).then_some(entry.index));
2294        return Err(DurabilityError::LocalLogGap {
2295            expected: start + entries.len() as u64,
2296            actual,
2297        });
2298    }
2299
2300    let mut expected_hash = durable_tip.hash();
2301    for (expected_index, entry) in (start..).zip(entries) {
2302        if entry.index != expected_index {
2303            return Err(DurabilityError::LocalLogGap {
2304                expected: expected_index,
2305                actual: Some(entry.index),
2306            });
2307        }
2308        if entry.prev_hash != expected_hash || entry.recompute_hash() != entry.hash {
2309            return Err(DurabilityError::LocalLogConflict { index: entry.index });
2310        }
2311        expected_hash = entry.hash;
2312    }
2313    Ok(())
2314}
2315
2316fn prepare_fresh_restore_data_dir(
2317    data_dir: &Path,
2318    completion_marker_name: Option<&str>,
2319    expected_intent: &[u8],
2320    resume_legacy_v1_intent: bool,
2321) -> Result<(), DurabilityError> {
2322    if !path_has_state(data_dir)? {
2323        return Ok(());
2324    }
2325
2326    let legacy_intent = data_dir.join(LEGACY_RESTORE_INTENT_FILE);
2327    let intent = data_dir.join(RESTORE_INTENT_FILE);
2328    let legacy_metadata = match fs::symlink_metadata(&legacy_intent) {
2329        Ok(metadata) => Some(metadata),
2330        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2331        Err(error) => return Err(error.into()),
2332    };
2333    let intent_metadata = match fs::symlink_metadata(&intent) {
2334        Ok(metadata) => Some(metadata),
2335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2336        Err(error) => return Err(error.into()),
2337    };
2338    if legacy_metadata.is_some() && intent_metadata.is_some() {
2339        return Err(DurabilityError::SnapshotVerification(
2340            "both legacy and identity-bound checkpoint restore intents exist".into(),
2341        ));
2342    }
2343    let (active_intent, recovery_identity) = if let Some(metadata) = legacy_metadata {
2344        if !resume_legacy_v1_intent
2345            || metadata.file_type().is_symlink()
2346            || !metadata.is_file()
2347            || read_bounded_regular_file(&legacy_intent, 4096)?.as_deref()
2348                != Some(b"rhiza restore in progress\n")
2349        {
2350            return Err(DurabilityError::SnapshotVerification(
2351                "legacy local checkpoint restore intent requires an exact node-bound v2 identity marker".into(),
2352            ));
2353        }
2354        (&legacy_intent, None)
2355    } else if let Some(metadata) = intent_metadata {
2356        if metadata.file_type().is_symlink()
2357            || !metadata.is_file()
2358            || read_bounded_regular_file(&intent, 4096)?.as_deref() != Some(expected_intent)
2359        {
2360            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2361        }
2362        (
2363            &intent,
2364            Some(RecoveryArtifactIdentity::Restore(
2365                parse_restore_intent_identity(expected_intent)
2366                    .ok_or_else(|| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?,
2367            )),
2368        )
2369    } else {
2370        let entries = fs::read_dir(data_dir)?.collect::<Result<Vec<_>, _>>()?;
2371        if entries.iter().all(|entry| {
2372            let name = entry.file_name();
2373            let name = name.to_string_lossy();
2374            is_safe_restore_marker_tmp(&entry.path(), &name).unwrap_or(false)
2375        }) {
2376            for entry in entries {
2377                fs::remove_file(entry.path())?;
2378            }
2379            sync_directory(data_dir)?;
2380            return Ok(());
2381        }
2382        return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2383    };
2384
2385    for entry in fs::read_dir(data_dir)? {
2386        let entry = entry?;
2387        let name = entry.file_name();
2388        let name = name.to_string_lossy();
2389        let marker_tmp = is_safe_restore_marker_tmp(&entry.path(), &name)?;
2390        if name.starts_with(RESTORE_MARKER_TMP_PREFIX) && !marker_tmp {
2391            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2392        }
2393        let is_staging = name.starts_with(RESTORE_STAGING_PREFIX);
2394        if is_staging
2395            && (!is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2396                || !recovery_identity.as_ref().is_some_and(|identity| {
2397                    is_owned_recovery_directory(
2398                        &entry.path(),
2399                        &["sqlite", "ladybug", "kv", "consensus"],
2400                        RepairArtifactRole::Staging,
2401                        identity,
2402                    )
2403                    .unwrap_or(false)
2404                }))
2405        {
2406            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2407        }
2408        let owned = entry.path() == active_intent.as_path()
2409            || completion_marker_name.is_some_and(|marker| name == marker)
2410            || name == "sqlite"
2411            || name == "ladybug"
2412            || name == "kv"
2413            || name == "consensus"
2414            || marker_tmp
2415            || is_staging;
2416        if !owned {
2417            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2418        }
2419    }
2420
2421    for name in ["sqlite", "ladybug", "kv", "consensus"] {
2422        let path = data_dir.join(name);
2423        if path.exists() {
2424            fs::remove_dir_all(path)?;
2425        }
2426    }
2427    for entry in fs::read_dir(data_dir)? {
2428        let entry = entry?;
2429        let name = entry.file_name();
2430        let name = name.to_string_lossy();
2431        if name.starts_with(RESTORE_STAGING_PREFIX) {
2432            fs::remove_dir_all(entry.path())?;
2433        } else if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2434            fs::remove_file(entry.path())?;
2435        }
2436    }
2437    fs::remove_file(active_intent)?;
2438    sync_directory(data_dir)?;
2439    Ok(())
2440}
2441
2442fn path_has_state(path: &Path) -> Result<bool, std::io::Error> {
2443    let metadata = match fs::symlink_metadata(path) {
2444        Ok(metadata) => metadata,
2445        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2446        Err(error) => return Err(error),
2447    };
2448    if !metadata.is_dir() {
2449        return Ok(true);
2450    }
2451    fs::read_dir(path)?
2452        .next()
2453        .transpose()
2454        .map(|entry| entry.is_some())
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459    use super::{
2460        completed_successor_identity_matches, mark_durable_state, observe_durable_tip,
2461        parse_successor_restore_receipt, prepare_successor_restore_root, snapshot_profile,
2462        validate_local_qlog, validate_restored_suffix, write_repair_artifact_ownership,
2463        CheckpointTip, CoordinatorState, DurabilityError, DurabilityHealth, ExecutionProfile,
2464        LogAnchor, LogHash, PendingLag, RecoveryArtifactIdentity, RepairArtifactRole,
2465        SuccessorRestorePreparation, SuccessorRestoreRootState, RESTORE_INTENT_FILE,
2466        SUCCESSOR_RESTORE_COMPLETE_FILE, SUCCESSOR_RESTORE_INTENT_FILE,
2467        SUCCESSOR_RESTORE_LOCK_FILE,
2468    };
2469    #[cfg(feature = "kv")]
2470    use crate::{KvCommandV1, NodeConfig, NodeRuntime};
2471    use rhiza_archive::CheckpointIdentity;
2472    #[cfg(feature = "kv")]
2473    use rhiza_archive::ObjectArchiveStore;
2474    use rhiza_core::{EntryType, LogEntry};
2475    use rhiza_log::{FileLogStore, LogStore};
2476    #[cfg(feature = "kv")]
2477    use rhiza_obj_store::{ObjStore, ObjStoreConfig};
2478    #[cfg(feature = "kv")]
2479    use rhiza_quepaxa::ThreeNodeConsensus;
2480    use std::sync::{Arc, Barrier, Mutex};
2481
2482    #[test]
2483    fn snapshot_profile_requires_a_canonical_effective_cluster_identity() {
2484        assert_eq!(
2485            snapshot_profile("rhiza:sql:cluster-a").unwrap(),
2486            ExecutionProfile::Sqlite
2487        );
2488        assert_eq!(
2489            snapshot_profile("rhiza:graph:cluster-a").unwrap(),
2490            ExecutionProfile::Graph
2491        );
2492        assert_eq!(
2493            snapshot_profile("rhiza:kv:cluster-a").unwrap(),
2494            ExecutionProfile::Kv
2495        );
2496        assert!(matches!(
2497            snapshot_profile("cluster-a"),
2498            Err(DurabilityError::SnapshotVerification(_))
2499        ));
2500        assert!(snapshot_profile("rhiza:graph:").is_err());
2501    }
2502
2503    #[test]
2504    fn completed_successor_prepare_discards_owned_interrupted_repair_artifacts() {
2505        let root = tempfile::tempdir().unwrap();
2506        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2507        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2508
2509        let staging = root.path().join(".restore-stage-4242-0");
2510        std::fs::create_dir_all(staging.join("sqlite")).unwrap();
2511        let quarantine = root.path().join(".rebuildable-quarantine-4242-1");
2512        std::fs::create_dir_all(quarantine.join("sqlite")).unwrap();
2513        std::fs::create_dir_all(quarantine.join("consensus")).unwrap();
2514        let complete_marker = parse_successor_restore_receipt(receipt).unwrap();
2515        let complete_identity = RecoveryArtifactIdentity::Successor(complete_marker);
2516        write_repair_artifact_ownership(&staging, RepairArtifactRole::Staging, &complete_identity)
2517            .unwrap();
2518        write_repair_artifact_ownership(
2519            &quarantine,
2520            RepairArtifactRole::Quarantine,
2521            &complete_identity,
2522        )
2523        .unwrap();
2524
2525        let (lock, state, _) = prepare_successor_restore_root(root.path(), receipt).unwrap();
2526        assert!(state == SuccessorRestoreRootState::Complete);
2527        drop(lock);
2528        assert!(!staging.exists());
2529        assert!(!quarantine.exists());
2530    }
2531
2532    #[test]
2533    fn completed_successor_prepare_keeps_unowned_repair_artifact_and_fails_closed() {
2534        let root = tempfile::tempdir().unwrap();
2535        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"membership","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2536        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2537        let unowned = root.path().join(".rebuildable-quarantine-not-owned");
2538        std::fs::create_dir_all(&unowned).unwrap();
2539        std::fs::write(unowned.join("keep"), b"do not remove").unwrap();
2540
2541        assert!(matches!(
2542            prepare_successor_restore_root(root.path(), receipt),
2543            Err(DurabilityError::DataDirNotFresh(_))
2544        ));
2545        assert_eq!(
2546            std::fs::read(unowned.join("keep")).unwrap(),
2547            b"do not remove"
2548        );
2549    }
2550
2551    #[test]
2552    fn completed_successor_prepare_keeps_exact_shaped_lookalike_without_ownership_record() {
2553        let root = tempfile::tempdir().unwrap();
2554        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"membership","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2555        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2556        let lookalike = root.path().join(".rebuildable-quarantine-4242-1");
2557        std::fs::create_dir_all(lookalike.join("sqlite")).unwrap();
2558        std::fs::create_dir_all(lookalike.join("consensus")).unwrap();
2559
2560        assert!(matches!(
2561            prepare_successor_restore_root(root.path(), receipt),
2562            Err(DurabilityError::DataDirNotFresh(_))
2563        ));
2564        assert!(lookalike.join("sqlite").is_dir());
2565        assert!(lookalike.join("consensus").is_dir());
2566    }
2567
2568    #[test]
2569    fn intent_successor_prepare_keeps_ownerless_staging_and_fails_closed() {
2570        let root = tempfile::tempdir().unwrap();
2571        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2572        std::fs::write(root.path().join(SUCCESSOR_RESTORE_INTENT_FILE), receipt).unwrap();
2573        let staging = root.path().join(".restore-stage-4242-1");
2574        std::fs::create_dir_all(staging.join("sqlite")).unwrap();
2575
2576        assert!(matches!(
2577            prepare_successor_restore_root(root.path(), receipt),
2578            Err(DurabilityError::DataDirNotFresh(_))
2579        ));
2580        assert!(staging.join("sqlite").is_dir());
2581    }
2582
2583    #[test]
2584    fn intent_successor_prepare_discards_exactly_owned_staging_after_interruption() {
2585        let root = tempfile::tempdir().unwrap();
2586        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2587        std::fs::write(root.path().join(SUCCESSOR_RESTORE_INTENT_FILE), receipt).unwrap();
2588        let staging = root.path().join(".restore-stage-4242-1");
2589        std::fs::create_dir_all(staging.join("sqlite")).unwrap();
2590        write_repair_artifact_ownership(
2591            &staging,
2592            RepairArtifactRole::Staging,
2593            &RecoveryArtifactIdentity::Successor(parse_successor_restore_receipt(receipt).unwrap()),
2594        )
2595        .unwrap();
2596
2597        let (lock, state, _) = prepare_successor_restore_root(root.path(), receipt).unwrap();
2598        assert!(state == SuccessorRestoreRootState::Intent);
2599        drop(lock);
2600        assert!(!staging.exists());
2601    }
2602
2603    #[test]
2604    fn generic_restore_prepare_keeps_prefix_spoofed_staging_and_fails_closed() {
2605        let root = tempfile::tempdir().unwrap();
2606        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
2607        let intent = super::encode_restore_intent(
2608            &identity,
2609            "node-1",
2610            ExecutionProfile::Sqlite,
2611            LogAnchor::new(0, LogHash::ZERO),
2612        )
2613        .unwrap();
2614        std::fs::write(root.path().join(RESTORE_INTENT_FILE), &intent).unwrap();
2615        let staging = root.path().join(".restore-stage-4242-1");
2616        std::fs::create_dir_all(staging.join("sqlite")).unwrap();
2617
2618        assert!(matches!(
2619            super::prepare_fresh_restore_data_dir(root.path(), None, &intent, false),
2620            Err(DurabilityError::DataDirNotFresh(_))
2621        ));
2622        assert!(staging.join("sqlite").is_dir());
2623    }
2624
2625    #[cfg(unix)]
2626    #[test]
2627    fn successor_intent_symlink_fails_without_following_target() {
2628        use std::os::unix::fs::symlink;
2629
2630        let root = tempfile::tempdir().unwrap();
2631        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2632        let target = root.path().join("target");
2633        std::fs::write(&target, receipt).unwrap();
2634        let intent = root.path().join(SUCCESSOR_RESTORE_INTENT_FILE);
2635        symlink(&target, &intent).unwrap();
2636
2637        assert!(matches!(
2638            prepare_successor_restore_root(root.path(), receipt),
2639            Err(DurabilityError::DataDirNotFresh(_))
2640        ));
2641        assert_eq!(std::fs::read(&target).unwrap(), receipt);
2642        assert!(std::fs::symlink_metadata(&intent)
2643            .unwrap()
2644            .file_type()
2645            .is_symlink());
2646    }
2647
2648    #[test]
2649    fn successor_prepare_keeps_spoofed_restore_marker_tmp_directory_and_fails_closed() {
2650        let root = tempfile::tempdir().unwrap();
2651        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2652        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2653        let spoof = root.path().join(".restore-marker-tmp-not-generated");
2654        std::fs::create_dir(&spoof).unwrap();
2655
2656        assert!(matches!(
2657            prepare_successor_restore_root(root.path(), receipt),
2658            Err(DurabilityError::DataDirNotFresh(_))
2659        ));
2660        assert!(spoof.is_dir());
2661    }
2662
2663    #[test]
2664    fn bounded_regular_reader_rejects_file_larger_than_its_limit() {
2665        let root = tempfile::tempdir().unwrap();
2666        let path = root.path().join("oversized");
2667        std::fs::write(&path, b"12345").unwrap();
2668
2669        assert!(super::read_bounded_regular_file(&path, 4).is_err());
2670        assert_eq!(std::fs::read(&path).unwrap(), b"12345");
2671    }
2672
2673    #[test]
2674    fn rejoin_artifact_cleanup_removes_only_owned_stale_stage_and_quarantine() {
2675        let root = tempfile::tempdir().unwrap();
2676        let checkpoint = LogAnchor::new(0, LogHash::ZERO);
2677        let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
2678            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
2679            "node-1",
2680            ExecutionProfile::Sqlite,
2681            checkpoint,
2682        ));
2683        let stage = root.path().join(".restore-stage-4242-1");
2684        std::fs::create_dir_all(stage.join("sqlite")).unwrap();
2685        write_repair_artifact_ownership(&stage, RepairArtifactRole::Staging, &identity).unwrap();
2686        let quarantine = root.path().join(".rebuildable-quarantine-4242-2");
2687        std::fs::create_dir_all(quarantine.join("sqlite")).unwrap();
2688        write_repair_artifact_ownership(&quarantine, RepairArtifactRole::Quarantine, &identity)
2689            .unwrap();
2690
2691        super::cleanup_owned_recovery_artifacts(root.path(), &identity).unwrap();
2692        assert!(!stage.exists());
2693        assert!(!quarantine.exists());
2694    }
2695
2696    #[test]
2697    fn rejoin_artifact_cleanup_keeps_foreign_prefix_artifact_without_mutation() {
2698        let root = tempfile::tempdir().unwrap();
2699        let spoof = root.path().join(".restore-stage-foreign");
2700        std::fs::create_dir(&spoof).unwrap();
2701        let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
2702            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
2703            "node-1",
2704            ExecutionProfile::Sqlite,
2705            LogAnchor::new(0, LogHash::ZERO),
2706        ));
2707
2708        assert!(matches!(
2709            super::cleanup_owned_recovery_artifacts(root.path(), &identity),
2710            Err(DurabilityError::DataDirNotFresh(_))
2711        ));
2712        assert!(spoof.is_dir());
2713    }
2714
2715    #[cfg(unix)]
2716    #[test]
2717    fn successor_completion_keeps_existing_complete_symlink_and_intent() {
2718        use std::os::unix::fs::symlink;
2719
2720        let root = tempfile::tempdir().unwrap();
2721        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2722        let intent = root.path().join(SUCCESSOR_RESTORE_INTENT_FILE);
2723        std::fs::write(&intent, receipt).unwrap();
2724        let target = root.path().join("target");
2725        std::fs::write(&target, b"do not replace").unwrap();
2726        let complete = root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE);
2727        symlink(&target, &complete).unwrap();
2728        let lock = std::fs::OpenOptions::new()
2729            .read(true)
2730            .write(true)
2731            .create(true)
2732            .truncate(false)
2733            .open(root.path().join(SUCCESSOR_RESTORE_LOCK_FILE))
2734            .unwrap();
2735        let preparation = SuccessorRestorePreparation {
2736            tip: CheckpointTip::new(0, LogHash::ZERO),
2737            data_dir: root.path().to_path_buf(),
2738            identity: receipt.to_vec(),
2739            requires_recorder_install: true,
2740            _lock: lock,
2741        };
2742
2743        assert!(preparation.complete().is_err());
2744        assert_eq!(std::fs::read(&target).unwrap(), b"do not replace");
2745        assert_eq!(std::fs::read(&intent).unwrap(), receipt);
2746        assert!(std::fs::symlink_metadata(&complete)
2747            .unwrap()
2748            .file_type()
2749            .is_symlink());
2750    }
2751
2752    #[test]
2753    fn completed_successor_prepare_keeps_artifact_bound_to_a_different_complete_marker() {
2754        let root = tempfile::tempdir().unwrap();
2755        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2756        let foreign_receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"other-node","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2757        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2758        let artifact = root.path().join(".rebuildable-quarantine-4242-1");
2759        std::fs::create_dir_all(artifact.join("sqlite")).unwrap();
2760        write_repair_artifact_ownership(
2761            &artifact,
2762            RepairArtifactRole::Quarantine,
2763            &RecoveryArtifactIdentity::Successor(
2764                parse_successor_restore_receipt(foreign_receipt).unwrap(),
2765            ),
2766        )
2767        .unwrap();
2768
2769        assert!(matches!(
2770            prepare_successor_restore_root(root.path(), receipt),
2771            Err(DurabilityError::DataDirNotFresh(_))
2772        ));
2773        assert!(artifact.join("sqlite").is_dir());
2774    }
2775
2776    #[cfg(unix)]
2777    #[test]
2778    fn completed_successor_prepare_keeps_symlinked_repair_lookalike_and_fails_closed() {
2779        use std::os::unix::fs::symlink;
2780
2781        let root = tempfile::tempdir().unwrap();
2782        let receipt = br#"{"version":1,"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000","checkpoint_index":0,"checkpoint_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
2783        std::fs::write(root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE), receipt).unwrap();
2784        let target = root.path().join("target");
2785        std::fs::create_dir_all(&target).unwrap();
2786        let lookalike = root.path().join(".rebuildable-quarantine-4242-1");
2787        symlink(&target, &lookalike).unwrap();
2788
2789        assert!(matches!(
2790            prepare_successor_restore_root(root.path(), receipt),
2791            Err(DurabilityError::DataDirNotFresh(_))
2792        ));
2793        assert!(target.is_dir());
2794        assert!(std::fs::symlink_metadata(&lookalike)
2795            .unwrap()
2796            .file_type()
2797            .is_symlink());
2798    }
2799
2800    #[test]
2801    fn sqlite_restore_suffix_rejects_legacy_commands_during_preflight() {
2802        let payload = b"put\tlegacy\tkey\tvalue".to_vec();
2803        let entry = LogEntry {
2804            cluster_id: "rhiza:sql:cluster-a".into(),
2805            epoch: 1,
2806            config_id: 1,
2807            index: 1,
2808            entry_type: EntryType::Command,
2809            prev_hash: LogHash::ZERO,
2810            hash: LogEntry::calculate_hash(
2811                "rhiza:sql:cluster-a",
2812                1,
2813                1,
2814                1,
2815                EntryType::Command,
2816                LogHash::ZERO,
2817                &payload,
2818            ),
2819            payload,
2820        };
2821
2822        assert!(matches!(
2823            validate_restored_suffix(ExecutionProfile::Sqlite, &[entry]),
2824            Err(DurabilityError::SnapshotVerification(message)) if message.contains("QWAL")
2825        ));
2826    }
2827
2828    #[test]
2829    fn local_qlog_accepts_ahead_tip_when_checkpoint_entry_is_retained() {
2830        let root = tempfile::tempdir().unwrap();
2831        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
2832        let log = FileLogStore::open(
2833            root.path().join("consensus/log"),
2834            identity.cluster_id(),
2835            1,
2836            1,
2837        )
2838        .unwrap();
2839        let entry = |index, previous| {
2840            let hash = LogEntry::calculate_hash(
2841                identity.cluster_id(),
2842                index,
2843                1,
2844                1,
2845                EntryType::Noop,
2846                previous,
2847                &[],
2848            );
2849            LogEntry {
2850                cluster_id: identity.cluster_id().into(),
2851                epoch: 1,
2852                config_id: 1,
2853                index,
2854                entry_type: EntryType::Noop,
2855                payload: Vec::new(),
2856                prev_hash: previous,
2857                hash,
2858            }
2859        };
2860        let first = entry(1, LogHash::ZERO);
2861        let second = entry(2, first.hash);
2862        log.append_batch(&[first.clone(), second.clone()]).unwrap();
2863
2864        assert_eq!(
2865            validate_local_qlog(
2866                root.path(),
2867                &identity,
2868                rhiza_core::LogAnchor::new(first.index, first.hash),
2869            )
2870            .unwrap(),
2871            rhiza_core::LogAnchor::new(2, second.hash)
2872        );
2873        assert!(validate_local_qlog(
2874            root.path(),
2875            &identity,
2876            rhiza_core::LogAnchor::new(2, LogHash::digest(&[b"conflicting"])),
2877        )
2878        .is_err());
2879        assert!(validate_local_qlog(
2880            root.path(),
2881            &identity,
2882            rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"ahead checkpoint"])),
2883        )
2884        .is_err());
2885    }
2886
2887    #[test]
2888    fn local_qlog_treats_absent_log_as_genesis_only() {
2889        let root = tempfile::tempdir().unwrap();
2890        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
2891        let genesis = rhiza_core::LogAnchor::new(0, LogHash::ZERO);
2892
2893        assert_eq!(
2894            validate_local_qlog(root.path(), &identity, genesis).unwrap(),
2895            genesis
2896        );
2897        assert!(validate_local_qlog(
2898            root.path(),
2899            &identity,
2900            rhiza_core::LogAnchor::new(1, LogHash::digest(&[b"checkpoint"])),
2901        )
2902        .is_err());
2903    }
2904
2905    #[test]
2906    fn restore_intent_remains_until_completion_marker_is_durable_and_retryable() {
2907        let root = tempfile::tempdir().unwrap();
2908        let data_dir = root.path().join("data");
2909        std::fs::create_dir_all(&data_dir).unwrap();
2910        let intent = super::encode_restore_intent(
2911            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
2912            "node-1",
2913            ExecutionProfile::Sqlite,
2914            rhiza_core::LogAnchor::new(0, LogHash::ZERO),
2915        )
2916        .unwrap();
2917        std::fs::write(data_dir.join(RESTORE_INTENT_FILE), &intent).unwrap();
2918        std::fs::create_dir(data_dir.join("identity.json")).unwrap();
2919        let staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
2920
2921        assert!(super::publish_restore_staging(
2922            &staging,
2923            &data_dir,
2924            true,
2925            Some(("identity.json", b"identity-fixture")),
2926        )
2927        .is_err());
2928        assert_eq!(
2929            std::fs::read(data_dir.join(RESTORE_INTENT_FILE)).unwrap(),
2930            intent
2931        );
2932
2933        std::fs::remove_dir(data_dir.join("identity.json")).unwrap();
2934        let retry_staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
2935        super::publish_restore_staging(
2936            &retry_staging,
2937            &data_dir,
2938            true,
2939            Some(("identity.json", b"identity-fixture")),
2940        )
2941        .unwrap();
2942        assert_eq!(
2943            std::fs::read(data_dir.join("identity.json")).unwrap(),
2944            b"identity-fixture"
2945        );
2946        assert!(!data_dir.join(RESTORE_INTENT_FILE).exists());
2947    }
2948
2949    #[cfg(feature = "kv")]
2950    #[tokio::test]
2951    async fn kv_compacted_rejoin_restores_missing_or_corrupt_views_without_touching_recorder() {
2952        let root = tempfile::tempdir().unwrap();
2953        let identity = CheckpointIdentity::new("rhiza:kv:cluster-a", 1, 1, 1);
2954        let archive = ObjectArchiveStore::new_checkpoint_for_single_process(
2955            ObjStore::new(ObjStoreConfig::Local {
2956                root: root.path().join("archive"),
2957            })
2958            .unwrap(),
2959            identity.clone(),
2960        );
2961        archive.initialize_checkpoint().await.unwrap();
2962        let source_dir = root.path().join("source");
2963        let config = NodeConfig::new_embedded(
2964            "cluster-a",
2965            "node-1",
2966            source_dir,
2967            1,
2968            1,
2969            ["node-1", "node-2", "node-3"],
2970        )
2971        .unwrap()
2972        .with_execution_profile(ExecutionProfile::Kv)
2973        .unwrap()
2974        .with_recovery_generation(1)
2975        .unwrap();
2976        let consensus = Arc::new(
2977            ThreeNodeConsensus::from_recovered_tip(
2978                "rhiza:kv:cluster-a",
2979                "node-1",
2980                1,
2981                1,
2982                [
2983                    root.path().join("recorders/node-1"),
2984                    root.path().join("recorders/node-2"),
2985                    root.path().join("recorders/node-3"),
2986                ],
2987                1,
2988                LogHash::ZERO,
2989            )
2990            .unwrap(),
2991        );
2992        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
2993        let coordinator = CheckpointCoordinator::open(archive.clone(), DurabilityMode::Sync)
2994            .await
2995            .unwrap();
2996        let committed = runtime
2997            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"value".to_vec()).unwrap())
2998            .unwrap();
2999        coordinator.note_committed(committed.applied_index());
3000        coordinator
3001            .flush_runtime(&runtime, committed.applied_index())
3002            .await
3003            .unwrap();
3004        let checkpoint_root = runtime.checkpoint_compact(&coordinator).await.unwrap();
3005
3006        let target = root.path().join("target");
3007        restore_checkpoint_to_fresh_data_dir_for_node(archive.clone(), &target, "node-1")
3008            .await
3009            .unwrap();
3010        validate_local_recovery_view(
3011            &target,
3012            &identity,
3013            "node-1",
3014            ExecutionProfile::Kv,
3015            *checkpoint_root.compacted(),
3016        )
3017        .unwrap();
3018        std::fs::create_dir_all(target.join("recorder")).unwrap();
3019        std::fs::write(target.join("recorder/sentinel"), b"keep-me").unwrap();
3020
3021        std::fs::remove_dir_all(target.join("consensus")).unwrap();
3022        assert!(validate_local_recovery_view(
3023            &target,
3024            &identity,
3025            "node-1",
3026            ExecutionProfile::Kv,
3027            *checkpoint_root.compacted(),
3028        )
3029        .is_err());
3030        restore_checkpoint_for_rejoin_preserving_recorder(
3031            archive.clone(),
3032            &target,
3033            "node-1",
3034            ExecutionProfile::Kv,
3035            "identity.json",
3036            b"identity-fixture",
3037            false,
3038        )
3039        .await
3040        .unwrap();
3041
3042        std::fs::write(target.join("kv/data.redb"), b"corrupt").unwrap();
3043        assert!(validate_local_recovery_view(
3044            &target,
3045            &identity,
3046            "node-1",
3047            ExecutionProfile::Kv,
3048            *checkpoint_root.compacted(),
3049        )
3050        .is_err());
3051        restore_checkpoint_for_rejoin_preserving_recorder(
3052            archive,
3053            &target,
3054            "node-1",
3055            ExecutionProfile::Kv,
3056            "identity.json",
3057            b"identity-fixture",
3058            false,
3059        )
3060        .await
3061        .unwrap();
3062        assert_eq!(
3063            std::fs::read(target.join("recorder/sentinel")).unwrap(),
3064            b"keep-me"
3065        );
3066    }
3067
3068    fn successor_receipt(index: u64, hash_byte: char, generation: u64) -> Vec<u8> {
3069        serde_json::to_vec(&serde_json::json!({
3070            "version": 1,
3071            "cluster_id": "cluster-a",
3072            "epoch": 1,
3073            "target_config_id": 2,
3074            "recovery_generation": generation,
3075            "node_id": "node-1",
3076            "membership_digest": "digest",
3077            "predecessor_config_id": 1,
3078            "stop_index": 4,
3079            "stop_hash": "stop",
3080            "checkpoint_index": index,
3081            "checkpoint_hash": hash_byte.to_string().repeat(64),
3082        }))
3083        .unwrap()
3084    }
3085
3086    #[test]
3087    fn completed_successor_receipt_allows_only_forward_checkpoint_progress() {
3088        let expected = successor_receipt(8, '8', 1);
3089
3090        assert!(completed_successor_identity_matches(
3091            &successor_receipt(4, '4', 1),
3092            &expected
3093        ));
3094        assert!(!completed_successor_identity_matches(
3095            &successor_receipt(8, '9', 1),
3096            &expected
3097        ));
3098        assert!(!completed_successor_identity_matches(
3099            &successor_receipt(9, '9', 1),
3100            &expected
3101        ));
3102        assert!(!completed_successor_identity_matches(
3103            &successor_receipt(4, '4', 2),
3104            &expected
3105        ));
3106        let mut malformed =
3107            serde_json::from_slice::<serde_json::Value>(&successor_receipt(4, '4', 1)).unwrap();
3108        malformed["checkpoint_hash"] = serde_json::json!(7);
3109        assert!(!completed_successor_identity_matches(
3110            &serde_json::to_vec(&malformed).unwrap(),
3111            &expected
3112        ));
3113    }
3114
3115    #[test]
3116    fn concurrent_flush_completion_cannot_regress_the_durable_tip() {
3117        let newer = CheckpointTip::new(8, LogHash::digest(&[b"newer"]));
3118        let older = CheckpointTip::new(4, LogHash::digest(&[b"older"]));
3119        let mut state = CoordinatorState {
3120            durable_tip: newer,
3121            committed_index: 8,
3122            pending_lag: None,
3123            health: DurabilityHealth::Available,
3124        };
3125
3126        mark_durable_state(&mut state, older);
3127
3128        assert_eq!(state.durable_tip, newer);
3129    }
3130
3131    #[test]
3132    fn checkpoint_observation_rejects_same_index_hash_conflict_without_mutation() {
3133        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3134        let conflicting = CheckpointTip::new(8, LogHash::digest(&[b"conflicting"]));
3135        let state = Mutex::new(CoordinatorState {
3136            durable_tip: current,
3137            committed_index: 8,
3138            pending_lag: None,
3139            health: DurabilityHealth::Available,
3140        });
3141
3142        assert!(matches!(
3143            observe_durable_tip(&state, conflicting),
3144            Err(DurabilityError::SnapshotVerification(_))
3145        ));
3146        assert_eq!(state.lock().unwrap().durable_tip, current);
3147    }
3148
3149    #[test]
3150    fn checkpoint_observation_rejects_remote_rollback_without_mutation() {
3151        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3152        let older = CheckpointTip::new(7, LogHash::digest(&[b"older"]));
3153        let state = Mutex::new(CoordinatorState {
3154            durable_tip: current,
3155            committed_index: 8,
3156            pending_lag: None,
3157            health: DurabilityHealth::Available,
3158        });
3159
3160        assert!(matches!(
3161            observe_durable_tip(&state, older),
3162            Err(DurabilityError::SnapshotVerification(_))
3163        ));
3164        assert_eq!(state.lock().unwrap().durable_tip, current);
3165    }
3166
3167    #[test]
3168    fn concurrent_conflicting_checkpoint_observations_accept_exactly_one_tip() {
3169        let state = Arc::new(Mutex::new(CoordinatorState {
3170            durable_tip: CheckpointTip::new(0, LogHash::ZERO),
3171            committed_index: 0,
3172            pending_lag: None,
3173            health: DurabilityHealth::Available,
3174        }));
3175        let start = Arc::new(Barrier::new(3));
3176        let results = [b"first".as_slice(), b"second".as_slice()]
3177            .into_iter()
3178            .map(|label| {
3179                let state = Arc::clone(&state);
3180                let start = Arc::clone(&start);
3181                std::thread::spawn(move || {
3182                    let tip = CheckpointTip::new(1, LogHash::digest(&[label]));
3183                    start.wait();
3184                    (tip, observe_durable_tip(&state, tip))
3185                })
3186            })
3187            .collect::<Vec<_>>();
3188        start.wait();
3189        let results = results
3190            .into_iter()
3191            .map(|thread| thread.join().unwrap())
3192            .collect::<Vec<_>>();
3193
3194        assert_eq!(
3195            results.iter().filter(|(_, result)| result.is_ok()).count(),
3196            1
3197        );
3198        assert_eq!(
3199            results.iter().filter(|(_, result)| result.is_err()).count(),
3200            1
3201        );
3202        let accepted = results
3203            .iter()
3204            .find_map(|(tip, result)| result.is_ok().then_some(*tip))
3205            .unwrap();
3206        assert_eq!(state.lock().unwrap().durable_tip, accepted);
3207    }
3208
3209    #[test]
3210    fn durable_progress_clears_lag_only_after_reaching_the_committed_index() {
3211        let mut state = CoordinatorState {
3212            durable_tip: CheckpointTip::new(2, LogHash::digest(&[b"two"])),
3213            committed_index: 4,
3214            pending_lag: Some(PendingLag::Recovered),
3215            health: DurabilityHealth::Unavailable,
3216        };
3217        let partial = CheckpointTip::new(3, LogHash::digest(&[b"three"]));
3218        mark_durable_state(&mut state, partial);
3219        assert!(state.pending_lag.is_some());
3220        assert_eq!(state.health, DurabilityHealth::Unavailable);
3221
3222        let complete = CheckpointTip::new(4, LogHash::digest(&[b"four"]));
3223        mark_durable_state(&mut state, complete);
3224
3225        assert_eq!(state.durable_tip, complete);
3226        assert!(state.pending_lag.is_none());
3227        assert_eq!(state.health, DurabilityHealth::Available);
3228    }
3229}