Skip to main content

rhiza_node/
durability.rs

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