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,
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 => false,
1432    })
1433}
1434
1435pub async fn restore_checkpoint_for_rejoin_preserving_recorder(
1436    store: ObjectArchiveStore,
1437    data_dir: impl AsRef<Path>,
1438    target_node_id: &str,
1439    execution_profile: ExecutionProfile,
1440    marker_name: &str,
1441    marker_contents: &[u8],
1442) -> Result<CheckpointTip, DurabilityError> {
1443    if target_node_id.is_empty() {
1444        return Err(DurabilityError::SnapshotVerification(
1445            "target node_id is empty".into(),
1446        ));
1447    }
1448    validate_restore_marker_name(marker_name)?;
1449    let data_dir = data_dir.as_ref();
1450    let identity = store.checkpoint_identity()?.clone();
1451    if snapshot_profile(identity.cluster_id())? != execution_profile
1452        || execution_profile == ExecutionProfile::Graph
1453    {
1454        return Err(DurabilityError::SnapshotVerification(
1455            "rejoin recovery only replaces matching SQL or KV recovery views".into(),
1456        ));
1457    }
1458    let restored = store.restore_checkpoint_state().await?;
1459    let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1460    let intent = encode_restore_intent(
1461        &identity,
1462        target_node_id,
1463        execution_profile,
1464        checkpoint_root,
1465    )?;
1466    let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1467        &identity,
1468        target_node_id,
1469        execution_profile,
1470        checkpoint_root,
1471    ));
1472    checkpoint_restore_in_progress(
1473        data_dir,
1474        &identity,
1475        target_node_id,
1476        execution_profile,
1477        checkpoint_root,
1478    )?;
1479    cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
1480    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1481    install_restored_checkpoint(
1482        &identity,
1483        &restored,
1484        data_dir,
1485        RestoreInstallOptions {
1486            target_node_id: Some(target_node_id),
1487            remove_generic_intent: true,
1488            replace_rebuildable: true,
1489            recovery_identity: Some(&recovery_identity),
1490            completion_marker: Some((marker_name, marker_contents)),
1491        },
1492    )
1493}
1494
1495async fn restore_checkpoint_to_fresh_data_dir_with_target(
1496    store: ObjectArchiveStore,
1497    data_dir: &Path,
1498    target_node_id: Option<&str>,
1499    completion_marker: Option<(&str, &[u8])>,
1500) -> Result<CheckpointTip, DurabilityError> {
1501    let identity = store.checkpoint_identity()?.clone();
1502    store
1503        .load_checkpoint()
1504        .await?
1505        .ok_or(DurabilityError::MissingCheckpoint)?;
1506    let restored = store.restore_checkpoint_state().await?;
1507    let target_node_id = target_node_id.unwrap_or("<unbound-restore>");
1508    let profile = snapshot_profile(identity.cluster_id())?;
1509    let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1510    let intent = encode_restore_intent(&identity, target_node_id, profile, checkpoint_root)?;
1511    let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1512        &identity,
1513        target_node_id,
1514        profile,
1515        checkpoint_root,
1516    ));
1517    prepare_fresh_restore_data_dir(data_dir, completion_marker.map(|(name, _)| name), &intent)?;
1518    publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1519    install_restored_checkpoint(
1520        &identity,
1521        &restored,
1522        data_dir,
1523        RestoreInstallOptions {
1524            target_node_id: Some(target_node_id),
1525            remove_generic_intent: true,
1526            replace_rebuildable: false,
1527            recovery_identity: Some(&recovery_identity),
1528            completion_marker,
1529        },
1530    )
1531}
1532
1533struct RestoreInstallOptions<'a> {
1534    target_node_id: Option<&'a str>,
1535    remove_generic_intent: bool,
1536    replace_rebuildable: bool,
1537    recovery_identity: Option<&'a RecoveryArtifactIdentity>,
1538    completion_marker: Option<(&'a str, &'a [u8])>,
1539}
1540
1541struct RestoredCheckpointStaging {
1542    path: PathBuf,
1543    tip: CheckpointTip,
1544}
1545
1546fn install_restored_checkpoint(
1547    identity: &CheckpointIdentity,
1548    restored: &RestoredCheckpoint,
1549    data_dir: &Path,
1550    options: RestoreInstallOptions<'_>,
1551) -> Result<CheckpointTip, DurabilityError> {
1552    let staged = stage_restored_checkpoint(
1553        identity,
1554        restored,
1555        data_dir,
1556        options.target_node_id,
1557        options.recovery_identity,
1558    )?;
1559    let tip = staged.tip;
1560    let staging = staged.path;
1561    let profile = snapshot_profile(identity.cluster_id())?;
1562    let result = (|| -> Result<(), DurabilityError> {
1563        if options.replace_rebuildable {
1564            quarantine_rebuildable_view(data_dir, profile, options.recovery_identity)?;
1565        }
1566        publish_restore_staging(
1567            &staging,
1568            data_dir,
1569            options.remove_generic_intent,
1570            options.completion_marker,
1571        )
1572    })();
1573    if result.is_err() {
1574        let _ = fs::remove_dir_all(&staging);
1575    }
1576    result?;
1577    Ok(tip)
1578}
1579
1580fn stage_restored_checkpoint(
1581    identity: &CheckpointIdentity,
1582    restored: &RestoredCheckpoint,
1583    staging_parent: &Path,
1584    target_node_id: Option<&str>,
1585    recovery_identity: Option<&RecoveryArtifactIdentity>,
1586) -> Result<RestoredCheckpointStaging, DurabilityError> {
1587    let tip = *restored.tip();
1588    let profile = snapshot_profile(identity.cluster_id())?;
1589    validate_restored_suffix(profile, restored.suffix())?;
1590    let staging = create_restore_staging_dir(staging_parent, recovery_identity)?;
1591    let result = (|| -> Result<(), DurabilityError> {
1592        if let Some(snapshot) = restored.snapshot() {
1593            install_profile_snapshot(identity, snapshot, &staging, target_node_id)?;
1594        }
1595
1596        if restored.snapshot().is_some() || !restored.suffix().is_empty() {
1597            let initial_configuration = restored
1598                .snapshot()
1599                .map(|snapshot| snapshot.anchor().configuration_state().clone())
1600                .unwrap_or_else(|| ConfigurationState::active(identity.config_id(), LogHash::ZERO));
1601            let log = FileLogStore::open_with_configuration(
1602                staging.join("consensus/log"),
1603                identity.cluster_id(),
1604                identity.epoch(),
1605                initial_configuration,
1606            )?;
1607            if let Some(snapshot) = restored.snapshot() {
1608                log.install_recovery_anchor(
1609                    snapshot.anchor(),
1610                    identity.recovery_generation(),
1611                    snapshot.anchor().configuration_state(),
1612                )?;
1613            }
1614            for batch in restored.suffix().chunks(FLUSH_BATCH_ENTRIES as usize) {
1615                log.append_batch(batch)?;
1616            }
1617            let installed_tip = log.logical_state()?.tip;
1618            if installed_tip.as_ref().map(|tip| (tip.index(), tip.hash()))
1619                != Some((tip.index(), tip.hash()))
1620            {
1621                return Err(DurabilityError::SnapshotVerification(
1622                    "installed qlog tip does not match checkpoint tip".into(),
1623                ));
1624            }
1625        }
1626        sync_directory(&staging)
1627    })();
1628    if result.is_err() {
1629        let _ = fs::remove_dir_all(&staging);
1630    }
1631    result?;
1632    Ok(RestoredCheckpointStaging { path: staging, tip })
1633}
1634
1635fn validate_restored_suffix(
1636    profile: ExecutionProfile,
1637    suffix: &[LogEntry],
1638) -> Result<(), DurabilityError> {
1639    for entry in suffix {
1640        crate::validate_profile_entry_shape(profile, entry)
1641            .map_err(DurabilityError::SnapshotVerification)?;
1642    }
1643    Ok(())
1644}
1645
1646fn validate_local_qlog(
1647    data_dir: &Path,
1648    identity: &CheckpointIdentity,
1649    checkpoint_root: LogAnchor,
1650) -> Result<LogAnchor, DurabilityError> {
1651    validate_local_qlog_with_configuration(
1652        data_dir,
1653        identity,
1654        checkpoint_root,
1655        ConfigurationState::active(identity.config_id(), LogHash::ZERO),
1656    )
1657}
1658
1659fn validate_local_qlog_with_configuration(
1660    data_dir: &Path,
1661    identity: &CheckpointIdentity,
1662    checkpoint_root: LogAnchor,
1663    initial_configuration: ConfigurationState,
1664) -> Result<LogAnchor, DurabilityError> {
1665    let path = data_dir.join("consensus/log");
1666    if !path_has_state(&path)? {
1667        if checkpoint_root == LogAnchor::new(0, LogHash::ZERO) {
1668            return Ok(checkpoint_root);
1669        }
1670        return Err(DurabilityError::SnapshotVerification(
1671            "local qlog is missing or empty".into(),
1672        ));
1673    }
1674    let log = FileLogStore::open_with_configuration(
1675        path,
1676        identity.cluster_id(),
1677        identity.epoch(),
1678        initial_configuration,
1679    )?;
1680    let state = log.logical_state()?;
1681    let tip = state
1682        .tip
1683        .ok_or_else(|| DurabilityError::SnapshotVerification("local qlog has no tip".into()))?;
1684    if tip.index() < checkpoint_root.index() {
1685        return Err(DurabilityError::SnapshotVerification(format!(
1686            "local qlog tip {}/{} is behind checkpoint root {}/{}",
1687            tip.index(),
1688            tip.hash().to_hex(),
1689            checkpoint_root.index(),
1690            checkpoint_root.hash().to_hex(),
1691        )));
1692    }
1693    if checkpoint_root.index() == 0 {
1694        if checkpoint_root.hash() != LogHash::ZERO {
1695            return Err(DurabilityError::SnapshotVerification(
1696                "checkpoint genesis hash is not zero".into(),
1697            ));
1698        }
1699        return Ok(tip);
1700    }
1701    let included_hash = match state.anchor.as_ref() {
1702        Some(anchor) if anchor.compacted().index() == checkpoint_root.index() => {
1703            Some(anchor.compacted().hash())
1704        }
1705        Some(anchor) if anchor.compacted().index() > checkpoint_root.index() => {
1706            return Err(DurabilityError::SnapshotVerification(
1707                "local qlog compacted past checkpoint root without exact inclusion evidence".into(),
1708            ));
1709        }
1710        _ => log.read(checkpoint_root.index())?.map(|entry| entry.hash),
1711    };
1712    if included_hash != Some(checkpoint_root.hash()) {
1713        return Err(DurabilityError::SnapshotVerification(format!(
1714            "local qlog does not include checkpoint root {} with its exact hash",
1715            checkpoint_root.index(),
1716        )));
1717    }
1718    Ok(tip)
1719}
1720
1721fn install_profile_snapshot(
1722    identity: &CheckpointIdentity,
1723    snapshot: &rhiza_archive::RestoredCheckpointSnapshot,
1724    staging: &Path,
1725    target_node_id: Option<&str>,
1726) -> Result<(), DurabilityError> {
1727    match snapshot_profile(identity.cluster_id())? {
1728        ExecutionProfile::Sqlite => {
1729            #[cfg(feature = "sql")]
1730            {
1731                validate_anchor_fingerprint(
1732                    snapshot.anchor(),
1733                    sql_executor_fingerprint().map_err(|error| {
1734                        DurabilityError::SnapshotVerification(error.to_string())
1735                    })?,
1736                )?;
1737                let path = staging.join("sqlite/db.sqlite");
1738                let node_id = target_node_id.ok_or_else(|| {
1739                    DurabilityError::SnapshotVerification(
1740                        "SQLite QWAL snapshot restore requires a target node_id".into(),
1741                    )
1742                })?;
1743                restore_recovery_snapshot_file(path, snapshot.bytes(), snapshot.anchor(), node_id)
1744                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1745            }
1746            #[cfg(not(feature = "sql"))]
1747            Err(DurabilityError::SnapshotVerification(
1748                "sql execution profile is not compiled in".into(),
1749            ))
1750        }
1751        ExecutionProfile::Graph => {
1752            #[cfg(feature = "graph")]
1753            {
1754                let decoded = decode_graph_snapshot(snapshot.bytes())
1755                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1756                validate_decoded_snapshot_anchor(
1757                    snapshot.anchor(),
1758                    decoded.cluster_id(),
1759                    decoded.epoch(),
1760                    decoded.config_id(),
1761                    decoded.applied_index(),
1762                    decoded.applied_hash(),
1763                    decoded.materializer_fingerprint(),
1764                )?;
1765                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1766                restore_graph_snapshot_file(
1767                    staging.join("ladybug/graph.lbug"),
1768                    &decoded,
1769                    target_node_id,
1770                )
1771                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1772            }
1773            #[cfg(not(feature = "graph"))]
1774            Err(DurabilityError::SnapshotVerification(
1775                "graph recovery support is not compiled in".into(),
1776            ))
1777        }
1778        ExecutionProfile::Kv => {
1779            #[cfg(feature = "kv")]
1780            {
1781                let decoded = decode_kv_snapshot(snapshot.bytes())
1782                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1783                validate_decoded_snapshot_anchor(
1784                    snapshot.anchor(),
1785                    decoded.cluster_id(),
1786                    decoded.epoch(),
1787                    decoded.config_id(),
1788                    decoded.applied_index(),
1789                    decoded.applied_hash(),
1790                    decoded.materializer_fingerprint(),
1791                )?;
1792                let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1793                restore_kv_snapshot_file(staging.join("kv/data.redb"), &decoded, target_node_id)
1794                    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1795            }
1796            #[cfg(not(feature = "kv"))]
1797            Err(DurabilityError::SnapshotVerification(
1798                "KV recovery support is not compiled in".into(),
1799            ))
1800        }
1801    }
1802}
1803
1804fn snapshot_profile(cluster_id: &str) -> Result<ExecutionProfile, DurabilityError> {
1805    if matches!(cluster_id.strip_prefix("rhiza:graph:"), Some(logical) if !logical.is_empty()) {
1806        Ok(ExecutionProfile::Graph)
1807    } else if matches!(cluster_id.strip_prefix("rhiza:kv:"), Some(logical) if !logical.is_empty()) {
1808        Ok(ExecutionProfile::Kv)
1809    } else if matches!(cluster_id.strip_prefix("rhiza:sql:"), Some(logical) if !logical.is_empty())
1810    {
1811        Ok(ExecutionProfile::Sqlite)
1812    } else {
1813        Err(DurabilityError::SnapshotVerification(
1814            "snapshot checkpoint identity has no canonical execution profile prefix".into(),
1815        ))
1816    }
1817}
1818
1819fn validate_anchor_fingerprint(
1820    anchor: &RecoveryAnchor,
1821    expected: LogHash,
1822) -> Result<(), DurabilityError> {
1823    if anchor.executor_fingerprint() != expected {
1824        return Err(DurabilityError::SnapshotVerification(
1825            "snapshot executor fingerprint does not match this binary".into(),
1826        ));
1827    }
1828    Ok(())
1829}
1830
1831#[cfg(any(feature = "graph", feature = "kv"))]
1832fn validate_decoded_snapshot_anchor(
1833    anchor: &RecoveryAnchor,
1834    cluster_id: &str,
1835    epoch: u64,
1836    config_id: u64,
1837    applied_index: LogIndex,
1838    applied_hash: LogHash,
1839    materializer_fingerprint: LogHash,
1840) -> Result<(), DurabilityError> {
1841    validate_anchor_fingerprint(anchor, materializer_fingerprint)?;
1842    if anchor.cluster_id() != cluster_id
1843        || anchor.epoch() != epoch
1844        || anchor.config_id() != config_id
1845        || anchor.compacted().index() != applied_index
1846        || anchor.compacted().hash() != applied_hash
1847    {
1848        return Err(DurabilityError::SnapshotVerification(
1849            "decoded snapshot identity does not match its recovery anchor".into(),
1850        ));
1851    }
1852    Ok(())
1853}
1854
1855pub async fn prestage_successor_checkpoint(
1856    store: ObjectArchiveStore,
1857    prestage_dir: impl AsRef<Path>,
1858    predecessor_configuration: ConfigurationState,
1859    target_node_id: &str,
1860    execution_profile: ExecutionProfile,
1861    target_config_id: u64,
1862    target_membership_digest: LogHash,
1863) -> Result<SuccessorPrestage, DurabilityError> {
1864    if target_node_id.is_empty() {
1865        return Err(DurabilityError::SnapshotVerification(
1866            "successor prestage target node_id is empty".into(),
1867        ));
1868    }
1869    let identity = store.checkpoint_identity()?.clone();
1870    if !predecessor_configuration.is_active()
1871        || predecessor_configuration.config_id() != identity.config_id()
1872    {
1873        return Err(DurabilityError::SnapshotVerification(
1874            "successor prestage predecessor configuration does not match the checkpoint".into(),
1875        ));
1876    }
1877    if snapshot_profile(identity.cluster_id())? != execution_profile {
1878        return Err(DurabilityError::SnapshotVerification(
1879            "successor prestage profile does not match checkpoint identity".into(),
1880        ));
1881    }
1882    if identity
1883        .config_id()
1884        .checked_add(1)
1885        .filter(|next| *next == target_config_id)
1886        .is_none()
1887    {
1888        return Err(DurabilityError::SnapshotVerification(
1889            "successor prestage target config_id is not the next configuration".into(),
1890        ));
1891    }
1892    let loaded = store
1893        .load_checkpoint()
1894        .await?
1895        .ok_or(DurabilityError::MissingCheckpoint)?;
1896    if loaded.manifest().identity() != &identity {
1897        return Err(DurabilityError::SnapshotVerification(
1898            "successor prestage checkpoint identity changed while loading".into(),
1899        ));
1900    }
1901    let expected = SuccessorPrestageIdentity {
1902        cluster_id: identity.cluster_id().to_owned(),
1903        epoch: identity.epoch(),
1904        predecessor_config_id: identity.config_id(),
1905        predecessor_membership_digest: predecessor_configuration.digest().to_hex(),
1906        predecessor_recovery_generation: identity.recovery_generation(),
1907        node_id: target_node_id.to_owned(),
1908        execution_profile,
1909        target_config_id,
1910        target_membership_digest: target_membership_digest.to_hex(),
1911        seed_index: loaded.manifest().tip().index(),
1912        seed_hash: loaded.manifest().tip().hash().to_hex(),
1913    };
1914    validate_successor_prestage_identity(&expected)?;
1915    let prestage_dir = prestage_dir.as_ref();
1916    let mut prestage =
1917        prepare_successor_prestage_root(prestage_dir, Some(&expected), &predecessor_configuration)?;
1918    match prestage.state {
1919        SuccessorPrestageState::Ready
1920        | SuccessorPrestageState::Published
1921        | SuccessorPrestageState::Finalized => return Ok(prestage),
1922        SuccessorPrestageState::Preparing => {}
1923    }
1924
1925    cleanup_preparing_successor_prestage(prestage_dir, &expected)?;
1926    let restored = store.restore_checkpoint_state().await?;
1927    if restored.tip() != loaded.manifest().tip() {
1928        return Err(DurabilityError::SnapshotVerification(
1929            "successor prestage checkpoint changed during restore".into(),
1930        ));
1931    }
1932    let recovery_identity = RecoveryArtifactIdentity::Prestage(expected.clone());
1933    let staged = stage_restored_checkpoint(
1934        &identity,
1935        &restored,
1936        prestage_dir,
1937        Some(target_node_id),
1938        Some(&recovery_identity),
1939    )?;
1940    if let Err(error) = publish_restore_staging(&staged.path, prestage_dir, false, None) {
1941        let _ = fs::remove_dir_all(&staged.path);
1942        return Err(error);
1943    }
1944    fs::rename(
1945        prestage_dir.join(SUCCESSOR_PRESTAGE_INTENT_FILE),
1946        prestage_dir.join(SUCCESSOR_PRESTAGE_READY_FILE),
1947    )?;
1948    sync_directory(prestage_dir)?;
1949    prestage.state = SuccessorPrestageState::Ready;
1950    Ok(prestage)
1951}
1952
1953pub fn inspect_successor_prestage(
1954    prestage_dir: impl AsRef<Path>,
1955    predecessor_configuration: ConfigurationState,
1956) -> Result<SuccessorPrestage, DurabilityError> {
1957    prepare_successor_prestage_root(prestage_dir.as_ref(), None, &predecessor_configuration)
1958}
1959
1960pub fn publish_successor_prestage(
1961    mut prestage: SuccessorPrestage,
1962    data_dir: impl AsRef<Path>,
1963) -> Result<SuccessorPrestage, DurabilityError> {
1964    let data_dir = data_dir.as_ref();
1965    match prestage.state {
1966        SuccessorPrestageState::Published if prestage.path == data_dir => return Ok(prestage),
1967        SuccessorPrestageState::Ready => {}
1968        _ => return Err(DurabilityError::PreconditionFailed),
1969    }
1970    if prestage.path != data_dir {
1971        match fs::symlink_metadata(data_dir) {
1972            Ok(_) => return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf())),
1973            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1974            Err(error) => return Err(error.into()),
1975        }
1976        let source_parent = prestage.path.parent().ok_or_else(|| {
1977            DurabilityError::SnapshotVerification(
1978                "successor prestage path has no parent directory".into(),
1979            )
1980        })?;
1981        let target_parent = data_dir.parent().ok_or_else(|| {
1982            DurabilityError::SnapshotVerification(
1983                "successor data path has no parent directory".into(),
1984            )
1985        })?;
1986        fs::create_dir_all(target_parent)?;
1987        #[cfg(unix)]
1988        if fs::metadata(source_parent)?.dev() != fs::metadata(target_parent)?.dev() {
1989            return Err(DurabilityError::SnapshotVerification(
1990                "successor prestage and final data directory must share a filesystem".into(),
1991            ));
1992        }
1993        fs::rename(&prestage.path, data_dir)?;
1994        sync_directory(source_parent)?;
1995        if target_parent != source_parent {
1996            sync_directory(target_parent)?;
1997        }
1998        prestage.path = data_dir.to_path_buf();
1999    }
2000    fs::rename(
2001        prestage.path.join(SUCCESSOR_PRESTAGE_READY_FILE),
2002        prestage.path.join(SUCCESSOR_PRESTAGE_PUBLISHED_FILE),
2003    )?;
2004    sync_directory(&prestage.path)?;
2005    prestage.state = SuccessorPrestageState::Published;
2006    Ok(prestage)
2007}
2008
2009fn validate_successor_prestage_stop(
2010    identity: &SuccessorPrestageIdentity,
2011    stop: &StopInformation,
2012    predecessor_membership: &Membership,
2013) -> Result<rhiza_core::SuccessorDescriptor, DurabilityError> {
2014    if stop.entry.cluster_id != identity.cluster_id()
2015        || stop.entry.epoch != identity.epoch()
2016        || stop.entry.config_id != identity.predecessor_config_id()
2017        || stop.entry.recompute_hash() != stop.entry.hash
2018    {
2019        return Err(DurabilityError::SnapshotVerification(
2020            "successor prestage requires the exact bound Stop".into(),
2021        ));
2022    }
2023    let change = ConfigChange::recognize_parts(stop.entry.entry_type, &stop.entry.payload)
2024        .map_err(|_| {
2025            DurabilityError::SnapshotVerification(
2026                "successor prestage Stop entry is not a bound configuration change".into(),
2027            )
2028        })?;
2029    let ConfigChange::BoundStop { successor } = change else {
2030        return Err(DurabilityError::SnapshotVerification(
2031            "successor prestage requires a bound Stop entry".into(),
2032        ));
2033    };
2034    if successor.cluster_id() != identity.cluster_id()
2035        || successor.predecessor_config_id() != identity.predecessor_config_id()
2036        || successor.predecessor_config_digest() != predecessor_membership.digest()
2037        || successor.config_id() != identity.target_config_id()
2038        || successor.digest() != identity.target_membership_digest()
2039    {
2040        return Err(DurabilityError::SnapshotVerification(
2041            "successor prestage Stop binding conflicts with the prestage target".into(),
2042        ));
2043    }
2044    stop.proof
2045        .validate_for_cluster(
2046            identity.cluster_id(),
2047            stop.entry.index,
2048            identity.epoch(),
2049            identity.predecessor_config_id(),
2050            predecessor_membership,
2051        )
2052        .map_err(|error| {
2053            DurabilityError::SnapshotVerification(format!(
2054                "successor prestage Stop proof is not quorum-certified: {error:?}"
2055            ))
2056        })?;
2057    let command = rhiza_core::StoredCommand::new(stop.entry.entry_type, stop.entry.payload.clone());
2058    let expected_value = rhiza_quepaxa::AcceptedValue::from_command(
2059        identity.cluster_id(),
2060        stop.entry.index,
2061        identity.epoch(),
2062        identity.predecessor_config_id(),
2063        stop.entry.prev_hash,
2064        &command,
2065    );
2066    if stop.proof.proposal().value.as_ref() != Some(&expected_value) {
2067        return Err(DurabilityError::SnapshotVerification(
2068            "successor prestage Stop proof value does not match the exact Stop entry".into(),
2069        ));
2070    }
2071    Ok(successor)
2072}
2073
2074pub fn finalize_successor_prestage_for_stop(
2075    mut prestage: SuccessorPrestage,
2076    stop: &StopInformation,
2077    predecessor_membership: &Membership,
2078) -> Result<SuccessorPrestage, DurabilityError> {
2079    if !matches!(
2080        prestage.state,
2081        SuccessorPrestageState::Published | SuccessorPrestageState::Finalized
2082    ) {
2083        return Err(DurabilityError::PreconditionFailed);
2084    }
2085    let successor =
2086        validate_successor_prestage_stop(&prestage.identity, stop, predecessor_membership)?;
2087    let expected_stop = LogAnchor::new(stop.entry.index, stop.entry.hash);
2088    let local_tip = validate_local_qlog_with_configuration(
2089        &prestage.path,
2090        &prestage.identity.checkpoint_identity(),
2091        prestage.identity.seed_anchor(),
2092        ConfigurationState::active(
2093            prestage.identity.predecessor_config_id(),
2094            successor.predecessor_config_digest(),
2095        ),
2096    )?;
2097    if local_tip != expected_stop {
2098        return Err(DurabilityError::SnapshotVerification(
2099            "successor prestage final qlog tip does not exactly match the bound Stop".into(),
2100        ));
2101    }
2102    if prestage.state == SuccessorPrestageState::Published {
2103        fs::rename(
2104            prestage.path.join(SUCCESSOR_PRESTAGE_PUBLISHED_FILE),
2105            prestage.path.join(SUCCESSOR_PRESTAGE_FINALIZED_FILE),
2106        )?;
2107        sync_directory(&prestage.path)?;
2108        prestage.state = SuccessorPrestageState::Finalized;
2109    }
2110    Ok(prestage)
2111}
2112
2113pub fn adopt_finalized_successor_prestage(
2114    prestage: SuccessorPrestage,
2115    config: &NodeConfig,
2116    stop: &StopInformation,
2117    predecessor_membership: &Membership,
2118) -> Result<SuccessorRestorePreparation, DurabilityError> {
2119    if prestage.state != SuccessorPrestageState::Finalized
2120        || prestage.path.as_path() != config.data_dir().as_path()
2121        || prestage.identity.cluster_id() != config.cluster_id()
2122        || prestage.identity.epoch() != config.epoch()
2123        || prestage.identity.predecessor_recovery_generation() != config.recovery_generation()
2124        || prestage.identity.node_id() != config.node_id()
2125        || prestage.identity.execution_profile() != config.execution_profile()
2126        || prestage.identity.target_membership_digest() != config.membership().digest()
2127        || stop.entry.cluster_id != config.cluster_id()
2128        || stop.entry.epoch != config.epoch()
2129        || stop.entry.config_id != prestage.identity.predecessor_config_id()
2130        || stop.entry.recompute_hash() != stop.entry.hash
2131        || config.predecessor_stop_entry.as_ref() != Some(&stop.entry)
2132    {
2133        return Err(DurabilityError::SnapshotVerification(
2134            "finalized successor prestage does not match the target configuration and Stop".into(),
2135        ));
2136    }
2137    let successor =
2138        validate_successor_prestage_stop(&prestage.identity, stop, predecessor_membership)?;
2139    let predecessor_digest = successor.predecessor_config_digest();
2140    let expected_stop = LogAnchor::new(stop.entry.index, stop.entry.hash);
2141    if successor.cluster_id() != config.cluster_id()
2142        || successor.predecessor_config_id() != prestage.identity.predecessor_config_id()
2143        || successor.config_id() != prestage.identity.target_config_id()
2144        || successor.digest() != prestage.identity.target_membership_digest()
2145        || successor.members() != config.membership().members()
2146        || config.log_initial_configuration()
2147            != &ConfigurationState::active(
2148                prestage.identity.predecessor_config_id(),
2149                predecessor_digest,
2150            )
2151        || config.configuration_state()
2152            != &ConfigurationState::stopped(
2153                prestage.identity.predecessor_config_id(),
2154                predecessor_digest,
2155                expected_stop,
2156                StopBinding::Bound {
2157                    successor: successor.clone(),
2158                    stop_command_hash: rhiza_core::StoredCommand::new(
2159                        stop.entry.entry_type,
2160                        stop.entry.payload.clone(),
2161                    )
2162                    .hash(),
2163                },
2164            )
2165    {
2166        return Err(DurabilityError::SnapshotVerification(
2167            "finalized successor prestage Stop binding conflicts with the target configuration"
2168                .into(),
2169        ));
2170    }
2171    let local_tip = validate_local_qlog_with_configuration(
2172        &prestage.path,
2173        &prestage.identity.checkpoint_identity(),
2174        prestage.identity.seed_anchor(),
2175        ConfigurationState::active(
2176            prestage.identity.predecessor_config_id(),
2177            predecessor_digest,
2178        ),
2179    )?;
2180    if local_tip != expected_stop {
2181        return Err(DurabilityError::SnapshotVerification(
2182            "finalized successor prestage qlog does not end at the exact Stop".into(),
2183        ));
2184    }
2185
2186    let receipt = serde_json::to_vec(&SuccessorRestoreIdentity {
2187        cluster_id: config.cluster_id(),
2188        epoch: config.epoch(),
2189        target_config_id: prestage.identity.target_config_id(),
2190        recovery_generation: config.recovery_generation(),
2191        node_id: config.node_id(),
2192        membership_digest: config.membership().digest().to_hex(),
2193        predecessor_config_id: prestage.identity.predecessor_config_id(),
2194        stop_index: stop.entry.index,
2195        stop_hash: stop.entry.hash.to_hex(),
2196    })
2197    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2198    let successor_lock = fs::OpenOptions::new()
2199        .read(true)
2200        .write(true)
2201        .create(true)
2202        .truncate(false)
2203        .open(prestage.path.join(SUCCESSOR_RESTORE_LOCK_FILE))?;
2204    successor_lock
2205        .try_lock()
2206        .map_err(|_| DurabilityError::PreconditionFailed)?;
2207    let intent = prestage.path.join(SUCCESSOR_RESTORE_INTENT_FILE);
2208    let complete = prestage.path.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
2209    match (
2210        read_regular_successor_control_file(&intent)?,
2211        read_regular_successor_control_file(&complete)?,
2212    ) {
2213        (None, None) => {
2214            publish_restore_marker(&prestage.path, SUCCESSOR_RESTORE_INTENT_FILE, &receipt)?
2215        }
2216        (Some(actual), None) if actual == receipt => {}
2217        _ => {
2218            return Err(DurabilityError::DataDirNotFresh(
2219                prestage.path.to_path_buf(),
2220            ))
2221        }
2222    }
2223    fs::remove_file(prestage.path.join(SUCCESSOR_PRESTAGE_FINALIZED_FILE))?;
2224    sync_directory(&prestage.path)?;
2225    Ok(SuccessorRestorePreparation {
2226        tip: CheckpointTip::new(stop.entry.index, stop.entry.hash),
2227        data_dir: prestage.path.clone(),
2228        identity: receipt,
2229        requires_recorder_install: true,
2230        _lock: successor_lock,
2231    })
2232}
2233
2234fn write_repair_artifact_ownership(
2235    artifact: &Path,
2236    role: RepairArtifactRole,
2237    identity: &RecoveryArtifactIdentity,
2238) -> Result<(), DurabilityError> {
2239    let name = artifact
2240        .file_name()
2241        .and_then(|name| name.to_str())
2242        .ok_or_else(|| {
2243            DurabilityError::SnapshotVerification(
2244                "repair artifact path must have a UTF-8 final component".into(),
2245            )
2246        })?
2247        .to_owned();
2248    let contents = serde_json::to_vec(&RepairArtifactOwnership {
2249        role,
2250        name,
2251        identity: identity.clone(),
2252    })
2253    .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2254    let owner = artifact.join(REPAIR_ARTIFACT_OWNER_FILE);
2255    let mut file = fs::OpenOptions::new()
2256        .write(true)
2257        .create_new(true)
2258        .open(owner)?;
2259    file.write_all(&contents)?;
2260    file.sync_all()?;
2261    sync_directory(artifact)
2262}
2263
2264fn create_restore_staging_dir(
2265    data_dir: &Path,
2266    recovery_identity: Option<&RecoveryArtifactIdentity>,
2267) -> Result<PathBuf, DurabilityError> {
2268    fs::create_dir_all(data_dir)?;
2269    for _ in 0..128 {
2270        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2271        let staging = data_dir.join(format!(
2272            "{RESTORE_STAGING_PREFIX}{}-{sequence}",
2273            process::id()
2274        ));
2275        match fs::create_dir(&staging) {
2276            Ok(()) => {
2277                if let Some(identity) = recovery_identity {
2278                    if let Err(error) = write_repair_artifact_ownership(
2279                        &staging,
2280                        RepairArtifactRole::Staging,
2281                        identity,
2282                    ) {
2283                        let _ = fs::remove_dir_all(&staging);
2284                        return Err(error);
2285                    }
2286                }
2287                return Ok(staging);
2288            }
2289            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2290            Err(error) => return Err(error.into()),
2291        }
2292    }
2293    Err(std::io::Error::new(
2294        std::io::ErrorKind::AlreadyExists,
2295        "could not allocate restore staging directory",
2296    )
2297    .into())
2298}
2299
2300fn publish_restore_staging(
2301    staging: &Path,
2302    data_dir: &Path,
2303    remove_generic_intent: bool,
2304    completion_marker: Option<(&str, &[u8])>,
2305) -> Result<(), DurabilityError> {
2306    sync_directory(staging)?;
2307    for name in ["sqlite", "ladybug", "kv", "consensus"] {
2308        let source = staging.join(name);
2309        if source.exists() {
2310            fs::rename(&source, data_dir.join(name))?;
2311        }
2312    }
2313    // A recovery-owned staging directory carries its ownership record inside the staging root.
2314    // Remove that sidecar together with the now-empty staging root; it must never be promoted
2315    // into the live data directory.
2316    fs::remove_dir_all(staging)?;
2317    sync_directory(data_dir)?;
2318    if let Some((marker_name, marker_contents)) = completion_marker {
2319        publish_restore_marker(data_dir, marker_name, marker_contents)?;
2320    }
2321    if remove_generic_intent {
2322        fs::remove_file(data_dir.join(RESTORE_INTENT_FILE))?;
2323    }
2324    sync_directory(data_dir)
2325}
2326
2327fn quarantine_rebuildable_view(
2328    data_dir: &Path,
2329    profile: ExecutionProfile,
2330    recovery_identity: Option<&RecoveryArtifactIdentity>,
2331) -> Result<Option<PathBuf>, DurabilityError> {
2332    let materializer = match profile {
2333        ExecutionProfile::Sqlite => "sqlite",
2334        ExecutionProfile::Kv => "kv",
2335        ExecutionProfile::Graph => {
2336            return Err(DurabilityError::SnapshotVerification(
2337                "graph recovery view replacement is outside this recovery path".into(),
2338            ))
2339        }
2340    };
2341    let names = [materializer, "consensus"];
2342    let mut has_rebuildable_view = false;
2343    for name in names {
2344        match fs::symlink_metadata(data_dir.join(name)) {
2345            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
2346                return Err(DurabilityError::SnapshotVerification(
2347                    "rebuildable recovery view is not a regular directory".into(),
2348                ));
2349            }
2350            Ok(_) => has_rebuildable_view = true,
2351            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2352            Err(error) => return Err(error.into()),
2353        }
2354    }
2355    if !has_rebuildable_view {
2356        return Ok(None);
2357    }
2358    for _ in 0..128 {
2359        let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2360        let quarantine = data_dir.join(format!(
2361            ".rebuildable-quarantine-{}-{sequence}",
2362            process::id()
2363        ));
2364        match fs::create_dir(&quarantine) {
2365            Ok(()) => {
2366                if let Some(identity) = recovery_identity {
2367                    if let Err(error) = write_repair_artifact_ownership(
2368                        &quarantine,
2369                        RepairArtifactRole::Quarantine,
2370                        identity,
2371                    ) {
2372                        let _ = fs::remove_dir_all(&quarantine);
2373                        return Err(error);
2374                    }
2375                }
2376                for name in names {
2377                    let source = data_dir.join(name);
2378                    match fs::symlink_metadata(&source) {
2379                        Ok(_) => fs::rename(source, quarantine.join(name))?,
2380                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2381                        Err(error) => return Err(error.into()),
2382                    }
2383                }
2384                sync_directory(&quarantine)?;
2385                sync_directory(data_dir)?;
2386                return Ok(Some(quarantine));
2387            }
2388            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2389            Err(error) => return Err(error.into()),
2390        }
2391    }
2392    Err(std::io::Error::new(
2393        std::io::ErrorKind::AlreadyExists,
2394        "could not allocate rebuildable recovery quarantine",
2395    )
2396    .into())
2397}
2398
2399fn publish_restore_marker(
2400    data_dir: &Path,
2401    marker_name: &str,
2402    contents: &[u8],
2403) -> Result<(), DurabilityError> {
2404    validate_restore_marker_name(marker_name)?;
2405    fs::create_dir_all(data_dir)?;
2406    let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2407    let temporary = data_dir.join(format!(
2408        "{RESTORE_MARKER_TMP_PREFIX}{}-{sequence}",
2409        process::id()
2410    ));
2411    let mut file = fs::OpenOptions::new()
2412        .write(true)
2413        .create_new(true)
2414        .open(&temporary)?;
2415    file.write_all(contents)?;
2416    file.sync_all()?;
2417    fs::rename(temporary, data_dir.join(marker_name))?;
2418    sync_directory(data_dir)
2419}
2420
2421fn validate_restore_marker_name(marker_name: &str) -> Result<(), DurabilityError> {
2422    if marker_name.is_empty()
2423        || matches!(marker_name, "." | "..")
2424        || marker_name.contains(std::path::MAIN_SEPARATOR)
2425    {
2426        return Err(DurabilityError::SnapshotVerification(
2427            "restore marker name must be one local file name".into(),
2428        ));
2429    }
2430    Ok(())
2431}
2432
2433fn is_owned_generated_recovery_name(name: &str, prefix: &str) -> bool {
2434    let Some(suffix) = name.strip_prefix(prefix) else {
2435        return false;
2436    };
2437    let mut parts = suffix.split('-');
2438    let (Some(process_id), Some(sequence), None) = (parts.next(), parts.next(), parts.next())
2439    else {
2440        return false;
2441    };
2442    process_id.parse::<u32>().is_ok_and(|id| id > 0) && sequence.parse::<u64>().is_ok()
2443}
2444
2445fn is_safe_restore_marker_tmp(path: &Path, name: &str) -> Result<bool, DurabilityError> {
2446    if !is_owned_generated_recovery_name(name, RESTORE_MARKER_TMP_PREFIX) {
2447        return Ok(false);
2448    }
2449    Ok(read_bounded_regular_file(path, 16384)?.is_some())
2450}
2451
2452fn is_owned_recovery_directory(
2453    path: &Path,
2454    allowed_children: &[&str],
2455    expected_role: RepairArtifactRole,
2456    expected_identity: &RecoveryArtifactIdentity,
2457) -> Result<bool, DurabilityError> {
2458    let metadata = fs::symlink_metadata(path)?;
2459    if metadata.file_type().is_symlink() || !metadata.is_dir() {
2460        return Ok(false);
2461    }
2462    let owner = path.join(REPAIR_ARTIFACT_OWNER_FILE);
2463    let Some(owner_bytes) = read_bounded_regular_file(&owner, 16384)? else {
2464        return Ok(false);
2465    };
2466    let Ok(ownership) = serde_json::from_slice::<RepairArtifactOwnership>(&owner_bytes) else {
2467        return Ok(false);
2468    };
2469    if ownership.role != expected_role
2470        || ownership.identity != *expected_identity
2471        || path.file_name().and_then(|name| name.to_str()) != Some(ownership.name.as_str())
2472    {
2473        return Ok(false);
2474    }
2475    for entry in fs::read_dir(path)? {
2476        let entry = entry?;
2477        let name = entry.file_name();
2478        let name = name.to_string_lossy();
2479        if name == REPAIR_ARTIFACT_OWNER_FILE {
2480            continue;
2481        }
2482        let metadata = fs::symlink_metadata(entry.path())?;
2483        if !allowed_children.contains(&name.as_ref())
2484            || metadata.file_type().is_symlink()
2485            || !metadata.is_dir()
2486        {
2487            return Ok(false);
2488        }
2489    }
2490    Ok(true)
2491}
2492
2493fn cleanup_owned_recovery_artifacts(
2494    data_dir: &Path,
2495    identity: &RecoveryArtifactIdentity,
2496) -> Result<(), DurabilityError> {
2497    let mut cleanup = Vec::new();
2498    for entry in fs::read_dir(data_dir)? {
2499        let entry = entry?;
2500        let name = entry.file_name();
2501        let name = name.to_string_lossy();
2502        let has_staging_prefix = name.starts_with(RESTORE_STAGING_PREFIX);
2503        let has_quarantine_prefix = name.starts_with(".rebuildable-quarantine-");
2504        if (has_staging_prefix && !is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX))
2505            || (has_quarantine_prefix
2506                && !is_owned_generated_recovery_name(&name, ".rebuildable-quarantine-"))
2507        {
2508            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2509        }
2510        let artifact = if has_staging_prefix {
2511            Some((
2512                ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
2513                RepairArtifactRole::Staging,
2514            ))
2515        } else if has_quarantine_prefix {
2516            Some((
2517                ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
2518                RepairArtifactRole::Quarantine,
2519            ))
2520        } else {
2521            None
2522        };
2523        let Some((allowed_children, role)) = artifact else {
2524            continue;
2525        };
2526        if !is_owned_recovery_directory(&entry.path(), allowed_children, role, identity)? {
2527            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2528        }
2529        cleanup.push(entry.path());
2530    }
2531    for path in cleanup.iter() {
2532        fs::remove_dir_all(path)?;
2533    }
2534    if !cleanup.is_empty() {
2535        sync_directory(data_dir)?;
2536    }
2537    Ok(())
2538}
2539
2540#[cfg(any(target_os = "linux", target_os = "android"))]
2541const O_NOFOLLOW_FLAG: i32 = 0o400000;
2542#[cfg(any(
2543    target_os = "macos",
2544    target_os = "ios",
2545    target_os = "freebsd",
2546    target_os = "openbsd",
2547    target_os = "netbsd",
2548    target_os = "dragonfly"
2549))]
2550const O_NOFOLLOW_FLAG: i32 = 0x0100;
2551
2552fn open_recovery_file_no_follow(path: &Path) -> Result<fs::File, DurabilityError> {
2553    let mut options = fs::OpenOptions::new();
2554    options.read(true);
2555    #[cfg(any(
2556        target_os = "linux",
2557        target_os = "android",
2558        target_os = "macos",
2559        target_os = "ios",
2560        target_os = "freebsd",
2561        target_os = "openbsd",
2562        target_os = "netbsd",
2563        target_os = "dragonfly"
2564    ))]
2565    options.custom_flags(O_NOFOLLOW_FLAG);
2566    Ok(options.open(path)?)
2567}
2568
2569fn read_bounded_regular_file(
2570    path: &Path,
2571    max_bytes: u64,
2572) -> Result<Option<Vec<u8>>, DurabilityError> {
2573    let metadata = match fs::symlink_metadata(path) {
2574        Ok(metadata) => metadata,
2575        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2576        Err(error) => return Err(error.into()),
2577    };
2578    if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes {
2579        return Err(DurabilityError::SnapshotVerification(
2580            "recovery file is not a bounded regular file".into(),
2581        ));
2582    }
2583    let mut file = open_recovery_file_no_follow(path)?;
2584    let opened = file.metadata()?;
2585    if !opened.is_file() || opened.len() > max_bytes {
2586        return Err(DurabilityError::SnapshotVerification(
2587            "recovery file changed to an invalid form before read".into(),
2588        ));
2589    }
2590    #[cfg(unix)]
2591    if metadata.dev() != opened.dev()
2592        || metadata.ino() != opened.ino()
2593        || metadata.len() != opened.len()
2594    {
2595        return Err(DurabilityError::SnapshotVerification(
2596            "recovery file changed before no-follow open".into(),
2597        ));
2598    }
2599    let mut contents = Vec::with_capacity(usize::try_from(opened.len()).unwrap_or(0));
2600    Read::by_ref(&mut file)
2601        .take(max_bytes + 1)
2602        .read_to_end(&mut contents)?;
2603    if contents.len() as u64 > max_bytes || contents.len() as u64 != opened.len() {
2604        return Err(DurabilityError::SnapshotVerification(
2605            "recovery file changed during bounded read".into(),
2606        ));
2607    }
2608    Ok(Some(contents))
2609}
2610
2611fn read_regular_successor_control_file(path: &Path) -> Result<Option<Vec<u8>>, DurabilityError> {
2612    read_bounded_regular_file(path, 16384)
2613}
2614
2615fn parse_successor_restore_receipt(bytes: &[u8]) -> Option<SuccessorRestoreReceipt> {
2616    let receipt = serde_json::from_slice::<SuccessorRestoreReceipt>(bytes).ok()?;
2617    (!receipt.cluster_id.is_empty()
2618        && !receipt.node_id.is_empty()
2619        && LogHash::from_hex(&receipt.membership_digest).is_some()
2620        && LogHash::from_hex(&receipt.stop_hash).is_some())
2621    .then_some(receipt)
2622}
2623
2624fn successor_receipt_matches_finalized_prestage(
2625    receipt: &SuccessorRestoreReceipt,
2626    identity: &SuccessorPrestageIdentity,
2627) -> bool {
2628    receipt.cluster_id == identity.cluster_id
2629        && receipt.epoch == identity.epoch
2630        && receipt.target_config_id == identity.target_config_id
2631        && receipt.recovery_generation == identity.predecessor_recovery_generation
2632        && receipt.node_id == identity.node_id
2633        && receipt.membership_digest == identity.target_membership_digest
2634        && receipt.predecessor_config_id == identity.predecessor_config_id
2635}
2636
2637fn parse_successor_prestage_identity(bytes: &[u8]) -> Option<SuccessorPrestageIdentity> {
2638    let identity = serde_json::from_slice::<SuccessorPrestageIdentity>(bytes).ok()?;
2639    validate_successor_prestage_identity(&identity)
2640        .is_ok()
2641        .then_some(identity)
2642}
2643
2644fn validate_successor_prestage_identity(
2645    identity: &SuccessorPrestageIdentity,
2646) -> Result<(), DurabilityError> {
2647    let valid = !identity.cluster_id.is_empty()
2648        && !identity.node_id.is_empty()
2649        && snapshot_profile(&identity.cluster_id)? == identity.execution_profile
2650        && identity
2651            .predecessor_config_id
2652            .checked_add(1)
2653            .is_some_and(|next| next == identity.target_config_id)
2654        && LogHash::from_hex(&identity.predecessor_membership_digest).is_some()
2655        && LogHash::from_hex(&identity.target_membership_digest).is_some()
2656        && LogHash::from_hex(&identity.seed_hash).is_some();
2657    if !valid {
2658        return Err(DurabilityError::SnapshotVerification(
2659            "successor prestage identity is invalid".into(),
2660        ));
2661    }
2662    Ok(())
2663}
2664
2665fn prepare_successor_prestage_root(
2666    prestage_dir: &Path,
2667    expected_identity: Option<&SuccessorPrestageIdentity>,
2668    predecessor_configuration: &ConfigurationState,
2669) -> Result<SuccessorPrestage, DurabilityError> {
2670    if expected_identity.is_some() {
2671        fs::create_dir_all(prestage_dir)?;
2672    }
2673    let metadata = fs::symlink_metadata(prestage_dir)
2674        .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2675    if metadata.file_type().is_symlink() || !metadata.is_dir() {
2676        return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2677    }
2678    let lock_path = prestage_dir.join(SUCCESSOR_PRESTAGE_LOCK_FILE);
2679    let mut lock_options = fs::OpenOptions::new();
2680    lock_options.read(true).write(true);
2681    if expected_identity.is_some() {
2682        lock_options.create(true).truncate(false);
2683    }
2684    let lock = lock_options
2685        .open(&lock_path)
2686        .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2687    lock.try_lock()
2688        .map_err(|_| DurabilityError::PreconditionFailed)?;
2689
2690    let marker_files = [
2691        (
2692            SUCCESSOR_PRESTAGE_INTENT_FILE,
2693            SuccessorPrestageState::Preparing,
2694        ),
2695        (SUCCESSOR_PRESTAGE_READY_FILE, SuccessorPrestageState::Ready),
2696        (
2697            SUCCESSOR_PRESTAGE_PUBLISHED_FILE,
2698            SuccessorPrestageState::Published,
2699        ),
2700        (
2701            SUCCESSOR_PRESTAGE_FINALIZED_FILE,
2702            SuccessorPrestageState::Finalized,
2703        ),
2704    ];
2705    let mut marker = None;
2706    for (name, state) in marker_files {
2707        let Some(bytes) = read_bounded_regular_file(&prestage_dir.join(name), 16384)
2708            .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?
2709        else {
2710            continue;
2711        };
2712        if marker.is_some() {
2713            return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2714        }
2715        let identity = parse_successor_prestage_identity(&bytes)
2716            .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2717        marker = Some((name, state, identity));
2718    }
2719
2720    let (marker_name, state, identity) = match marker {
2721        Some(marker) => marker,
2722        None => {
2723            let expected = expected_identity
2724                .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2725            for entry in fs::read_dir(prestage_dir)? {
2726                let entry = entry?;
2727                let name = entry.file_name();
2728                let name = name.to_string_lossy();
2729                if name == SUCCESSOR_PRESTAGE_LOCK_FILE {
2730                    continue;
2731                }
2732                if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2733                    fs::remove_file(entry.path())?;
2734                    continue;
2735                }
2736                return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2737            }
2738            let bytes = serde_json::to_vec(expected)
2739                .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2740            publish_restore_marker(prestage_dir, SUCCESSOR_PRESTAGE_INTENT_FILE, &bytes)?;
2741            (
2742                SUCCESSOR_PRESTAGE_INTENT_FILE,
2743                SuccessorPrestageState::Preparing,
2744                expected.clone(),
2745            )
2746        }
2747    };
2748    if expected_identity.is_some_and(|expected| expected != &identity) {
2749        return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2750    }
2751    if !predecessor_configuration.is_active()
2752        || predecessor_configuration.config_id() != identity.predecessor_config_id()
2753        || predecessor_configuration.digest() != identity.predecessor_membership_digest()
2754    {
2755        return Err(DurabilityError::SnapshotVerification(
2756            "successor prestage predecessor configuration does not match its identity".into(),
2757        ));
2758    }
2759
2760    let recovery_identity = RecoveryArtifactIdentity::Prestage(identity.clone());
2761    for entry in fs::read_dir(prestage_dir)? {
2762        let entry = entry?;
2763        let name = entry.file_name();
2764        let name = name.to_string_lossy();
2765        if name == SUCCESSOR_PRESTAGE_LOCK_FILE || name == marker_name {
2766            continue;
2767        }
2768        if state == SuccessorPrestageState::Finalized && name == SUCCESSOR_RESTORE_INTENT_FILE {
2769            let bytes = read_regular_successor_control_file(&entry.path())?
2770                .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2771            let receipt = parse_successor_restore_receipt(&bytes)
2772                .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2773            if successor_receipt_matches_finalized_prestage(&receipt, &identity) {
2774                continue;
2775            }
2776            return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2777        }
2778        if state == SuccessorPrestageState::Finalized
2779            && name == SUCCESSOR_RESTORE_LOCK_FILE
2780            && fs::symlink_metadata(entry.path())
2781                .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
2782        {
2783            continue;
2784        }
2785        if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2786            if state == SuccessorPrestageState::Preparing {
2787                fs::remove_file(entry.path())?;
2788                continue;
2789            }
2790            return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2791        }
2792        if ["sqlite", "ladybug", "kv", "consensus"].contains(&name.as_ref()) {
2793            let metadata = fs::symlink_metadata(entry.path())?;
2794            if metadata.file_type().is_symlink() || !metadata.is_dir() {
2795                return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2796            }
2797            continue;
2798        }
2799        if name.starts_with(RESTORE_STAGING_PREFIX)
2800            && state == SuccessorPrestageState::Preparing
2801            && is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2802            && is_owned_recovery_directory(
2803                &entry.path(),
2804                &["sqlite", "ladybug", "kv", "consensus"],
2805                RepairArtifactRole::Staging,
2806                &recovery_identity,
2807            )?
2808        {
2809            continue;
2810        }
2811        return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2812    }
2813    if !matches!(
2814        state,
2815        SuccessorPrestageState::Preparing | SuccessorPrestageState::Finalized
2816    ) {
2817        validate_local_qlog_with_configuration(
2818            prestage_dir,
2819            &identity.checkpoint_identity(),
2820            identity.seed_anchor(),
2821            predecessor_configuration.clone(),
2822        )?;
2823    }
2824    Ok(SuccessorPrestage {
2825        path: prestage_dir.to_path_buf(),
2826        identity,
2827        state,
2828        _lock: lock,
2829    })
2830}
2831
2832fn cleanup_preparing_successor_prestage(
2833    prestage_dir: &Path,
2834    identity: &SuccessorPrestageIdentity,
2835) -> Result<(), DurabilityError> {
2836    let recovery_identity = RecoveryArtifactIdentity::Prestage(identity.clone());
2837    for entry in fs::read_dir(prestage_dir)? {
2838        let entry = entry?;
2839        let name = entry.file_name();
2840        let name = name.to_string_lossy();
2841        let owned_component = ["sqlite", "ladybug", "kv", "consensus"].contains(&name.as_ref());
2842        let owned_staging = name.starts_with(RESTORE_STAGING_PREFIX)
2843            && is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2844            && is_owned_recovery_directory(
2845                &entry.path(),
2846                &["sqlite", "ladybug", "kv", "consensus"],
2847                RepairArtifactRole::Staging,
2848                &recovery_identity,
2849            )?;
2850        if owned_component || owned_staging {
2851            fs::remove_dir_all(entry.path())?;
2852        }
2853    }
2854    sync_directory(prestage_dir)
2855}
2856
2857fn sync_directory(path: &Path) -> Result<(), DurabilityError> {
2858    fs::File::open(path)?.sync_all()?;
2859    Ok(())
2860}
2861
2862fn validate_local_batch(
2863    entries: &[rhiza_core::LogEntry],
2864    start: LogIndex,
2865    end: LogIndex,
2866    durable_tip: CheckpointTip,
2867) -> Result<(), DurabilityError> {
2868    let expected_len =
2869        usize::try_from(end - start + 1).map_err(|_| DurabilityError::LocalLogGap {
2870            expected: start,
2871            actual: entries.first().map(|entry| entry.index),
2872        })?;
2873    if entries.len() != expected_len {
2874        let actual = entries
2875            .iter()
2876            .zip(start..=end)
2877            .find_map(|(entry, expected)| (entry.index != expected).then_some(entry.index));
2878        return Err(DurabilityError::LocalLogGap {
2879            expected: start + entries.len() as u64,
2880            actual,
2881        });
2882    }
2883
2884    let mut expected_hash = durable_tip.hash();
2885    for (expected_index, entry) in (start..).zip(entries) {
2886        if entry.index != expected_index {
2887            return Err(DurabilityError::LocalLogGap {
2888                expected: expected_index,
2889                actual: Some(entry.index),
2890            });
2891        }
2892        if entry.prev_hash != expected_hash || entry.recompute_hash() != entry.hash {
2893            return Err(DurabilityError::LocalLogConflict { index: entry.index });
2894        }
2895        expected_hash = entry.hash;
2896    }
2897    Ok(())
2898}
2899
2900fn prepare_fresh_restore_data_dir(
2901    data_dir: &Path,
2902    completion_marker_name: Option<&str>,
2903    expected_intent: &[u8],
2904) -> Result<(), DurabilityError> {
2905    if !path_has_state(data_dir)? {
2906        return Ok(());
2907    }
2908
2909    let intent = data_dir.join(RESTORE_INTENT_FILE);
2910    let intent_metadata = match fs::symlink_metadata(&intent) {
2911        Ok(metadata) => Some(metadata),
2912        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2913        Err(error) => return Err(error.into()),
2914    };
2915    let (active_intent, recovery_identity) = if let Some(metadata) = intent_metadata {
2916        if metadata.file_type().is_symlink()
2917            || !metadata.is_file()
2918            || read_bounded_regular_file(&intent, 4096)?.as_deref() != Some(expected_intent)
2919        {
2920            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2921        }
2922        (
2923            &intent,
2924            Some(RecoveryArtifactIdentity::Restore(
2925                parse_restore_intent_identity(expected_intent)
2926                    .ok_or_else(|| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?,
2927            )),
2928        )
2929    } else {
2930        let entries = fs::read_dir(data_dir)?.collect::<Result<Vec<_>, _>>()?;
2931        if entries.iter().all(|entry| {
2932            let name = entry.file_name();
2933            let name = name.to_string_lossy();
2934            is_safe_restore_marker_tmp(&entry.path(), &name).unwrap_or(false)
2935        }) {
2936            for entry in entries {
2937                fs::remove_file(entry.path())?;
2938            }
2939            sync_directory(data_dir)?;
2940            return Ok(());
2941        }
2942        return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2943    };
2944
2945    for entry in fs::read_dir(data_dir)? {
2946        let entry = entry?;
2947        let name = entry.file_name();
2948        let name = name.to_string_lossy();
2949        let marker_tmp = is_safe_restore_marker_tmp(&entry.path(), &name)?;
2950        if name.starts_with(RESTORE_MARKER_TMP_PREFIX) && !marker_tmp {
2951            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2952        }
2953        let is_staging = name.starts_with(RESTORE_STAGING_PREFIX);
2954        if is_staging
2955            && (!is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2956                || !recovery_identity.as_ref().is_some_and(|identity| {
2957                    is_owned_recovery_directory(
2958                        &entry.path(),
2959                        &["sqlite", "ladybug", "kv", "consensus"],
2960                        RepairArtifactRole::Staging,
2961                        identity,
2962                    )
2963                    .unwrap_or(false)
2964                }))
2965        {
2966            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2967        }
2968        let owned = entry.path() == active_intent.as_path()
2969            || completion_marker_name.is_some_and(|marker| name == marker)
2970            || name == "sqlite"
2971            || name == "ladybug"
2972            || name == "kv"
2973            || name == "consensus"
2974            || marker_tmp
2975            || is_staging;
2976        if !owned {
2977            return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2978        }
2979    }
2980
2981    for name in ["sqlite", "ladybug", "kv", "consensus"] {
2982        let path = data_dir.join(name);
2983        if path.exists() {
2984            fs::remove_dir_all(path)?;
2985        }
2986    }
2987    for entry in fs::read_dir(data_dir)? {
2988        let entry = entry?;
2989        let name = entry.file_name();
2990        let name = name.to_string_lossy();
2991        if name.starts_with(RESTORE_STAGING_PREFIX) {
2992            fs::remove_dir_all(entry.path())?;
2993        } else if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2994            fs::remove_file(entry.path())?;
2995        }
2996    }
2997    fs::remove_file(active_intent)?;
2998    sync_directory(data_dir)?;
2999    Ok(())
3000}
3001
3002fn path_has_state(path: &Path) -> Result<bool, std::io::Error> {
3003    let metadata = match fs::symlink_metadata(path) {
3004        Ok(metadata) => metadata,
3005        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3006        Err(error) => return Err(error),
3007    };
3008    if !metadata.is_dir() {
3009        return Ok(true);
3010    }
3011    fs::read_dir(path)?
3012        .next()
3013        .transpose()
3014        .map(|entry| entry.is_some())
3015}
3016
3017#[cfg(test)]
3018#[path = "durability/prestage_tests.rs"]
3019mod prestage_tests;
3020
3021#[cfg(test)]
3022mod tests {
3023    use super::{
3024        mark_durable_state, observe_durable_tip, snapshot_profile, validate_local_qlog,
3025        validate_restored_suffix, write_repair_artifact_ownership, CheckpointTip, CoordinatorState,
3026        DurabilityError, DurabilityHealth, ExecutionProfile, LogAnchor, LogHash, PendingLag,
3027        RecoveryArtifactIdentity, RepairArtifactRole, SuccessorRestorePreparation,
3028        RESTORE_INTENT_FILE, SUCCESSOR_RESTORE_COMPLETE_FILE, SUCCESSOR_RESTORE_INTENT_FILE,
3029        SUCCESSOR_RESTORE_LOCK_FILE,
3030    };
3031    #[cfg(feature = "kv")]
3032    use crate::{KvCommandV1, NodeConfig, NodeRuntime};
3033    use rhiza_archive::CheckpointIdentity;
3034    #[cfg(feature = "kv")]
3035    use rhiza_archive::ObjectArchiveStore;
3036    use rhiza_core::{EntryType, LogEntry};
3037    use rhiza_log::{FileLogStore, LogStore};
3038    #[cfg(feature = "kv")]
3039    use rhiza_obj_store::{ObjStore, ObjStoreConfig};
3040    #[cfg(feature = "kv")]
3041    use rhiza_quepaxa::ThreeNodeConsensus;
3042    use std::sync::{Arc, Barrier, Mutex};
3043
3044    #[test]
3045    fn snapshot_profile_requires_a_canonical_effective_cluster_identity() {
3046        assert_eq!(
3047            snapshot_profile("rhiza:sql:cluster-a").unwrap(),
3048            ExecutionProfile::Sqlite
3049        );
3050        assert_eq!(
3051            snapshot_profile("rhiza:graph:cluster-a").unwrap(),
3052            ExecutionProfile::Graph
3053        );
3054        assert_eq!(
3055            snapshot_profile("rhiza:kv:cluster-a").unwrap(),
3056            ExecutionProfile::Kv
3057        );
3058        assert!(matches!(
3059            snapshot_profile("cluster-a"),
3060            Err(DurabilityError::SnapshotVerification(_))
3061        ));
3062        assert!(snapshot_profile("rhiza:graph:").is_err());
3063    }
3064
3065    #[test]
3066    fn generic_restore_prepare_keeps_prefix_spoofed_staging_and_fails_closed() {
3067        let root = tempfile::tempdir().unwrap();
3068        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3069        let intent = super::encode_restore_intent(
3070            &identity,
3071            "node-1",
3072            ExecutionProfile::Sqlite,
3073            LogAnchor::new(0, LogHash::ZERO),
3074        )
3075        .unwrap();
3076        std::fs::write(root.path().join(RESTORE_INTENT_FILE), &intent).unwrap();
3077        let staging = root.path().join(".restore-stage-4242-1");
3078        std::fs::create_dir_all(staging.join("sqlite")).unwrap();
3079
3080        assert!(matches!(
3081            super::prepare_fresh_restore_data_dir(root.path(), None, &intent),
3082            Err(DurabilityError::DataDirNotFresh(_))
3083        ));
3084        assert!(staging.join("sqlite").is_dir());
3085    }
3086
3087    #[test]
3088    fn bounded_regular_reader_rejects_file_larger_than_its_limit() {
3089        let root = tempfile::tempdir().unwrap();
3090        let path = root.path().join("oversized");
3091        std::fs::write(&path, b"12345").unwrap();
3092
3093        assert!(super::read_bounded_regular_file(&path, 4).is_err());
3094        assert_eq!(std::fs::read(&path).unwrap(), b"12345");
3095    }
3096
3097    #[test]
3098    fn rejoin_artifact_cleanup_removes_only_owned_stale_stage_and_quarantine() {
3099        let root = tempfile::tempdir().unwrap();
3100        let checkpoint = LogAnchor::new(0, LogHash::ZERO);
3101        let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
3102            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3103            "node-1",
3104            ExecutionProfile::Sqlite,
3105            checkpoint,
3106        ));
3107        let stage = root.path().join(".restore-stage-4242-1");
3108        std::fs::create_dir_all(stage.join("sqlite")).unwrap();
3109        write_repair_artifact_ownership(&stage, RepairArtifactRole::Staging, &identity).unwrap();
3110        let quarantine = root.path().join(".rebuildable-quarantine-4242-2");
3111        std::fs::create_dir_all(quarantine.join("sqlite")).unwrap();
3112        write_repair_artifact_ownership(&quarantine, RepairArtifactRole::Quarantine, &identity)
3113            .unwrap();
3114
3115        super::cleanup_owned_recovery_artifacts(root.path(), &identity).unwrap();
3116        assert!(!stage.exists());
3117        assert!(!quarantine.exists());
3118    }
3119
3120    #[test]
3121    fn rejoin_artifact_cleanup_keeps_foreign_prefix_artifact_without_mutation() {
3122        let root = tempfile::tempdir().unwrap();
3123        let spoof = root.path().join(".restore-stage-foreign");
3124        std::fs::create_dir(&spoof).unwrap();
3125        let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
3126            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3127            "node-1",
3128            ExecutionProfile::Sqlite,
3129            LogAnchor::new(0, LogHash::ZERO),
3130        ));
3131
3132        assert!(matches!(
3133            super::cleanup_owned_recovery_artifacts(root.path(), &identity),
3134            Err(DurabilityError::DataDirNotFresh(_))
3135        ));
3136        assert!(spoof.is_dir());
3137    }
3138
3139    #[cfg(unix)]
3140    #[test]
3141    fn successor_completion_keeps_existing_complete_symlink_and_intent() {
3142        use std::os::unix::fs::symlink;
3143
3144        let root = tempfile::tempdir().unwrap();
3145        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"}"#;
3146        let intent = root.path().join(SUCCESSOR_RESTORE_INTENT_FILE);
3147        std::fs::write(&intent, receipt).unwrap();
3148        let target = root.path().join("target");
3149        std::fs::write(&target, b"do not replace").unwrap();
3150        let complete = root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE);
3151        symlink(&target, &complete).unwrap();
3152        let lock = std::fs::OpenOptions::new()
3153            .read(true)
3154            .write(true)
3155            .create(true)
3156            .truncate(false)
3157            .open(root.path().join(SUCCESSOR_RESTORE_LOCK_FILE))
3158            .unwrap();
3159        let preparation = SuccessorRestorePreparation {
3160            tip: CheckpointTip::new(0, LogHash::ZERO),
3161            data_dir: root.path().to_path_buf(),
3162            identity: receipt.to_vec(),
3163            requires_recorder_install: true,
3164            _lock: lock,
3165        };
3166
3167        assert!(preparation.complete().is_err());
3168        assert_eq!(std::fs::read(&target).unwrap(), b"do not replace");
3169        assert_eq!(std::fs::read(&intent).unwrap(), receipt);
3170        assert!(std::fs::symlink_metadata(&complete)
3171            .unwrap()
3172            .file_type()
3173            .is_symlink());
3174    }
3175
3176    #[test]
3177    fn sqlite_restore_suffix_rejects_noncanonical_commands_during_preflight() {
3178        let payload = b"put\tnoncanonical\tkey\tvalue".to_vec();
3179        let entry = LogEntry {
3180            cluster_id: "rhiza:sql:cluster-a".into(),
3181            epoch: 1,
3182            config_id: 1,
3183            index: 1,
3184            entry_type: EntryType::Command,
3185            prev_hash: LogHash::ZERO,
3186            hash: LogEntry::calculate_hash(
3187                "rhiza:sql:cluster-a",
3188                1,
3189                1,
3190                1,
3191                EntryType::Command,
3192                LogHash::ZERO,
3193                &payload,
3194            ),
3195            payload,
3196        };
3197
3198        assert!(matches!(
3199            validate_restored_suffix(ExecutionProfile::Sqlite, &[entry]),
3200            Err(DurabilityError::SnapshotVerification(message)) if message.contains("QWAL")
3201        ));
3202    }
3203
3204    #[test]
3205    fn local_qlog_accepts_ahead_tip_when_checkpoint_entry_is_retained() {
3206        let root = tempfile::tempdir().unwrap();
3207        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3208        let log = FileLogStore::open(
3209            root.path().join("consensus/log"),
3210            identity.cluster_id(),
3211            1,
3212            1,
3213        )
3214        .unwrap();
3215        let entry = |index, previous| {
3216            let hash = LogEntry::calculate_hash(
3217                identity.cluster_id(),
3218                index,
3219                1,
3220                1,
3221                EntryType::Noop,
3222                previous,
3223                &[],
3224            );
3225            LogEntry {
3226                cluster_id: identity.cluster_id().into(),
3227                epoch: 1,
3228                config_id: 1,
3229                index,
3230                entry_type: EntryType::Noop,
3231                payload: Vec::new(),
3232                prev_hash: previous,
3233                hash,
3234            }
3235        };
3236        let first = entry(1, LogHash::ZERO);
3237        let second = entry(2, first.hash);
3238        log.append_batch(&[first.clone(), second.clone()]).unwrap();
3239
3240        assert_eq!(
3241            validate_local_qlog(
3242                root.path(),
3243                &identity,
3244                rhiza_core::LogAnchor::new(first.index, first.hash),
3245            )
3246            .unwrap(),
3247            rhiza_core::LogAnchor::new(2, second.hash)
3248        );
3249        assert!(validate_local_qlog(
3250            root.path(),
3251            &identity,
3252            rhiza_core::LogAnchor::new(2, LogHash::digest(&[b"conflicting"])),
3253        )
3254        .is_err());
3255        assert!(validate_local_qlog(
3256            root.path(),
3257            &identity,
3258            rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"ahead checkpoint"])),
3259        )
3260        .is_err());
3261    }
3262
3263    #[test]
3264    fn local_qlog_treats_absent_log_as_genesis_only() {
3265        let root = tempfile::tempdir().unwrap();
3266        let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3267        let genesis = rhiza_core::LogAnchor::new(0, LogHash::ZERO);
3268
3269        assert_eq!(
3270            validate_local_qlog(root.path(), &identity, genesis).unwrap(),
3271            genesis
3272        );
3273        assert!(validate_local_qlog(
3274            root.path(),
3275            &identity,
3276            rhiza_core::LogAnchor::new(1, LogHash::digest(&[b"checkpoint"])),
3277        )
3278        .is_err());
3279    }
3280
3281    #[test]
3282    fn restore_intent_remains_until_completion_marker_is_durable_and_retryable() {
3283        let root = tempfile::tempdir().unwrap();
3284        let data_dir = root.path().join("data");
3285        std::fs::create_dir_all(&data_dir).unwrap();
3286        let intent = super::encode_restore_intent(
3287            &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3288            "node-1",
3289            ExecutionProfile::Sqlite,
3290            rhiza_core::LogAnchor::new(0, LogHash::ZERO),
3291        )
3292        .unwrap();
3293        std::fs::write(data_dir.join(RESTORE_INTENT_FILE), &intent).unwrap();
3294        std::fs::create_dir(data_dir.join("identity.json")).unwrap();
3295        let staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
3296
3297        assert!(super::publish_restore_staging(
3298            &staging,
3299            &data_dir,
3300            true,
3301            Some(("identity.json", b"identity-fixture")),
3302        )
3303        .is_err());
3304        assert_eq!(
3305            std::fs::read(data_dir.join(RESTORE_INTENT_FILE)).unwrap(),
3306            intent
3307        );
3308
3309        std::fs::remove_dir(data_dir.join("identity.json")).unwrap();
3310        let retry_staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
3311        super::publish_restore_staging(
3312            &retry_staging,
3313            &data_dir,
3314            true,
3315            Some(("identity.json", b"identity-fixture")),
3316        )
3317        .unwrap();
3318        assert_eq!(
3319            std::fs::read(data_dir.join("identity.json")).unwrap(),
3320            b"identity-fixture"
3321        );
3322        assert!(!data_dir.join(RESTORE_INTENT_FILE).exists());
3323    }
3324
3325    #[cfg(feature = "kv")]
3326    #[tokio::test]
3327    async fn kv_compacted_rejoin_restores_missing_or_corrupt_views_without_touching_recorder() {
3328        let root = tempfile::tempdir().unwrap();
3329        let identity = CheckpointIdentity::new("rhiza:kv:cluster-a", 1, 1, 1);
3330        let archive = ObjectArchiveStore::new_checkpoint_for_single_process(
3331            ObjStore::new(ObjStoreConfig::Local {
3332                root: root.path().join("archive"),
3333            })
3334            .unwrap(),
3335            identity.clone(),
3336        );
3337        archive.initialize_checkpoint().await.unwrap();
3338        let source_dir = root.path().join("source");
3339        let config = NodeConfig::new_embedded(
3340            "cluster-a",
3341            "node-1",
3342            source_dir,
3343            1,
3344            1,
3345            ["node-1", "node-2", "node-3"],
3346        )
3347        .unwrap()
3348        .with_execution_profile(ExecutionProfile::Kv)
3349        .unwrap()
3350        .with_recovery_generation(1)
3351        .unwrap();
3352        let consensus = Arc::new(
3353            ThreeNodeConsensus::from_recovered_tip(
3354                "rhiza:kv:cluster-a",
3355                "node-1",
3356                1,
3357                1,
3358                [
3359                    root.path().join("recorders/node-1"),
3360                    root.path().join("recorders/node-2"),
3361                    root.path().join("recorders/node-3"),
3362                ],
3363                1,
3364                LogHash::ZERO,
3365            )
3366            .unwrap(),
3367        );
3368        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
3369        let coordinator = CheckpointCoordinator::open(archive.clone(), DurabilityMode::Sync)
3370            .await
3371            .unwrap();
3372        let committed = runtime
3373            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"value".to_vec()).unwrap())
3374            .unwrap();
3375        coordinator.note_committed(committed.applied_index());
3376        coordinator
3377            .flush_runtime(&runtime, committed.applied_index())
3378            .await
3379            .unwrap();
3380        let checkpoint_root = runtime.checkpoint_compact(&coordinator).await.unwrap();
3381
3382        let target = root.path().join("target");
3383        restore_checkpoint_to_fresh_data_dir_for_node(archive.clone(), &target, "node-1")
3384            .await
3385            .unwrap();
3386        validate_local_recovery_view(
3387            &target,
3388            &identity,
3389            "node-1",
3390            ExecutionProfile::Kv,
3391            *checkpoint_root.compacted(),
3392        )
3393        .unwrap();
3394        std::fs::create_dir_all(target.join("recorder")).unwrap();
3395        std::fs::write(target.join("recorder/sentinel"), b"keep-me").unwrap();
3396
3397        std::fs::remove_dir_all(target.join("consensus")).unwrap();
3398        assert!(validate_local_recovery_view(
3399            &target,
3400            &identity,
3401            "node-1",
3402            ExecutionProfile::Kv,
3403            *checkpoint_root.compacted(),
3404        )
3405        .is_err());
3406        restore_checkpoint_for_rejoin_preserving_recorder(
3407            archive.clone(),
3408            &target,
3409            "node-1",
3410            ExecutionProfile::Kv,
3411            "identity.json",
3412            b"identity-fixture",
3413        )
3414        .await
3415        .unwrap();
3416
3417        std::fs::write(target.join("kv/data.redb"), b"corrupt").unwrap();
3418        assert!(validate_local_recovery_view(
3419            &target,
3420            &identity,
3421            "node-1",
3422            ExecutionProfile::Kv,
3423            *checkpoint_root.compacted(),
3424        )
3425        .is_err());
3426        restore_checkpoint_for_rejoin_preserving_recorder(
3427            archive,
3428            &target,
3429            "node-1",
3430            ExecutionProfile::Kv,
3431            "identity.json",
3432            b"identity-fixture",
3433        )
3434        .await
3435        .unwrap();
3436        assert_eq!(
3437            std::fs::read(target.join("recorder/sentinel")).unwrap(),
3438            b"keep-me"
3439        );
3440    }
3441
3442    #[test]
3443    fn concurrent_flush_completion_cannot_regress_the_durable_tip() {
3444        let newer = CheckpointTip::new(8, LogHash::digest(&[b"newer"]));
3445        let older = CheckpointTip::new(4, LogHash::digest(&[b"older"]));
3446        let mut state = CoordinatorState {
3447            durable_tip: newer,
3448            committed_index: 8,
3449            pending_lag: None,
3450            health: DurabilityHealth::Available,
3451        };
3452
3453        mark_durable_state(&mut state, older);
3454
3455        assert_eq!(state.durable_tip, newer);
3456    }
3457
3458    #[test]
3459    fn checkpoint_observation_rejects_same_index_hash_conflict_without_mutation() {
3460        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3461        let conflicting = CheckpointTip::new(8, LogHash::digest(&[b"conflicting"]));
3462        let state = Mutex::new(CoordinatorState {
3463            durable_tip: current,
3464            committed_index: 8,
3465            pending_lag: None,
3466            health: DurabilityHealth::Available,
3467        });
3468
3469        assert!(matches!(
3470            observe_durable_tip(&state, conflicting),
3471            Err(DurabilityError::SnapshotVerification(_))
3472        ));
3473        assert_eq!(state.lock().unwrap().durable_tip, current);
3474    }
3475
3476    #[test]
3477    fn checkpoint_observation_rejects_remote_rollback_without_mutation() {
3478        let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3479        let older = CheckpointTip::new(7, LogHash::digest(&[b"older"]));
3480        let state = Mutex::new(CoordinatorState {
3481            durable_tip: current,
3482            committed_index: 8,
3483            pending_lag: None,
3484            health: DurabilityHealth::Available,
3485        });
3486
3487        assert!(matches!(
3488            observe_durable_tip(&state, older),
3489            Err(DurabilityError::SnapshotVerification(_))
3490        ));
3491        assert_eq!(state.lock().unwrap().durable_tip, current);
3492    }
3493
3494    #[test]
3495    fn concurrent_conflicting_checkpoint_observations_accept_exactly_one_tip() {
3496        let state = Arc::new(Mutex::new(CoordinatorState {
3497            durable_tip: CheckpointTip::new(0, LogHash::ZERO),
3498            committed_index: 0,
3499            pending_lag: None,
3500            health: DurabilityHealth::Available,
3501        }));
3502        let start = Arc::new(Barrier::new(3));
3503        let results = [b"first".as_slice(), b"second".as_slice()]
3504            .into_iter()
3505            .map(|label| {
3506                let state = Arc::clone(&state);
3507                let start = Arc::clone(&start);
3508                std::thread::spawn(move || {
3509                    let tip = CheckpointTip::new(1, LogHash::digest(&[label]));
3510                    start.wait();
3511                    (tip, observe_durable_tip(&state, tip))
3512                })
3513            })
3514            .collect::<Vec<_>>();
3515        start.wait();
3516        let results = results
3517            .into_iter()
3518            .map(|thread| thread.join().unwrap())
3519            .collect::<Vec<_>>();
3520
3521        assert_eq!(
3522            results.iter().filter(|(_, result)| result.is_ok()).count(),
3523            1
3524        );
3525        assert_eq!(
3526            results.iter().filter(|(_, result)| result.is_err()).count(),
3527            1
3528        );
3529        let accepted = results
3530            .iter()
3531            .find_map(|(tip, result)| result.is_ok().then_some(*tip))
3532            .unwrap();
3533        assert_eq!(state.lock().unwrap().durable_tip, accepted);
3534    }
3535
3536    #[test]
3537    fn durable_progress_clears_lag_only_after_reaching_the_committed_index() {
3538        let mut state = CoordinatorState {
3539            durable_tip: CheckpointTip::new(2, LogHash::digest(&[b"two"])),
3540            committed_index: 4,
3541            pending_lag: Some(PendingLag::Recovered),
3542            health: DurabilityHealth::Unavailable,
3543        };
3544        let partial = CheckpointTip::new(3, LogHash::digest(&[b"three"]));
3545        mark_durable_state(&mut state, partial);
3546        assert!(state.pending_lag.is_some());
3547        assert_eq!(state.health, DurabilityHealth::Unavailable);
3548
3549        let complete = CheckpointTip::new(4, LogHash::digest(&[b"four"]));
3550        mark_durable_state(&mut state, complete);
3551
3552        assert_eq!(state.durable_tip, complete);
3553        assert!(state.pending_lag.is_none());
3554        assert_eq!(state.health, DurabilityHealth::Available);
3555    }
3556}