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