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