1#[cfg(unix)]
2use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
3use std::{
4 error::Error,
5 fmt, fs,
6 future::Future,
7 io::{Read, Write},
8 path::{Path, PathBuf},
9 process,
10 sync::{
11 atomic::{AtomicBool, AtomicU64, Ordering},
12 Arc, Mutex, MutexGuard,
13 },
14 time::{Duration, Instant},
15};
16
17use rhiza_archive::{
18 CheckpointIdentity, CheckpointPublisher, CheckpointPublisherOptions, CheckpointTip,
19 ObjectArchiveStore, RestoredCheckpoint,
20};
21#[cfg(any(feature = "graph", feature = "kv"))]
22use rhiza_core::SnapshotIdentity;
23use rhiza_core::{
24 ConfigChange, ConfigurationState, ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex,
25 RecoveryAnchor, StopBinding,
26};
27#[cfg(feature = "graph")]
28use rhiza_graph::{
29 decode_snapshot as decode_graph_snapshot, encode_snapshot as encode_graph_snapshot,
30 restore_snapshot_file as restore_graph_snapshot_file, LadybugStateMachine,
31};
32#[cfg(feature = "kv")]
33use rhiza_kv::{
34 decode_snapshot as decode_kv_snapshot, encode_snapshot as encode_kv_snapshot,
35 restore_snapshot_file as restore_kv_snapshot_file, RedbStateMachine,
36};
37use rhiza_log::{FileLogStore, IndexRange, LogStore};
38use rhiza_quepaxa::Membership;
39#[cfg(feature = "sql")]
40use rhiza_sql::{restore_recovery_snapshot_file, sql_executor_fingerprint};
41use serde::{Deserialize, Serialize};
42
43use crate::{Materializer, NodeConfig, NodeRuntime, StopInformation};
44
45const FLUSH_BATCH_ENTRIES: LogIndex = 32;
46const SYNC_COMPACTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
47const RESTORE_INTENT_FILE: &str = ".rhiza-restore.json";
48const RESTORE_STAGING_PREFIX: &str = ".restore-stage-";
49const RESTORE_MARKER_TMP_PREFIX: &str = ".restore-marker-tmp-";
50const SUCCESSOR_RESTORE_LOCK_FILE: &str = ".successor-restore.lock";
51const SUCCESSOR_RESTORE_INTENT_FILE: &str = ".successor-restore.intent";
52const SUCCESSOR_RESTORE_COMPLETE_FILE: &str = ".successor-restore.complete";
53const SUCCESSOR_PRESTAGE_LOCK_FILE: &str = ".successor-prestage.lock";
54pub(crate) const SUCCESSOR_PRESTAGE_INTENT_FILE: &str = ".successor-prestage.intent";
55pub(crate) const SUCCESSOR_PRESTAGE_READY_FILE: &str = ".successor-prestage.ready";
56const SUCCESSOR_PRESTAGE_PUBLISHED_FILE: &str = ".successor-prestage.published";
57const SUCCESSOR_PRESTAGE_FINALIZED_FILE: &str = ".successor-prestage.finalized";
58const REPAIR_ARTIFACT_OWNER_FILE: &str = ".rhiza-recovery-owner.json";
59pub const LOCAL_CHECKPOINT_IDENTITY_FILE: &str = ".rhiza-checkpoint-identity.json";
60static RESTORE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
61
62#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
63#[serde(deny_unknown_fields)]
64struct RestoreIntentIdentity {
65 cluster_id: String,
66 node_id: String,
67 execution_profile: ExecutionProfile,
68 epoch: u64,
69 config_id: u64,
70 recovery_generation: u64,
71 checkpoint_index: LogIndex,
72 checkpoint_hash: String,
73}
74
75#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(tag = "kind", content = "identity", rename_all = "snake_case")]
77enum RecoveryArtifactIdentity {
78 Prestage(SuccessorPrestageIdentity),
79 Restore(RestoreIntentIdentity),
80}
81
82#[derive(Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84enum RepairArtifactRole {
85 Staging,
86 Quarantine,
87}
88
89#[derive(Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91struct RepairArtifactOwnership {
92 role: RepairArtifactRole,
93 name: String,
94 identity: RecoveryArtifactIdentity,
95}
96
97#[derive(Serialize)]
98struct SuccessorRestoreIdentity<'a> {
99 cluster_id: &'a str,
100 epoch: u64,
101 target_config_id: u64,
102 recovery_generation: u64,
103 node_id: &'a str,
104 membership_digest: String,
105 predecessor_config_id: u64,
106 stop_index: LogIndex,
107 stop_hash: String,
108}
109
110#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
111#[serde(deny_unknown_fields)]
112struct SuccessorRestoreReceipt {
113 cluster_id: String,
114 epoch: u64,
115 target_config_id: u64,
116 recovery_generation: u64,
117 node_id: String,
118 membership_digest: String,
119 predecessor_config_id: u64,
120 stop_index: LogIndex,
121 stop_hash: String,
122}
123
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(deny_unknown_fields)]
126pub struct SuccessorPrestageIdentity {
127 cluster_id: String,
128 epoch: u64,
129 predecessor_config_id: u64,
130 predecessor_membership_digest: String,
131 predecessor_recovery_generation: u64,
132 node_id: String,
133 execution_profile: ExecutionProfile,
134 target_config_id: u64,
135 target_membership_digest: String,
136 seed_index: LogIndex,
137 seed_hash: String,
138}
139
140impl SuccessorPrestageIdentity {
141 pub fn cluster_id(&self) -> &str {
142 &self.cluster_id
143 }
144
145 pub const fn epoch(&self) -> u64 {
146 self.epoch
147 }
148
149 pub const fn predecessor_config_id(&self) -> u64 {
150 self.predecessor_config_id
151 }
152
153 pub fn predecessor_membership_digest(&self) -> LogHash {
154 LogHash::from_hex(&self.predecessor_membership_digest)
155 .expect("validated predecessor membership digest")
156 }
157
158 pub const fn predecessor_recovery_generation(&self) -> u64 {
159 self.predecessor_recovery_generation
160 }
161
162 pub fn node_id(&self) -> &str {
163 &self.node_id
164 }
165
166 pub const fn execution_profile(&self) -> ExecutionProfile {
167 self.execution_profile
168 }
169
170 pub const fn target_config_id(&self) -> u64 {
171 self.target_config_id
172 }
173
174 pub fn target_membership_digest(&self) -> LogHash {
175 LogHash::from_hex(&self.target_membership_digest)
176 .expect("validated successor prestage membership digest")
177 }
178
179 pub fn seed_anchor(&self) -> LogAnchor {
180 LogAnchor::new(
181 self.seed_index,
182 LogHash::from_hex(&self.seed_hash).expect("validated successor prestage seed hash"),
183 )
184 }
185
186 fn checkpoint_identity(&self) -> CheckpointIdentity {
187 CheckpointIdentity::new(
188 self.cluster_id.clone(),
189 self.epoch,
190 self.predecessor_config_id,
191 self.predecessor_recovery_generation,
192 )
193 }
194}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub enum SuccessorPrestageState {
198 Preparing,
199 Ready,
200 Published,
201 Finalized,
202}
203
204#[derive(Debug)]
205pub struct SuccessorPrestage {
206 path: PathBuf,
207 identity: SuccessorPrestageIdentity,
208 state: SuccessorPrestageState,
209 _lock: fs::File,
210}
211
212impl SuccessorPrestage {
213 pub fn path(&self) -> &Path {
214 &self.path
215 }
216
217 pub const fn identity(&self) -> &SuccessorPrestageIdentity {
218 &self.identity
219 }
220
221 pub const fn state(&self) -> SuccessorPrestageState {
222 self.state
223 }
224}
225
226pub struct SuccessorRestorePreparation {
227 tip: CheckpointTip,
228 data_dir: PathBuf,
229 identity: Vec<u8>,
230 requires_recorder_install: bool,
231 _lock: fs::File,
232}
233
234impl SuccessorRestorePreparation {
235 pub const fn tip(&self) -> CheckpointTip {
236 self.tip
237 }
238
239 pub const fn requires_recorder_install(&self) -> bool {
240 self.requires_recorder_install
241 }
242
243 pub fn complete(mut self) -> Result<CheckpointTip, DurabilityError> {
244 if !self.requires_recorder_install {
245 return Ok(self.tip);
246 }
247 complete_adopted_successor_prestage(&self.data_dir, &self.identity)?;
248 self.requires_recorder_install = false;
249 Ok(self.tip)
250 }
251}
252
253pub fn complete_adopted_successor_prestage(
254 data_dir: &Path,
255 expected_identity: &[u8],
256) -> Result<(), DurabilityError> {
257 let intent = data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
258 let actual = read_regular_successor_control_file(&intent)?.ok_or_else(|| {
259 DurabilityError::SnapshotVerification("successor restore intent is missing".into())
260 })?;
261 if parse_successor_restore_receipt(&actual).is_none()
262 || parse_successor_restore_receipt(expected_identity).is_none()
263 || actual != expected_identity
264 {
265 return Err(DurabilityError::SnapshotVerification(
266 "successor restore intent changed before completion".into(),
267 ));
268 }
269 let complete = data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
270 match fs::symlink_metadata(&complete) {
271 Ok(_) => {
272 return Err(DurabilityError::SnapshotVerification(
273 "successor restore completion target already exists".into(),
274 ));
275 }
276 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
277 Err(error) => return Err(error.into()),
278 }
279 fs::rename(intent, complete)?;
280 sync_directory(data_dir)
281}
282
283#[derive(Clone, Debug, Eq, PartialEq)]
284pub enum DurabilityMode {
285 Sync,
286 Bounded { max_lag: Duration },
287 Periodic { interval: Duration },
288}
289
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub enum DurabilityHealth {
292 Available,
293 Unavailable,
294}
295
296#[derive(Clone, Copy, Debug, Eq, PartialEq)]
297pub enum CheckpointRestoreState {
298 None,
299 IdentityBound,
300}
301
302impl DurabilityMode {
303 pub fn validate(&self) -> Result<(), DurabilityError> {
304 match self {
305 Self::Sync => Ok(()),
306 Self::Bounded { max_lag } if max_lag.is_zero() => {
307 Err(DurabilityError::InvalidDuration { mode: "bounded" })
308 }
309 Self::Periodic { interval } if interval.is_zero() => {
310 Err(DurabilityError::InvalidDuration { mode: "periodic" })
311 }
312 Self::Bounded { .. } | Self::Periodic { .. } => Ok(()),
313 }
314 }
315}
316
317#[derive(Debug)]
318pub enum DurabilityError {
319 InvalidDuration {
320 mode: &'static str,
321 },
322 MissingCheckpoint,
323 Unavailable,
324 LagExceeded {
325 committed_index: LogIndex,
326 durable_index: LogIndex,
327 max_lag: Duration,
328 },
329 ArchiveAheadOfLocal {
330 durable_index: LogIndex,
331 local_index: LogIndex,
332 },
333 SnapshotRequired {
334 anchor: Box<RecoveryAnchor>,
335 },
336 LocalLogGap {
337 expected: LogIndex,
338 actual: Option<LogIndex>,
339 },
340 LocalLogConflict {
341 index: LogIndex,
342 },
343 SnapshotVerification(String),
344 PreconditionFailed,
345 DataDirNotFresh(PathBuf),
346 Archive(rhiza_archive::Error),
347 Log(rhiza_log::Error),
348 Io(std::io::Error),
349}
350
351impl fmt::Display for DurabilityError {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 match self {
354 Self::InvalidDuration { mode } => {
355 write!(f, "{mode} durability duration must be non-zero")
356 }
357 Self::MissingCheckpoint => write!(f, "checkpoint manifest is missing"),
358 Self::Unavailable => write!(f, "sync durability is unavailable"),
359 Self::LagExceeded {
360 committed_index,
361 durable_index,
362 max_lag,
363 } => write!(
364 f,
365 "checkpoint lag exceeded {max_lag:?}: committed index {committed_index}, durable index {durable_index}"
366 ),
367 Self::ArchiveAheadOfLocal {
368 durable_index,
369 local_index,
370 } => write!(
371 f,
372 "checkpoint tip {durable_index} is ahead of local qlog tip {local_index}"
373 ),
374 Self::SnapshotRequired { anchor } => write!(
375 f,
376 "snapshot restore required at qlog anchor {} before checkpoint flush",
377 anchor.compacted().index()
378 ),
379 Self::LocalLogGap { expected, actual } => {
380 write!(f, "local qlog gap: expected index {expected}, got {actual:?}")
381 }
382 Self::LocalLogConflict { index } => {
383 write!(f, "local qlog hash chain conflicts at index {index}")
384 }
385 Self::SnapshotVerification(message) => {
386 write!(f, "checkpoint snapshot verification failed: {message}")
387 }
388 Self::PreconditionFailed => write!(f, "checkpoint precondition failed"),
389 Self::DataDirNotFresh(path) => write!(
390 f,
391 "restore data directory contains existing state: {}",
392 path.display()
393 ),
394 Self::Archive(error) => error.fmt(f),
395 Self::Log(error) => error.fmt(f),
396 Self::Io(error) => error.fmt(f),
397 }
398 }
399}
400
401impl Error for DurabilityError {
402 fn source(&self) -> Option<&(dyn Error + 'static)> {
403 match self {
404 Self::Archive(error) => Some(error),
405 Self::Log(error) => Some(error),
406 Self::Io(error) => Some(error),
407 _ => None,
408 }
409 }
410}
411
412impl From<rhiza_archive::Error> for DurabilityError {
413 fn from(error: rhiza_archive::Error) -> Self {
414 Self::Archive(error)
415 }
416}
417
418impl From<rhiza_log::Error> for DurabilityError {
419 fn from(error: rhiza_log::Error) -> Self {
420 Self::Log(error)
421 }
422}
423
424impl From<std::io::Error> for DurabilityError {
425 fn from(error: std::io::Error) -> Self {
426 Self::Io(error)
427 }
428}
429
430#[derive(Debug)]
431enum PendingLag {
432 New(Instant),
433 Recovered,
434}
435
436#[derive(Debug)]
437struct CoordinatorState {
438 durable_tip: CheckpointTip,
439 committed_index: LogIndex,
440 pending_lag: Option<PendingLag>,
441 health: DurabilityHealth,
442}
443
444pub struct CheckpointCoordinator {
445 store: ObjectArchiveStore,
446 publisher: CheckpointPublisher,
447 mode: DurabilityMode,
448 state: Mutex<CoordinatorState>,
449 successor_baseline_required: AtomicBool,
450 publication_attempts: AtomicU64,
451}
452
453struct RuntimeCheckpointSnapshot {
454 anchor: RecoveryAnchor,
455 archive_bytes: Vec<u8>,
456}
457
458#[cfg(any(feature = "graph", feature = "kv"))]
459struct EngineSnapshotIdentity<'a> {
460 cluster_id: &'a str,
461 epoch: u64,
462 config_id: u64,
463 applied_index: LogIndex,
464 applied_hash: LogHash,
465}
466
467impl CheckpointCoordinator {
468 pub async fn open(
469 store: ObjectArchiveStore,
470 mode: DurabilityMode,
471 ) -> Result<Self, DurabilityError> {
472 Self::open_with_holder(store, mode, "anonymous-node").await
473 }
474
475 pub async fn open_with_holder(
476 store: ObjectArchiveStore,
477 mode: DurabilityMode,
478 holder: impl AsRef<str>,
479 ) -> Result<Self, DurabilityError> {
480 Self::open_with_holder_and_options(
481 store,
482 mode,
483 holder,
484 CheckpointPublisherOptions::default(),
485 )
486 .await
487 }
488
489 pub async fn open_with_holder_and_options(
490 store: ObjectArchiveStore,
491 mode: DurabilityMode,
492 holder: impl AsRef<str>,
493 publisher_options: CheckpointPublisherOptions,
494 ) -> Result<Self, DurabilityError> {
495 mode.validate()?;
496 store
497 .load_checkpoint()
498 .await?
499 .ok_or(DurabilityError::MissingCheckpoint)?;
500 let identity = store.checkpoint_identity()?;
501 let holder = format!(
502 "checkpoint-coordinator-{}-{}-{}-{}-{}",
503 identity.cluster_id(),
504 identity.epoch(),
505 identity.config_id(),
506 identity.recovery_generation(),
507 holder.as_ref()
508 );
509 let publisher = store
510 .open_checkpoint_publisher(holder, publisher_options)
511 .await?;
512 let loaded = publisher.cached_checkpoint().await;
513 let durable_tip = *loaded.manifest().tip();
514 let restored = store.restore_checkpoint_state().await?;
515 let restored_tip = *restored.tip();
516 if restored_tip != durable_tip {
517 return Err(DurabilityError::Archive(
518 rhiza_archive::Error::InvalidCheckpoint(
519 "restored entries changed while verifying the loaded manifest".into(),
520 ),
521 ));
522 }
523 Ok(Self {
524 store,
525 publisher,
526 mode,
527 state: Mutex::new(CoordinatorState {
528 durable_tip,
529 committed_index: durable_tip.index(),
530 pending_lag: None,
531 health: DurabilityHealth::Available,
532 }),
533 successor_baseline_required: AtomicBool::new(false),
534 publication_attempts: AtomicU64::new(0),
535 })
536 }
537
538 pub const fn mode(&self) -> &DurabilityMode {
539 &self.mode
540 }
541
542 pub fn durable_tip(&self) -> CheckpointTip {
543 self.lock_state().durable_tip
544 }
545
546 pub async fn refresh_durable_tip(&self) -> Result<CheckpointTip, DurabilityError> {
547 let loaded = self.publisher.observe_checkpoint().await?;
548 let accepted = self.publisher.cache_observed_checkpoint(loaded).await?;
549 observe_durable_tip(&self.state, *accepted.manifest().tip())
550 }
551
552 pub fn health(&self) -> DurabilityHealth {
553 self.lock_state().health
554 }
555
556 #[doc(hidden)]
557 pub fn checkpoint_publication_attempts(&self) -> u64 {
558 self.publication_attempts.load(Ordering::Relaxed)
559 }
560
561 pub fn note_committed(&self, index: LogIndex) {
562 let mut state = self.lock_state();
563 if index <= state.committed_index {
564 return;
565 }
566 state.committed_index = index;
567 if index > state.durable_tip.index() && state.pending_lag.is_none() {
568 state.pending_lag = Some(PendingLag::New(Instant::now()));
569 }
570 }
571
572 pub fn note_recovered_committed(&self, index: LogIndex) {
573 let mut state = self.lock_state();
574 state.committed_index = state.committed_index.max(index);
575 if state.committed_index > state.durable_tip.index() {
576 state.pending_lag = Some(PendingLag::Recovered);
577 }
578 }
579
580 pub fn write_allowed(&self) -> Result<(), DurabilityError> {
581 if self.successor_baseline_required.load(Ordering::Acquire) {
582 return Err(DurabilityError::Unavailable);
583 }
584 if matches!(self.mode, DurabilityMode::Sync)
585 && self.health() == DurabilityHealth::Unavailable
586 {
587 return Err(DurabilityError::Unavailable);
588 }
589 let DurabilityMode::Bounded { max_lag } = self.mode else {
590 return Ok(());
591 };
592 let state = self.lock_state();
593 let exceeded = state.committed_index > state.durable_tip.index()
594 && match state.pending_lag {
595 Some(PendingLag::Recovered) => true,
596 Some(PendingLag::New(pending)) => pending.elapsed() >= max_lag,
597 None => false,
598 };
599 if exceeded {
600 return Err(DurabilityError::LagExceeded {
601 committed_index: state.committed_index,
602 durable_index: state.durable_tip.index(),
603 max_lag,
604 });
605 }
606 Ok(())
607 }
608
609 #[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 => {
1432 #[cfg(feature = "graph")]
1433 {
1434 let path = data_dir.join("ladybug/graph.lbug");
1435 if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
1436 return Err(DurabilityError::SnapshotVerification(
1437 "Graph materializer is missing or is not a regular file".into(),
1438 ));
1439 }
1440 let _state = LadybugStateMachine::open(
1441 path,
1442 identity.cluster_id(),
1443 target_node_id,
1444 identity.epoch(),
1445 identity.config_id(),
1446 )
1447 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1448 true
1449 }
1450 #[cfg(not(feature = "graph"))]
1451 return Err(DurabilityError::SnapshotVerification(
1452 "graph execution profile is not compiled in".into(),
1453 ));
1454 }
1455 })
1456}
1457
1458pub async fn restore_checkpoint_for_rejoin_preserving_recorder(
1459 store: ObjectArchiveStore,
1460 data_dir: impl AsRef<Path>,
1461 target_node_id: &str,
1462 execution_profile: ExecutionProfile,
1463 marker_name: &str,
1464 marker_contents: &[u8],
1465) -> Result<CheckpointTip, DurabilityError> {
1466 if target_node_id.is_empty() {
1467 return Err(DurabilityError::SnapshotVerification(
1468 "target node_id is empty".into(),
1469 ));
1470 }
1471 validate_restore_marker_name(marker_name)?;
1472 let data_dir = data_dir.as_ref();
1473 let identity = store.checkpoint_identity()?.clone();
1474 if snapshot_profile(identity.cluster_id())? != execution_profile {
1475 return Err(DurabilityError::SnapshotVerification(
1476 "rejoin recovery only replaces a matching recovery view".into(),
1477 ));
1478 }
1479 let restored = store.restore_checkpoint_state().await?;
1480 let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1481 let intent = encode_restore_intent(
1482 &identity,
1483 target_node_id,
1484 execution_profile,
1485 checkpoint_root,
1486 )?;
1487 let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1488 &identity,
1489 target_node_id,
1490 execution_profile,
1491 checkpoint_root,
1492 ));
1493 checkpoint_restore_in_progress(
1494 data_dir,
1495 &identity,
1496 target_node_id,
1497 execution_profile,
1498 checkpoint_root,
1499 )?;
1500 cleanup_owned_recovery_artifacts(data_dir, &recovery_identity)?;
1501 publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1502 install_restored_checkpoint(
1503 &identity,
1504 &restored,
1505 data_dir,
1506 RestoreInstallOptions {
1507 target_node_id: Some(target_node_id),
1508 remove_generic_intent: true,
1509 replace_rebuildable: true,
1510 recovery_identity: Some(&recovery_identity),
1511 completion_marker: Some((marker_name, marker_contents)),
1512 },
1513 )
1514}
1515
1516async fn restore_checkpoint_to_fresh_data_dir_with_target(
1517 store: ObjectArchiveStore,
1518 data_dir: &Path,
1519 target_node_id: Option<&str>,
1520 completion_marker: Option<(&str, &[u8])>,
1521) -> Result<CheckpointTip, DurabilityError> {
1522 let identity = store.checkpoint_identity()?.clone();
1523 store
1524 .load_checkpoint()
1525 .await?
1526 .ok_or(DurabilityError::MissingCheckpoint)?;
1527 let restored = store.restore_checkpoint_state().await?;
1528 let target_node_id = target_node_id.unwrap_or("<unbound-restore>");
1529 let profile = snapshot_profile(identity.cluster_id())?;
1530 let checkpoint_root = LogAnchor::new(restored.tip().index(), restored.tip().hash());
1531 let intent = encode_restore_intent(&identity, target_node_id, profile, checkpoint_root)?;
1532 let recovery_identity = RecoveryArtifactIdentity::Restore(restore_intent_identity(
1533 &identity,
1534 target_node_id,
1535 profile,
1536 checkpoint_root,
1537 ));
1538 prepare_fresh_restore_data_dir(data_dir, completion_marker.map(|(name, _)| name), &intent)?;
1539 publish_restore_marker(data_dir, RESTORE_INTENT_FILE, &intent)?;
1540 install_restored_checkpoint(
1541 &identity,
1542 &restored,
1543 data_dir,
1544 RestoreInstallOptions {
1545 target_node_id: Some(target_node_id),
1546 remove_generic_intent: true,
1547 replace_rebuildable: false,
1548 recovery_identity: Some(&recovery_identity),
1549 completion_marker,
1550 },
1551 )
1552}
1553
1554struct RestoreInstallOptions<'a> {
1555 target_node_id: Option<&'a str>,
1556 remove_generic_intent: bool,
1557 replace_rebuildable: bool,
1558 recovery_identity: Option<&'a RecoveryArtifactIdentity>,
1559 completion_marker: Option<(&'a str, &'a [u8])>,
1560}
1561
1562struct RestoredCheckpointStaging {
1563 path: PathBuf,
1564 tip: CheckpointTip,
1565}
1566
1567fn install_restored_checkpoint(
1568 identity: &CheckpointIdentity,
1569 restored: &RestoredCheckpoint,
1570 data_dir: &Path,
1571 options: RestoreInstallOptions<'_>,
1572) -> Result<CheckpointTip, DurabilityError> {
1573 let staged = stage_restored_checkpoint(
1574 identity,
1575 restored,
1576 data_dir,
1577 options.target_node_id,
1578 options.recovery_identity,
1579 )?;
1580 let tip = staged.tip;
1581 let staging = staged.path;
1582 let profile = snapshot_profile(identity.cluster_id())?;
1583 let result = (|| -> Result<(), DurabilityError> {
1584 if options.replace_rebuildable {
1585 quarantine_rebuildable_view(data_dir, profile, options.recovery_identity)?;
1586 }
1587 publish_restore_staging(
1588 &staging,
1589 data_dir,
1590 options.remove_generic_intent,
1591 options.completion_marker,
1592 )
1593 })();
1594 if result.is_err() {
1595 let _ = fs::remove_dir_all(&staging);
1596 }
1597 result?;
1598 Ok(tip)
1599}
1600
1601fn stage_restored_checkpoint(
1602 identity: &CheckpointIdentity,
1603 restored: &RestoredCheckpoint,
1604 staging_parent: &Path,
1605 target_node_id: Option<&str>,
1606 recovery_identity: Option<&RecoveryArtifactIdentity>,
1607) -> Result<RestoredCheckpointStaging, DurabilityError> {
1608 let tip = *restored.tip();
1609 let profile = snapshot_profile(identity.cluster_id())?;
1610 validate_restored_suffix(profile, restored.suffix())?;
1611 let staging = create_restore_staging_dir(staging_parent, recovery_identity)?;
1612 let result = (|| -> Result<(), DurabilityError> {
1613 if let Some(snapshot) = restored.snapshot() {
1614 install_profile_snapshot(identity, snapshot, &staging, target_node_id)?;
1615 }
1616
1617 if restored.snapshot().is_some() || !restored.suffix().is_empty() {
1618 let initial_configuration = restored
1619 .snapshot()
1620 .map(|snapshot| snapshot.anchor().configuration_state().clone())
1621 .unwrap_or_else(|| ConfigurationState::active(identity.config_id(), LogHash::ZERO));
1622 let log = FileLogStore::open_with_configuration(
1623 staging.join("consensus/log"),
1624 identity.cluster_id(),
1625 identity.epoch(),
1626 initial_configuration,
1627 )?;
1628 if let Some(snapshot) = restored.snapshot() {
1629 log.install_recovery_anchor(
1630 snapshot.anchor(),
1631 identity.recovery_generation(),
1632 snapshot.anchor().configuration_state(),
1633 )?;
1634 }
1635 for batch in restored.suffix().chunks(FLUSH_BATCH_ENTRIES as usize) {
1636 log.append_batch(batch)?;
1637 }
1638 let installed_tip = log.logical_state()?.tip;
1639 if installed_tip.as_ref().map(|tip| (tip.index(), tip.hash()))
1640 != Some((tip.index(), tip.hash()))
1641 {
1642 return Err(DurabilityError::SnapshotVerification(
1643 "installed qlog tip does not match checkpoint tip".into(),
1644 ));
1645 }
1646 }
1647 sync_directory(&staging)
1648 })();
1649 if result.is_err() {
1650 let _ = fs::remove_dir_all(&staging);
1651 }
1652 result?;
1653 Ok(RestoredCheckpointStaging { path: staging, tip })
1654}
1655
1656fn validate_restored_suffix(
1657 profile: ExecutionProfile,
1658 suffix: &[LogEntry],
1659) -> Result<(), DurabilityError> {
1660 for entry in suffix {
1661 crate::validate_profile_entry_shape(profile, entry)
1662 .map_err(DurabilityError::SnapshotVerification)?;
1663 }
1664 Ok(())
1665}
1666
1667fn validate_local_qlog(
1668 data_dir: &Path,
1669 identity: &CheckpointIdentity,
1670 checkpoint_root: LogAnchor,
1671) -> Result<LogAnchor, DurabilityError> {
1672 validate_local_qlog_with_configuration(
1673 data_dir,
1674 identity,
1675 checkpoint_root,
1676 ConfigurationState::active(identity.config_id(), LogHash::ZERO),
1677 )
1678}
1679
1680fn validate_local_qlog_with_configuration(
1681 data_dir: &Path,
1682 identity: &CheckpointIdentity,
1683 checkpoint_root: LogAnchor,
1684 initial_configuration: ConfigurationState,
1685) -> Result<LogAnchor, DurabilityError> {
1686 let path = data_dir.join("consensus/log");
1687 if !path_has_state(&path)? {
1688 if checkpoint_root == LogAnchor::new(0, LogHash::ZERO) {
1689 return Ok(checkpoint_root);
1690 }
1691 return Err(DurabilityError::SnapshotVerification(
1692 "local qlog is missing or empty".into(),
1693 ));
1694 }
1695 let log = FileLogStore::open_with_configuration(
1696 path,
1697 identity.cluster_id(),
1698 identity.epoch(),
1699 initial_configuration,
1700 )?;
1701 let state = log.logical_state()?;
1702 let tip = state
1703 .tip
1704 .ok_or_else(|| DurabilityError::SnapshotVerification("local qlog has no tip".into()))?;
1705 if tip.index() < checkpoint_root.index() {
1706 return Err(DurabilityError::SnapshotVerification(format!(
1707 "local qlog tip {}/{} is behind checkpoint root {}/{}",
1708 tip.index(),
1709 tip.hash().to_hex(),
1710 checkpoint_root.index(),
1711 checkpoint_root.hash().to_hex(),
1712 )));
1713 }
1714 if checkpoint_root.index() == 0 {
1715 if checkpoint_root.hash() != LogHash::ZERO {
1716 return Err(DurabilityError::SnapshotVerification(
1717 "checkpoint genesis hash is not zero".into(),
1718 ));
1719 }
1720 return Ok(tip);
1721 }
1722 let included_hash = match state.anchor.as_ref() {
1723 Some(anchor) if anchor.compacted().index() == checkpoint_root.index() => {
1724 Some(anchor.compacted().hash())
1725 }
1726 Some(anchor) if anchor.compacted().index() > checkpoint_root.index() => {
1727 return Err(DurabilityError::SnapshotVerification(
1728 "local qlog compacted past checkpoint root without exact inclusion evidence".into(),
1729 ));
1730 }
1731 _ => log.read(checkpoint_root.index())?.map(|entry| entry.hash),
1732 };
1733 if included_hash != Some(checkpoint_root.hash()) {
1734 return Err(DurabilityError::SnapshotVerification(format!(
1735 "local qlog does not include checkpoint root {} with its exact hash",
1736 checkpoint_root.index(),
1737 )));
1738 }
1739 Ok(tip)
1740}
1741
1742fn install_profile_snapshot(
1743 identity: &CheckpointIdentity,
1744 snapshot: &rhiza_archive::RestoredCheckpointSnapshot,
1745 staging: &Path,
1746 target_node_id: Option<&str>,
1747) -> Result<(), DurabilityError> {
1748 match snapshot_profile(identity.cluster_id())? {
1749 ExecutionProfile::Sqlite => {
1750 #[cfg(feature = "sql")]
1751 {
1752 validate_anchor_fingerprint(
1753 snapshot.anchor(),
1754 sql_executor_fingerprint().map_err(|error| {
1755 DurabilityError::SnapshotVerification(error.to_string())
1756 })?,
1757 )?;
1758 let path = staging.join("sqlite/db.sqlite");
1759 let node_id = target_node_id.ok_or_else(|| {
1760 DurabilityError::SnapshotVerification(
1761 "SQLite QWAL snapshot restore requires a target node_id".into(),
1762 )
1763 })?;
1764 restore_recovery_snapshot_file(path, snapshot.bytes(), snapshot.anchor(), node_id)
1765 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1766 }
1767 #[cfg(not(feature = "sql"))]
1768 Err(DurabilityError::SnapshotVerification(
1769 "sql execution profile is not compiled in".into(),
1770 ))
1771 }
1772 ExecutionProfile::Graph => {
1773 #[cfg(feature = "graph")]
1774 {
1775 let decoded = decode_graph_snapshot(snapshot.bytes())
1776 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1777 validate_decoded_snapshot_anchor(
1778 snapshot.anchor(),
1779 decoded.cluster_id(),
1780 decoded.epoch(),
1781 decoded.config_id(),
1782 decoded.applied_index(),
1783 decoded.applied_hash(),
1784 decoded.materializer_fingerprint(),
1785 )?;
1786 let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1787 restore_graph_snapshot_file(
1788 staging.join("ladybug/graph.lbug"),
1789 &decoded,
1790 target_node_id,
1791 )
1792 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1793 }
1794 #[cfg(not(feature = "graph"))]
1795 Err(DurabilityError::SnapshotVerification(
1796 "graph recovery support is not compiled in".into(),
1797 ))
1798 }
1799 ExecutionProfile::Kv => {
1800 #[cfg(feature = "kv")]
1801 {
1802 let decoded = decode_kv_snapshot(snapshot.bytes())
1803 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1804 validate_decoded_snapshot_anchor(
1805 snapshot.anchor(),
1806 decoded.cluster_id(),
1807 decoded.epoch(),
1808 decoded.config_id(),
1809 decoded.applied_index(),
1810 decoded.applied_hash(),
1811 decoded.materializer_fingerprint(),
1812 )?;
1813 let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1814 restore_kv_snapshot_file(staging.join("kv/data.redb"), &decoded, target_node_id)
1815 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1816 }
1817 #[cfg(not(feature = "kv"))]
1818 Err(DurabilityError::SnapshotVerification(
1819 "KV recovery support is not compiled in".into(),
1820 ))
1821 }
1822 }
1823}
1824
1825fn snapshot_profile(cluster_id: &str) -> Result<ExecutionProfile, DurabilityError> {
1826 if matches!(cluster_id.strip_prefix("rhiza:graph:"), Some(logical) if !logical.is_empty()) {
1827 Ok(ExecutionProfile::Graph)
1828 } else if matches!(cluster_id.strip_prefix("rhiza:kv:"), Some(logical) if !logical.is_empty()) {
1829 Ok(ExecutionProfile::Kv)
1830 } else if matches!(cluster_id.strip_prefix("rhiza:sql:"), Some(logical) if !logical.is_empty())
1831 {
1832 Ok(ExecutionProfile::Sqlite)
1833 } else {
1834 Err(DurabilityError::SnapshotVerification(
1835 "snapshot checkpoint identity has no canonical execution profile prefix".into(),
1836 ))
1837 }
1838}
1839
1840fn validate_anchor_fingerprint(
1841 anchor: &RecoveryAnchor,
1842 expected: LogHash,
1843) -> Result<(), DurabilityError> {
1844 if anchor.executor_fingerprint() != expected {
1845 return Err(DurabilityError::SnapshotVerification(
1846 "snapshot executor fingerprint does not match this binary".into(),
1847 ));
1848 }
1849 Ok(())
1850}
1851
1852#[cfg(any(feature = "graph", feature = "kv"))]
1853fn validate_decoded_snapshot_anchor(
1854 anchor: &RecoveryAnchor,
1855 cluster_id: &str,
1856 epoch: u64,
1857 config_id: u64,
1858 applied_index: LogIndex,
1859 applied_hash: LogHash,
1860 materializer_fingerprint: LogHash,
1861) -> Result<(), DurabilityError> {
1862 validate_anchor_fingerprint(anchor, materializer_fingerprint)?;
1863 if anchor.cluster_id() != cluster_id
1864 || anchor.epoch() != epoch
1865 || anchor.config_id() != config_id
1866 || anchor.compacted().index() != applied_index
1867 || anchor.compacted().hash() != applied_hash
1868 {
1869 return Err(DurabilityError::SnapshotVerification(
1870 "decoded snapshot identity does not match its recovery anchor".into(),
1871 ));
1872 }
1873 Ok(())
1874}
1875
1876pub async fn prestage_successor_checkpoint(
1877 store: ObjectArchiveStore,
1878 prestage_dir: impl AsRef<Path>,
1879 predecessor_configuration: ConfigurationState,
1880 target_node_id: &str,
1881 execution_profile: ExecutionProfile,
1882 target_config_id: u64,
1883 target_membership_digest: LogHash,
1884) -> Result<SuccessorPrestage, DurabilityError> {
1885 if target_node_id.is_empty() {
1886 return Err(DurabilityError::SnapshotVerification(
1887 "successor prestage target node_id is empty".into(),
1888 ));
1889 }
1890 let identity = store.checkpoint_identity()?.clone();
1891 if !predecessor_configuration.is_active()
1892 || predecessor_configuration.config_id() != identity.config_id()
1893 {
1894 return Err(DurabilityError::SnapshotVerification(
1895 "successor prestage predecessor configuration does not match the checkpoint".into(),
1896 ));
1897 }
1898 if snapshot_profile(identity.cluster_id())? != execution_profile {
1899 return Err(DurabilityError::SnapshotVerification(
1900 "successor prestage profile does not match checkpoint identity".into(),
1901 ));
1902 }
1903 if identity
1904 .config_id()
1905 .checked_add(1)
1906 .filter(|next| *next == target_config_id)
1907 .is_none()
1908 {
1909 return Err(DurabilityError::SnapshotVerification(
1910 "successor prestage target config_id is not the next configuration".into(),
1911 ));
1912 }
1913 let loaded = store
1914 .load_checkpoint()
1915 .await?
1916 .ok_or(DurabilityError::MissingCheckpoint)?;
1917 if loaded.manifest().identity() != &identity {
1918 return Err(DurabilityError::SnapshotVerification(
1919 "successor prestage checkpoint identity changed while loading".into(),
1920 ));
1921 }
1922 let expected = SuccessorPrestageIdentity {
1923 cluster_id: identity.cluster_id().to_owned(),
1924 epoch: identity.epoch(),
1925 predecessor_config_id: identity.config_id(),
1926 predecessor_membership_digest: predecessor_configuration.digest().to_hex(),
1927 predecessor_recovery_generation: identity.recovery_generation(),
1928 node_id: target_node_id.to_owned(),
1929 execution_profile,
1930 target_config_id,
1931 target_membership_digest: target_membership_digest.to_hex(),
1932 seed_index: loaded.manifest().tip().index(),
1933 seed_hash: loaded.manifest().tip().hash().to_hex(),
1934 };
1935 validate_successor_prestage_identity(&expected)?;
1936 let prestage_dir = prestage_dir.as_ref();
1937 let mut prestage =
1938 prepare_successor_prestage_root(prestage_dir, Some(&expected), &predecessor_configuration)?;
1939 match prestage.state {
1940 SuccessorPrestageState::Ready
1941 | SuccessorPrestageState::Published
1942 | SuccessorPrestageState::Finalized => return Ok(prestage),
1943 SuccessorPrestageState::Preparing => {}
1944 }
1945
1946 cleanup_preparing_successor_prestage(prestage_dir, &expected)?;
1947 let restored = store.restore_checkpoint_state().await?;
1948 if restored.tip() != loaded.manifest().tip() {
1949 return Err(DurabilityError::SnapshotVerification(
1950 "successor prestage checkpoint changed during restore".into(),
1951 ));
1952 }
1953 let recovery_identity = RecoveryArtifactIdentity::Prestage(expected.clone());
1954 let staged = stage_restored_checkpoint(
1955 &identity,
1956 &restored,
1957 prestage_dir,
1958 Some(target_node_id),
1959 Some(&recovery_identity),
1960 )?;
1961 if let Err(error) = publish_restore_staging(&staged.path, prestage_dir, false, None) {
1962 let _ = fs::remove_dir_all(&staged.path);
1963 return Err(error);
1964 }
1965 fs::rename(
1966 prestage_dir.join(SUCCESSOR_PRESTAGE_INTENT_FILE),
1967 prestage_dir.join(SUCCESSOR_PRESTAGE_READY_FILE),
1968 )?;
1969 sync_directory(prestage_dir)?;
1970 prestage.state = SuccessorPrestageState::Ready;
1971 Ok(prestage)
1972}
1973
1974pub fn inspect_successor_prestage(
1975 prestage_dir: impl AsRef<Path>,
1976 predecessor_configuration: ConfigurationState,
1977) -> Result<SuccessorPrestage, DurabilityError> {
1978 prepare_successor_prestage_root(prestage_dir.as_ref(), None, &predecessor_configuration)
1979}
1980
1981pub fn publish_successor_prestage(
1982 mut prestage: SuccessorPrestage,
1983 data_dir: impl AsRef<Path>,
1984) -> Result<SuccessorPrestage, DurabilityError> {
1985 let data_dir = data_dir.as_ref();
1986 match prestage.state {
1987 SuccessorPrestageState::Published if prestage.path == data_dir => return Ok(prestage),
1988 SuccessorPrestageState::Ready => {}
1989 _ => return Err(DurabilityError::PreconditionFailed),
1990 }
1991 if prestage.path != data_dir {
1992 match fs::symlink_metadata(data_dir) {
1993 Ok(_) => return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf())),
1994 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1995 Err(error) => return Err(error.into()),
1996 }
1997 let source_parent = prestage.path.parent().ok_or_else(|| {
1998 DurabilityError::SnapshotVerification(
1999 "successor prestage path has no parent directory".into(),
2000 )
2001 })?;
2002 let target_parent = data_dir.parent().ok_or_else(|| {
2003 DurabilityError::SnapshotVerification(
2004 "successor data path has no parent directory".into(),
2005 )
2006 })?;
2007 fs::create_dir_all(target_parent)?;
2008 #[cfg(unix)]
2009 if fs::metadata(source_parent)?.dev() != fs::metadata(target_parent)?.dev() {
2010 return Err(DurabilityError::SnapshotVerification(
2011 "successor prestage and final data directory must share a filesystem".into(),
2012 ));
2013 }
2014 fs::rename(&prestage.path, data_dir)?;
2015 sync_directory(source_parent)?;
2016 if target_parent != source_parent {
2017 sync_directory(target_parent)?;
2018 }
2019 prestage.path = data_dir.to_path_buf();
2020 }
2021 fs::rename(
2022 prestage.path.join(SUCCESSOR_PRESTAGE_READY_FILE),
2023 prestage.path.join(SUCCESSOR_PRESTAGE_PUBLISHED_FILE),
2024 )?;
2025 sync_directory(&prestage.path)?;
2026 prestage.state = SuccessorPrestageState::Published;
2027 Ok(prestage)
2028}
2029
2030fn validate_successor_prestage_stop(
2031 identity: &SuccessorPrestageIdentity,
2032 stop: &StopInformation,
2033 predecessor_membership: &Membership,
2034) -> Result<rhiza_core::SuccessorDescriptor, DurabilityError> {
2035 if stop.entry.cluster_id != identity.cluster_id()
2036 || stop.entry.epoch != identity.epoch()
2037 || stop.entry.config_id != identity.predecessor_config_id()
2038 || stop.entry.recompute_hash() != stop.entry.hash
2039 {
2040 return Err(DurabilityError::SnapshotVerification(
2041 "successor prestage requires the exact bound Stop".into(),
2042 ));
2043 }
2044 let change = ConfigChange::recognize_parts(stop.entry.entry_type, &stop.entry.payload)
2045 .map_err(|_| {
2046 DurabilityError::SnapshotVerification(
2047 "successor prestage Stop entry is not a bound configuration change".into(),
2048 )
2049 })?;
2050 let ConfigChange::BoundStop { successor } = change else {
2051 return Err(DurabilityError::SnapshotVerification(
2052 "successor prestage requires a bound Stop entry".into(),
2053 ));
2054 };
2055 if successor.cluster_id() != identity.cluster_id()
2056 || successor.predecessor_config_id() != identity.predecessor_config_id()
2057 || successor.predecessor_config_digest() != predecessor_membership.digest()
2058 || successor.config_id() != identity.target_config_id()
2059 || successor.digest() != identity.target_membership_digest()
2060 {
2061 return Err(DurabilityError::SnapshotVerification(
2062 "successor prestage Stop binding conflicts with the prestage target".into(),
2063 ));
2064 }
2065 stop.proof
2066 .validate_for_cluster(
2067 identity.cluster_id(),
2068 stop.entry.index,
2069 identity.epoch(),
2070 identity.predecessor_config_id(),
2071 predecessor_membership,
2072 )
2073 .map_err(|error| {
2074 DurabilityError::SnapshotVerification(format!(
2075 "successor prestage Stop proof is not quorum-certified: {error:?}"
2076 ))
2077 })?;
2078 let command = rhiza_core::StoredCommand::new(stop.entry.entry_type, stop.entry.payload.clone());
2079 let expected_value = rhiza_quepaxa::AcceptedValue::from_command(
2080 identity.cluster_id(),
2081 stop.entry.index,
2082 identity.epoch(),
2083 identity.predecessor_config_id(),
2084 stop.entry.prev_hash,
2085 &command,
2086 );
2087 if stop.proof.proposal().value.as_ref() != Some(&expected_value) {
2088 return Err(DurabilityError::SnapshotVerification(
2089 "successor prestage Stop proof value does not match the exact Stop entry".into(),
2090 ));
2091 }
2092 Ok(successor)
2093}
2094
2095pub fn finalize_successor_prestage_for_stop(
2096 mut prestage: SuccessorPrestage,
2097 stop: &StopInformation,
2098 predecessor_membership: &Membership,
2099) -> Result<SuccessorPrestage, DurabilityError> {
2100 if !matches!(
2101 prestage.state,
2102 SuccessorPrestageState::Published | SuccessorPrestageState::Finalized
2103 ) {
2104 return Err(DurabilityError::PreconditionFailed);
2105 }
2106 let successor =
2107 validate_successor_prestage_stop(&prestage.identity, stop, predecessor_membership)?;
2108 let expected_stop = LogAnchor::new(stop.entry.index, stop.entry.hash);
2109 let local_tip = validate_local_qlog_with_configuration(
2110 &prestage.path,
2111 &prestage.identity.checkpoint_identity(),
2112 prestage.identity.seed_anchor(),
2113 ConfigurationState::active(
2114 prestage.identity.predecessor_config_id(),
2115 successor.predecessor_config_digest(),
2116 ),
2117 )?;
2118 if local_tip != expected_stop {
2119 return Err(DurabilityError::SnapshotVerification(
2120 "successor prestage final qlog tip does not exactly match the bound Stop".into(),
2121 ));
2122 }
2123 if prestage.state == SuccessorPrestageState::Published {
2124 fs::rename(
2125 prestage.path.join(SUCCESSOR_PRESTAGE_PUBLISHED_FILE),
2126 prestage.path.join(SUCCESSOR_PRESTAGE_FINALIZED_FILE),
2127 )?;
2128 sync_directory(&prestage.path)?;
2129 prestage.state = SuccessorPrestageState::Finalized;
2130 }
2131 Ok(prestage)
2132}
2133
2134pub fn adopt_finalized_successor_prestage(
2135 prestage: SuccessorPrestage,
2136 config: &NodeConfig,
2137 stop: &StopInformation,
2138 predecessor_membership: &Membership,
2139) -> Result<SuccessorRestorePreparation, DurabilityError> {
2140 if prestage.state != SuccessorPrestageState::Finalized
2141 || prestage.path.as_path() != config.data_dir().as_path()
2142 || prestage.identity.cluster_id() != config.cluster_id()
2143 || prestage.identity.epoch() != config.epoch()
2144 || prestage.identity.predecessor_recovery_generation() != config.recovery_generation()
2145 || prestage.identity.node_id() != config.node_id()
2146 || prestage.identity.execution_profile() != config.execution_profile()
2147 || prestage.identity.target_membership_digest() != config.membership().digest()
2148 || stop.entry.cluster_id != config.cluster_id()
2149 || stop.entry.epoch != config.epoch()
2150 || stop.entry.config_id != prestage.identity.predecessor_config_id()
2151 || stop.entry.recompute_hash() != stop.entry.hash
2152 || config.predecessor_stop_entry.as_ref() != Some(&stop.entry)
2153 {
2154 return Err(DurabilityError::SnapshotVerification(
2155 "finalized successor prestage does not match the target configuration and Stop".into(),
2156 ));
2157 }
2158 let successor =
2159 validate_successor_prestage_stop(&prestage.identity, stop, predecessor_membership)?;
2160 let predecessor_digest = successor.predecessor_config_digest();
2161 let expected_stop = LogAnchor::new(stop.entry.index, stop.entry.hash);
2162 if successor.cluster_id() != config.cluster_id()
2163 || successor.predecessor_config_id() != prestage.identity.predecessor_config_id()
2164 || successor.config_id() != prestage.identity.target_config_id()
2165 || successor.digest() != prestage.identity.target_membership_digest()
2166 || successor.members() != config.membership().members()
2167 || config.log_initial_configuration()
2168 != &ConfigurationState::active(
2169 prestage.identity.predecessor_config_id(),
2170 predecessor_digest,
2171 )
2172 || config.configuration_state()
2173 != &ConfigurationState::stopped(
2174 prestage.identity.predecessor_config_id(),
2175 predecessor_digest,
2176 expected_stop,
2177 StopBinding::Bound {
2178 successor: successor.clone(),
2179 stop_command_hash: rhiza_core::StoredCommand::new(
2180 stop.entry.entry_type,
2181 stop.entry.payload.clone(),
2182 )
2183 .hash(),
2184 },
2185 )
2186 {
2187 return Err(DurabilityError::SnapshotVerification(
2188 "finalized successor prestage Stop binding conflicts with the target configuration"
2189 .into(),
2190 ));
2191 }
2192 let local_tip = validate_local_qlog_with_configuration(
2193 &prestage.path,
2194 &prestage.identity.checkpoint_identity(),
2195 prestage.identity.seed_anchor(),
2196 ConfigurationState::active(
2197 prestage.identity.predecessor_config_id(),
2198 predecessor_digest,
2199 ),
2200 )?;
2201 if local_tip != expected_stop {
2202 return Err(DurabilityError::SnapshotVerification(
2203 "finalized successor prestage qlog does not end at the exact Stop".into(),
2204 ));
2205 }
2206
2207 let receipt = serde_json::to_vec(&SuccessorRestoreIdentity {
2208 cluster_id: config.cluster_id(),
2209 epoch: config.epoch(),
2210 target_config_id: prestage.identity.target_config_id(),
2211 recovery_generation: config.recovery_generation(),
2212 node_id: config.node_id(),
2213 membership_digest: config.membership().digest().to_hex(),
2214 predecessor_config_id: prestage.identity.predecessor_config_id(),
2215 stop_index: stop.entry.index,
2216 stop_hash: stop.entry.hash.to_hex(),
2217 })
2218 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2219 let successor_lock = fs::OpenOptions::new()
2220 .read(true)
2221 .write(true)
2222 .create(true)
2223 .truncate(false)
2224 .open(prestage.path.join(SUCCESSOR_RESTORE_LOCK_FILE))?;
2225 successor_lock
2226 .try_lock()
2227 .map_err(|_| DurabilityError::PreconditionFailed)?;
2228 let intent = prestage.path.join(SUCCESSOR_RESTORE_INTENT_FILE);
2229 let complete = prestage.path.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
2230 match (
2231 read_regular_successor_control_file(&intent)?,
2232 read_regular_successor_control_file(&complete)?,
2233 ) {
2234 (None, None) => {
2235 publish_restore_marker(&prestage.path, SUCCESSOR_RESTORE_INTENT_FILE, &receipt)?
2236 }
2237 (Some(actual), None) if actual == receipt => {}
2238 _ => {
2239 return Err(DurabilityError::DataDirNotFresh(
2240 prestage.path.to_path_buf(),
2241 ))
2242 }
2243 }
2244 fs::remove_file(prestage.path.join(SUCCESSOR_PRESTAGE_FINALIZED_FILE))?;
2245 sync_directory(&prestage.path)?;
2246 Ok(SuccessorRestorePreparation {
2247 tip: CheckpointTip::new(stop.entry.index, stop.entry.hash),
2248 data_dir: prestage.path.clone(),
2249 identity: receipt,
2250 requires_recorder_install: true,
2251 _lock: successor_lock,
2252 })
2253}
2254
2255fn write_repair_artifact_ownership(
2256 artifact: &Path,
2257 role: RepairArtifactRole,
2258 identity: &RecoveryArtifactIdentity,
2259) -> Result<(), DurabilityError> {
2260 let name = artifact
2261 .file_name()
2262 .and_then(|name| name.to_str())
2263 .ok_or_else(|| {
2264 DurabilityError::SnapshotVerification(
2265 "repair artifact path must have a UTF-8 final component".into(),
2266 )
2267 })?
2268 .to_owned();
2269 let contents = serde_json::to_vec(&RepairArtifactOwnership {
2270 role,
2271 name,
2272 identity: identity.clone(),
2273 })
2274 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2275 let owner = artifact.join(REPAIR_ARTIFACT_OWNER_FILE);
2276 let mut file = fs::OpenOptions::new()
2277 .write(true)
2278 .create_new(true)
2279 .open(owner)?;
2280 file.write_all(&contents)?;
2281 file.sync_all()?;
2282 sync_directory(artifact)
2283}
2284
2285fn create_restore_staging_dir(
2286 data_dir: &Path,
2287 recovery_identity: Option<&RecoveryArtifactIdentity>,
2288) -> Result<PathBuf, DurabilityError> {
2289 fs::create_dir_all(data_dir)?;
2290 for _ in 0..128 {
2291 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2292 let staging = data_dir.join(format!(
2293 "{RESTORE_STAGING_PREFIX}{}-{sequence}",
2294 process::id()
2295 ));
2296 match fs::create_dir(&staging) {
2297 Ok(()) => {
2298 if let Some(identity) = recovery_identity {
2299 if let Err(error) = write_repair_artifact_ownership(
2300 &staging,
2301 RepairArtifactRole::Staging,
2302 identity,
2303 ) {
2304 let _ = fs::remove_dir_all(&staging);
2305 return Err(error);
2306 }
2307 }
2308 return Ok(staging);
2309 }
2310 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2311 Err(error) => return Err(error.into()),
2312 }
2313 }
2314 Err(std::io::Error::new(
2315 std::io::ErrorKind::AlreadyExists,
2316 "could not allocate restore staging directory",
2317 )
2318 .into())
2319}
2320
2321fn publish_restore_staging(
2322 staging: &Path,
2323 data_dir: &Path,
2324 remove_generic_intent: bool,
2325 completion_marker: Option<(&str, &[u8])>,
2326) -> Result<(), DurabilityError> {
2327 sync_directory(staging)?;
2328 for name in ["sqlite", "ladybug", "kv", "consensus"] {
2329 let source = staging.join(name);
2330 if source.exists() {
2331 fs::rename(&source, data_dir.join(name))?;
2332 }
2333 }
2334 fs::remove_dir_all(staging)?;
2338 sync_directory(data_dir)?;
2339 if let Some((marker_name, marker_contents)) = completion_marker {
2340 publish_restore_marker(data_dir, marker_name, marker_contents)?;
2341 }
2342 if remove_generic_intent {
2343 fs::remove_file(data_dir.join(RESTORE_INTENT_FILE))?;
2344 }
2345 sync_directory(data_dir)
2346}
2347
2348fn quarantine_rebuildable_view(
2349 data_dir: &Path,
2350 profile: ExecutionProfile,
2351 recovery_identity: Option<&RecoveryArtifactIdentity>,
2352) -> Result<Option<PathBuf>, DurabilityError> {
2353 let materializer = match profile {
2354 ExecutionProfile::Sqlite => "sqlite",
2355 ExecutionProfile::Kv => "kv",
2356 ExecutionProfile::Graph => "ladybug",
2357 };
2358 let names = [materializer, "consensus"];
2359 let mut has_rebuildable_view = false;
2360 for name in names {
2361 match fs::symlink_metadata(data_dir.join(name)) {
2362 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
2363 return Err(DurabilityError::SnapshotVerification(
2364 "rebuildable recovery view is not a regular directory".into(),
2365 ));
2366 }
2367 Ok(_) => has_rebuildable_view = true,
2368 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2369 Err(error) => return Err(error.into()),
2370 }
2371 }
2372 if !has_rebuildable_view {
2373 return Ok(None);
2374 }
2375 for _ in 0..128 {
2376 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2377 let quarantine = data_dir.join(format!(
2378 ".rebuildable-quarantine-{}-{sequence}",
2379 process::id()
2380 ));
2381 match fs::create_dir(&quarantine) {
2382 Ok(()) => {
2383 if let Some(identity) = recovery_identity {
2384 if let Err(error) = write_repair_artifact_ownership(
2385 &quarantine,
2386 RepairArtifactRole::Quarantine,
2387 identity,
2388 ) {
2389 let _ = fs::remove_dir_all(&quarantine);
2390 return Err(error);
2391 }
2392 }
2393 for name in names {
2394 let source = data_dir.join(name);
2395 match fs::symlink_metadata(&source) {
2396 Ok(_) => fs::rename(source, quarantine.join(name))?,
2397 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2398 Err(error) => return Err(error.into()),
2399 }
2400 }
2401 sync_directory(&quarantine)?;
2402 sync_directory(data_dir)?;
2403 return Ok(Some(quarantine));
2404 }
2405 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2406 Err(error) => return Err(error.into()),
2407 }
2408 }
2409 Err(std::io::Error::new(
2410 std::io::ErrorKind::AlreadyExists,
2411 "could not allocate rebuildable recovery quarantine",
2412 )
2413 .into())
2414}
2415
2416fn publish_restore_marker(
2417 data_dir: &Path,
2418 marker_name: &str,
2419 contents: &[u8],
2420) -> Result<(), DurabilityError> {
2421 validate_restore_marker_name(marker_name)?;
2422 fs::create_dir_all(data_dir)?;
2423 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2424 let temporary = data_dir.join(format!(
2425 "{RESTORE_MARKER_TMP_PREFIX}{}-{sequence}",
2426 process::id()
2427 ));
2428 let mut file = fs::OpenOptions::new()
2429 .write(true)
2430 .create_new(true)
2431 .open(&temporary)?;
2432 file.write_all(contents)?;
2433 file.sync_all()?;
2434 fs::rename(temporary, data_dir.join(marker_name))?;
2435 sync_directory(data_dir)
2436}
2437
2438fn validate_restore_marker_name(marker_name: &str) -> Result<(), DurabilityError> {
2439 if marker_name.is_empty()
2440 || matches!(marker_name, "." | "..")
2441 || marker_name.contains(std::path::MAIN_SEPARATOR)
2442 {
2443 return Err(DurabilityError::SnapshotVerification(
2444 "restore marker name must be one local file name".into(),
2445 ));
2446 }
2447 Ok(())
2448}
2449
2450fn is_owned_generated_recovery_name(name: &str, prefix: &str) -> bool {
2451 let Some(suffix) = name.strip_prefix(prefix) else {
2452 return false;
2453 };
2454 let mut parts = suffix.split('-');
2455 let (Some(process_id), Some(sequence), None) = (parts.next(), parts.next(), parts.next())
2456 else {
2457 return false;
2458 };
2459 process_id.parse::<u32>().is_ok_and(|id| id > 0) && sequence.parse::<u64>().is_ok()
2460}
2461
2462fn is_safe_restore_marker_tmp(path: &Path, name: &str) -> Result<bool, DurabilityError> {
2463 if !is_owned_generated_recovery_name(name, RESTORE_MARKER_TMP_PREFIX) {
2464 return Ok(false);
2465 }
2466 Ok(read_bounded_regular_file(path, 16384)?.is_some())
2467}
2468
2469fn is_owned_recovery_directory(
2470 path: &Path,
2471 allowed_children: &[&str],
2472 expected_role: RepairArtifactRole,
2473 expected_identity: &RecoveryArtifactIdentity,
2474) -> Result<bool, DurabilityError> {
2475 let metadata = fs::symlink_metadata(path)?;
2476 if metadata.file_type().is_symlink() || !metadata.is_dir() {
2477 return Ok(false);
2478 }
2479 let owner = path.join(REPAIR_ARTIFACT_OWNER_FILE);
2480 let Some(owner_bytes) = read_bounded_regular_file(&owner, 16384)? else {
2481 return Ok(false);
2482 };
2483 let Ok(ownership) = serde_json::from_slice::<RepairArtifactOwnership>(&owner_bytes) else {
2484 return Ok(false);
2485 };
2486 if ownership.role != expected_role
2487 || ownership.identity != *expected_identity
2488 || path.file_name().and_then(|name| name.to_str()) != Some(ownership.name.as_str())
2489 {
2490 return Ok(false);
2491 }
2492 for entry in fs::read_dir(path)? {
2493 let entry = entry?;
2494 let name = entry.file_name();
2495 let name = name.to_string_lossy();
2496 if name == REPAIR_ARTIFACT_OWNER_FILE {
2497 continue;
2498 }
2499 let metadata = fs::symlink_metadata(entry.path())?;
2500 if !allowed_children.contains(&name.as_ref())
2501 || metadata.file_type().is_symlink()
2502 || !metadata.is_dir()
2503 {
2504 return Ok(false);
2505 }
2506 }
2507 Ok(true)
2508}
2509
2510fn cleanup_owned_recovery_artifacts(
2511 data_dir: &Path,
2512 identity: &RecoveryArtifactIdentity,
2513) -> Result<(), DurabilityError> {
2514 let mut cleanup = Vec::new();
2515 for entry in fs::read_dir(data_dir)? {
2516 let entry = entry?;
2517 let name = entry.file_name();
2518 let name = name.to_string_lossy();
2519 let has_staging_prefix = name.starts_with(RESTORE_STAGING_PREFIX);
2520 let has_quarantine_prefix = name.starts_with(".rebuildable-quarantine-");
2521 if (has_staging_prefix && !is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX))
2522 || (has_quarantine_prefix
2523 && !is_owned_generated_recovery_name(&name, ".rebuildable-quarantine-"))
2524 {
2525 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2526 }
2527 let artifact = if has_staging_prefix {
2528 Some((
2529 ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
2530 RepairArtifactRole::Staging,
2531 ))
2532 } else if has_quarantine_prefix {
2533 Some((
2534 ["sqlite", "ladybug", "kv", "consensus"].as_slice(),
2535 RepairArtifactRole::Quarantine,
2536 ))
2537 } else {
2538 None
2539 };
2540 let Some((allowed_children, role)) = artifact else {
2541 continue;
2542 };
2543 if !is_owned_recovery_directory(&entry.path(), allowed_children, role, identity)? {
2544 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2545 }
2546 cleanup.push(entry.path());
2547 }
2548 for path in cleanup.iter() {
2549 fs::remove_dir_all(path)?;
2550 }
2551 if !cleanup.is_empty() {
2552 sync_directory(data_dir)?;
2553 }
2554 Ok(())
2555}
2556
2557#[cfg(any(target_os = "linux", target_os = "android"))]
2558const O_NOFOLLOW_FLAG: i32 = 0o400000;
2559#[cfg(any(
2560 target_os = "macos",
2561 target_os = "ios",
2562 target_os = "freebsd",
2563 target_os = "openbsd",
2564 target_os = "netbsd",
2565 target_os = "dragonfly"
2566))]
2567const O_NOFOLLOW_FLAG: i32 = 0x0100;
2568
2569fn open_recovery_file_no_follow(path: &Path) -> Result<fs::File, DurabilityError> {
2570 let mut options = fs::OpenOptions::new();
2571 options.read(true);
2572 #[cfg(any(
2573 target_os = "linux",
2574 target_os = "android",
2575 target_os = "macos",
2576 target_os = "ios",
2577 target_os = "freebsd",
2578 target_os = "openbsd",
2579 target_os = "netbsd",
2580 target_os = "dragonfly"
2581 ))]
2582 options.custom_flags(O_NOFOLLOW_FLAG);
2583 Ok(options.open(path)?)
2584}
2585
2586fn read_bounded_regular_file(
2587 path: &Path,
2588 max_bytes: u64,
2589) -> Result<Option<Vec<u8>>, DurabilityError> {
2590 let metadata = match fs::symlink_metadata(path) {
2591 Ok(metadata) => metadata,
2592 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2593 Err(error) => return Err(error.into()),
2594 };
2595 if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes {
2596 return Err(DurabilityError::SnapshotVerification(
2597 "recovery file is not a bounded regular file".into(),
2598 ));
2599 }
2600 let mut file = open_recovery_file_no_follow(path)?;
2601 let opened = file.metadata()?;
2602 if !opened.is_file() || opened.len() > max_bytes {
2603 return Err(DurabilityError::SnapshotVerification(
2604 "recovery file changed to an invalid form before read".into(),
2605 ));
2606 }
2607 #[cfg(unix)]
2608 if metadata.dev() != opened.dev()
2609 || metadata.ino() != opened.ino()
2610 || metadata.len() != opened.len()
2611 {
2612 return Err(DurabilityError::SnapshotVerification(
2613 "recovery file changed before no-follow open".into(),
2614 ));
2615 }
2616 let mut contents = Vec::with_capacity(usize::try_from(opened.len()).unwrap_or(0));
2617 Read::by_ref(&mut file)
2618 .take(max_bytes + 1)
2619 .read_to_end(&mut contents)?;
2620 if contents.len() as u64 > max_bytes || contents.len() as u64 != opened.len() {
2621 return Err(DurabilityError::SnapshotVerification(
2622 "recovery file changed during bounded read".into(),
2623 ));
2624 }
2625 Ok(Some(contents))
2626}
2627
2628fn read_regular_successor_control_file(path: &Path) -> Result<Option<Vec<u8>>, DurabilityError> {
2629 read_bounded_regular_file(path, 16384)
2630}
2631
2632fn parse_successor_restore_receipt(bytes: &[u8]) -> Option<SuccessorRestoreReceipt> {
2633 let receipt = serde_json::from_slice::<SuccessorRestoreReceipt>(bytes).ok()?;
2634 (!receipt.cluster_id.is_empty()
2635 && !receipt.node_id.is_empty()
2636 && LogHash::from_hex(&receipt.membership_digest).is_some()
2637 && LogHash::from_hex(&receipt.stop_hash).is_some())
2638 .then_some(receipt)
2639}
2640
2641fn successor_receipt_matches_finalized_prestage(
2642 receipt: &SuccessorRestoreReceipt,
2643 identity: &SuccessorPrestageIdentity,
2644) -> bool {
2645 receipt.cluster_id == identity.cluster_id
2646 && receipt.epoch == identity.epoch
2647 && receipt.target_config_id == identity.target_config_id
2648 && receipt.recovery_generation == identity.predecessor_recovery_generation
2649 && receipt.node_id == identity.node_id
2650 && receipt.membership_digest == identity.target_membership_digest
2651 && receipt.predecessor_config_id == identity.predecessor_config_id
2652}
2653
2654fn parse_successor_prestage_identity(bytes: &[u8]) -> Option<SuccessorPrestageIdentity> {
2655 let identity = serde_json::from_slice::<SuccessorPrestageIdentity>(bytes).ok()?;
2656 validate_successor_prestage_identity(&identity)
2657 .is_ok()
2658 .then_some(identity)
2659}
2660
2661fn validate_successor_prestage_identity(
2662 identity: &SuccessorPrestageIdentity,
2663) -> Result<(), DurabilityError> {
2664 let valid = !identity.cluster_id.is_empty()
2665 && !identity.node_id.is_empty()
2666 && snapshot_profile(&identity.cluster_id)? == identity.execution_profile
2667 && identity
2668 .predecessor_config_id
2669 .checked_add(1)
2670 .is_some_and(|next| next == identity.target_config_id)
2671 && LogHash::from_hex(&identity.predecessor_membership_digest).is_some()
2672 && LogHash::from_hex(&identity.target_membership_digest).is_some()
2673 && LogHash::from_hex(&identity.seed_hash).is_some();
2674 if !valid {
2675 return Err(DurabilityError::SnapshotVerification(
2676 "successor prestage identity is invalid".into(),
2677 ));
2678 }
2679 Ok(())
2680}
2681
2682fn prepare_successor_prestage_root(
2683 prestage_dir: &Path,
2684 expected_identity: Option<&SuccessorPrestageIdentity>,
2685 predecessor_configuration: &ConfigurationState,
2686) -> Result<SuccessorPrestage, DurabilityError> {
2687 if expected_identity.is_some() {
2688 fs::create_dir_all(prestage_dir)?;
2689 }
2690 let metadata = fs::symlink_metadata(prestage_dir)
2691 .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2692 if metadata.file_type().is_symlink() || !metadata.is_dir() {
2693 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2694 }
2695 let lock_path = prestage_dir.join(SUCCESSOR_PRESTAGE_LOCK_FILE);
2696 let mut lock_options = fs::OpenOptions::new();
2697 lock_options.read(true).write(true);
2698 if expected_identity.is_some() {
2699 lock_options.create(true).truncate(false);
2700 }
2701 let lock = lock_options
2702 .open(&lock_path)
2703 .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2704 lock.try_lock()
2705 .map_err(|_| DurabilityError::PreconditionFailed)?;
2706
2707 let marker_files = [
2708 (
2709 SUCCESSOR_PRESTAGE_INTENT_FILE,
2710 SuccessorPrestageState::Preparing,
2711 ),
2712 (SUCCESSOR_PRESTAGE_READY_FILE, SuccessorPrestageState::Ready),
2713 (
2714 SUCCESSOR_PRESTAGE_PUBLISHED_FILE,
2715 SuccessorPrestageState::Published,
2716 ),
2717 (
2718 SUCCESSOR_PRESTAGE_FINALIZED_FILE,
2719 SuccessorPrestageState::Finalized,
2720 ),
2721 ];
2722 let mut marker = None;
2723 for (name, state) in marker_files {
2724 let Some(bytes) = read_bounded_regular_file(&prestage_dir.join(name), 16384)
2725 .map_err(|_| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?
2726 else {
2727 continue;
2728 };
2729 if marker.is_some() {
2730 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2731 }
2732 let identity = parse_successor_prestage_identity(&bytes)
2733 .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2734 marker = Some((name, state, identity));
2735 }
2736
2737 let (marker_name, state, identity) = match marker {
2738 Some(marker) => marker,
2739 None => {
2740 let expected = expected_identity
2741 .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2742 for entry in fs::read_dir(prestage_dir)? {
2743 let entry = entry?;
2744 let name = entry.file_name();
2745 let name = name.to_string_lossy();
2746 if name == SUCCESSOR_PRESTAGE_LOCK_FILE {
2747 continue;
2748 }
2749 if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2750 fs::remove_file(entry.path())?;
2751 continue;
2752 }
2753 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2754 }
2755 let bytes = serde_json::to_vec(expected)
2756 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
2757 publish_restore_marker(prestage_dir, SUCCESSOR_PRESTAGE_INTENT_FILE, &bytes)?;
2758 (
2759 SUCCESSOR_PRESTAGE_INTENT_FILE,
2760 SuccessorPrestageState::Preparing,
2761 expected.clone(),
2762 )
2763 }
2764 };
2765 if expected_identity.is_some_and(|expected| expected != &identity) {
2766 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2767 }
2768 if !predecessor_configuration.is_active()
2769 || predecessor_configuration.config_id() != identity.predecessor_config_id()
2770 || predecessor_configuration.digest() != identity.predecessor_membership_digest()
2771 {
2772 return Err(DurabilityError::SnapshotVerification(
2773 "successor prestage predecessor configuration does not match its identity".into(),
2774 ));
2775 }
2776
2777 let recovery_identity = RecoveryArtifactIdentity::Prestage(identity.clone());
2778 for entry in fs::read_dir(prestage_dir)? {
2779 let entry = entry?;
2780 let name = entry.file_name();
2781 let name = name.to_string_lossy();
2782 if name == SUCCESSOR_PRESTAGE_LOCK_FILE || name == marker_name {
2783 continue;
2784 }
2785 if state == SuccessorPrestageState::Finalized && name == SUCCESSOR_RESTORE_INTENT_FILE {
2786 let bytes = read_regular_successor_control_file(&entry.path())?
2787 .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2788 let receipt = parse_successor_restore_receipt(&bytes)
2789 .ok_or_else(|| DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()))?;
2790 if successor_receipt_matches_finalized_prestage(&receipt, &identity) {
2791 continue;
2792 }
2793 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2794 }
2795 if state == SuccessorPrestageState::Finalized
2796 && name == SUCCESSOR_RESTORE_LOCK_FILE
2797 && fs::symlink_metadata(entry.path())
2798 .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
2799 {
2800 continue;
2801 }
2802 if is_safe_restore_marker_tmp(&entry.path(), &name)? {
2803 if state == SuccessorPrestageState::Preparing {
2804 fs::remove_file(entry.path())?;
2805 continue;
2806 }
2807 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2808 }
2809 if ["sqlite", "ladybug", "kv", "consensus"].contains(&name.as_ref()) {
2810 let metadata = fs::symlink_metadata(entry.path())?;
2811 if metadata.file_type().is_symlink() || !metadata.is_dir() {
2812 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2813 }
2814 continue;
2815 }
2816 if name.starts_with(RESTORE_STAGING_PREFIX)
2817 && state == SuccessorPrestageState::Preparing
2818 && is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2819 && is_owned_recovery_directory(
2820 &entry.path(),
2821 &["sqlite", "ladybug", "kv", "consensus"],
2822 RepairArtifactRole::Staging,
2823 &recovery_identity,
2824 )?
2825 {
2826 continue;
2827 }
2828 return Err(DurabilityError::DataDirNotFresh(prestage_dir.to_path_buf()));
2829 }
2830 if !matches!(
2831 state,
2832 SuccessorPrestageState::Preparing | SuccessorPrestageState::Finalized
2833 ) {
2834 validate_local_qlog_with_configuration(
2835 prestage_dir,
2836 &identity.checkpoint_identity(),
2837 identity.seed_anchor(),
2838 predecessor_configuration.clone(),
2839 )?;
2840 }
2841 Ok(SuccessorPrestage {
2842 path: prestage_dir.to_path_buf(),
2843 identity,
2844 state,
2845 _lock: lock,
2846 })
2847}
2848
2849fn cleanup_preparing_successor_prestage(
2850 prestage_dir: &Path,
2851 identity: &SuccessorPrestageIdentity,
2852) -> Result<(), DurabilityError> {
2853 let recovery_identity = RecoveryArtifactIdentity::Prestage(identity.clone());
2854 for entry in fs::read_dir(prestage_dir)? {
2855 let entry = entry?;
2856 let name = entry.file_name();
2857 let name = name.to_string_lossy();
2858 let owned_component = ["sqlite", "ladybug", "kv", "consensus"].contains(&name.as_ref());
2859 let owned_staging = name.starts_with(RESTORE_STAGING_PREFIX)
2860 && is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2861 && is_owned_recovery_directory(
2862 &entry.path(),
2863 &["sqlite", "ladybug", "kv", "consensus"],
2864 RepairArtifactRole::Staging,
2865 &recovery_identity,
2866 )?;
2867 if owned_component || owned_staging {
2868 fs::remove_dir_all(entry.path())?;
2869 }
2870 }
2871 sync_directory(prestage_dir)
2872}
2873
2874fn sync_directory(path: &Path) -> Result<(), DurabilityError> {
2875 fs::File::open(path)?.sync_all()?;
2876 Ok(())
2877}
2878
2879fn validate_local_batch(
2880 entries: &[rhiza_core::LogEntry],
2881 start: LogIndex,
2882 end: LogIndex,
2883 durable_tip: CheckpointTip,
2884) -> Result<(), DurabilityError> {
2885 let expected_len =
2886 usize::try_from(end - start + 1).map_err(|_| DurabilityError::LocalLogGap {
2887 expected: start,
2888 actual: entries.first().map(|entry| entry.index),
2889 })?;
2890 if entries.len() != expected_len {
2891 let actual = entries
2892 .iter()
2893 .zip(start..=end)
2894 .find_map(|(entry, expected)| (entry.index != expected).then_some(entry.index));
2895 return Err(DurabilityError::LocalLogGap {
2896 expected: start + entries.len() as u64,
2897 actual,
2898 });
2899 }
2900
2901 let mut expected_hash = durable_tip.hash();
2902 for (expected_index, entry) in (start..).zip(entries) {
2903 if entry.index != expected_index {
2904 return Err(DurabilityError::LocalLogGap {
2905 expected: expected_index,
2906 actual: Some(entry.index),
2907 });
2908 }
2909 if entry.prev_hash != expected_hash || entry.recompute_hash() != entry.hash {
2910 return Err(DurabilityError::LocalLogConflict { index: entry.index });
2911 }
2912 expected_hash = entry.hash;
2913 }
2914 Ok(())
2915}
2916
2917fn prepare_fresh_restore_data_dir(
2918 data_dir: &Path,
2919 completion_marker_name: Option<&str>,
2920 expected_intent: &[u8],
2921) -> Result<(), DurabilityError> {
2922 if !path_has_state(data_dir)? {
2923 return Ok(());
2924 }
2925
2926 let intent = data_dir.join(RESTORE_INTENT_FILE);
2927 let intent_metadata = match fs::symlink_metadata(&intent) {
2928 Ok(metadata) => Some(metadata),
2929 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2930 Err(error) => return Err(error.into()),
2931 };
2932 let (active_intent, recovery_identity) = if let Some(metadata) = intent_metadata {
2933 if metadata.file_type().is_symlink()
2934 || !metadata.is_file()
2935 || read_bounded_regular_file(&intent, 4096)?.as_deref() != Some(expected_intent)
2936 {
2937 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2938 }
2939 (
2940 &intent,
2941 Some(RecoveryArtifactIdentity::Restore(
2942 parse_restore_intent_identity(expected_intent)
2943 .ok_or_else(|| DurabilityError::DataDirNotFresh(data_dir.to_path_buf()))?,
2944 )),
2945 )
2946 } else {
2947 let entries = fs::read_dir(data_dir)?.collect::<Result<Vec<_>, _>>()?;
2948 if entries.iter().all(|entry| {
2949 let name = entry.file_name();
2950 let name = name.to_string_lossy();
2951 is_safe_restore_marker_tmp(&entry.path(), &name).unwrap_or(false)
2952 }) {
2953 for entry in entries {
2954 fs::remove_file(entry.path())?;
2955 }
2956 sync_directory(data_dir)?;
2957 return Ok(());
2958 }
2959 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2960 };
2961
2962 for entry in fs::read_dir(data_dir)? {
2963 let entry = entry?;
2964 let name = entry.file_name();
2965 let name = name.to_string_lossy();
2966 let marker_tmp = is_safe_restore_marker_tmp(&entry.path(), &name)?;
2967 if name.starts_with(RESTORE_MARKER_TMP_PREFIX) && !marker_tmp {
2968 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2969 }
2970 let is_staging = name.starts_with(RESTORE_STAGING_PREFIX);
2971 if is_staging
2972 && (!is_owned_generated_recovery_name(&name, RESTORE_STAGING_PREFIX)
2973 || !recovery_identity.as_ref().is_some_and(|identity| {
2974 is_owned_recovery_directory(
2975 &entry.path(),
2976 &["sqlite", "ladybug", "kv", "consensus"],
2977 RepairArtifactRole::Staging,
2978 identity,
2979 )
2980 .unwrap_or(false)
2981 }))
2982 {
2983 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2984 }
2985 let owned = entry.path() == active_intent.as_path()
2986 || completion_marker_name.is_some_and(|marker| name == marker)
2987 || name == "sqlite"
2988 || name == "ladybug"
2989 || name == "kv"
2990 || name == "consensus"
2991 || marker_tmp
2992 || is_staging;
2993 if !owned {
2994 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
2995 }
2996 }
2997
2998 for name in ["sqlite", "ladybug", "kv", "consensus"] {
2999 let path = data_dir.join(name);
3000 if path.exists() {
3001 fs::remove_dir_all(path)?;
3002 }
3003 }
3004 for entry in fs::read_dir(data_dir)? {
3005 let entry = entry?;
3006 let name = entry.file_name();
3007 let name = name.to_string_lossy();
3008 if name.starts_with(RESTORE_STAGING_PREFIX) {
3009 fs::remove_dir_all(entry.path())?;
3010 } else if is_safe_restore_marker_tmp(&entry.path(), &name)? {
3011 fs::remove_file(entry.path())?;
3012 }
3013 }
3014 fs::remove_file(active_intent)?;
3015 sync_directory(data_dir)?;
3016 Ok(())
3017}
3018
3019fn path_has_state(path: &Path) -> Result<bool, std::io::Error> {
3020 let metadata = match fs::symlink_metadata(path) {
3021 Ok(metadata) => metadata,
3022 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3023 Err(error) => return Err(error),
3024 };
3025 if !metadata.is_dir() {
3026 return Ok(true);
3027 }
3028 fs::read_dir(path)?
3029 .next()
3030 .transpose()
3031 .map(|entry| entry.is_some())
3032}
3033
3034#[cfg(test)]
3035#[path = "durability/prestage_tests.rs"]
3036mod prestage_tests;
3037
3038#[cfg(test)]
3039mod tests {
3040 use super::{
3041 mark_durable_state, observe_durable_tip, snapshot_profile, validate_local_qlog,
3042 validate_restored_suffix, write_repair_artifact_ownership, CheckpointTip, CoordinatorState,
3043 DurabilityError, DurabilityHealth, ExecutionProfile, LogAnchor, LogHash, PendingLag,
3044 RecoveryArtifactIdentity, RepairArtifactRole, SuccessorRestorePreparation,
3045 RESTORE_INTENT_FILE, SUCCESSOR_RESTORE_COMPLETE_FILE, SUCCESSOR_RESTORE_INTENT_FILE,
3046 SUCCESSOR_RESTORE_LOCK_FILE,
3047 };
3048 #[cfg(feature = "kv")]
3049 use crate::{KvCommandV1, NodeConfig, NodeRuntime};
3050 use rhiza_archive::CheckpointIdentity;
3051 #[cfg(feature = "kv")]
3052 use rhiza_archive::ObjectArchiveStore;
3053 use rhiza_core::{EntryType, LogEntry};
3054 use rhiza_log::{FileLogStore, LogStore};
3055 #[cfg(feature = "kv")]
3056 use rhiza_obj_store::{ObjStore, ObjStoreConfig};
3057 #[cfg(feature = "kv")]
3058 use rhiza_quepaxa::ThreeNodeConsensus;
3059 use std::sync::{Arc, Barrier, Mutex};
3060
3061 #[test]
3062 fn snapshot_profile_requires_a_canonical_effective_cluster_identity() {
3063 assert_eq!(
3064 snapshot_profile("rhiza:sql:cluster-a").unwrap(),
3065 ExecutionProfile::Sqlite
3066 );
3067 assert_eq!(
3068 snapshot_profile("rhiza:graph:cluster-a").unwrap(),
3069 ExecutionProfile::Graph
3070 );
3071 assert_eq!(
3072 snapshot_profile("rhiza:kv:cluster-a").unwrap(),
3073 ExecutionProfile::Kv
3074 );
3075 assert!(matches!(
3076 snapshot_profile("cluster-a"),
3077 Err(DurabilityError::SnapshotVerification(_))
3078 ));
3079 assert!(snapshot_profile("rhiza:graph:").is_err());
3080 }
3081
3082 #[test]
3083 fn generic_restore_prepare_keeps_prefix_spoofed_staging_and_fails_closed() {
3084 let root = tempfile::tempdir().unwrap();
3085 let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3086 let intent = super::encode_restore_intent(
3087 &identity,
3088 "node-1",
3089 ExecutionProfile::Sqlite,
3090 LogAnchor::new(0, LogHash::ZERO),
3091 )
3092 .unwrap();
3093 std::fs::write(root.path().join(RESTORE_INTENT_FILE), &intent).unwrap();
3094 let staging = root.path().join(".restore-stage-4242-1");
3095 std::fs::create_dir_all(staging.join("sqlite")).unwrap();
3096
3097 assert!(matches!(
3098 super::prepare_fresh_restore_data_dir(root.path(), None, &intent),
3099 Err(DurabilityError::DataDirNotFresh(_))
3100 ));
3101 assert!(staging.join("sqlite").is_dir());
3102 }
3103
3104 #[test]
3105 fn bounded_regular_reader_rejects_file_larger_than_its_limit() {
3106 let root = tempfile::tempdir().unwrap();
3107 let path = root.path().join("oversized");
3108 std::fs::write(&path, b"12345").unwrap();
3109
3110 assert!(super::read_bounded_regular_file(&path, 4).is_err());
3111 assert_eq!(std::fs::read(&path).unwrap(), b"12345");
3112 }
3113
3114 #[test]
3115 fn rejoin_artifact_cleanup_removes_only_owned_stale_stage_and_quarantine() {
3116 let root = tempfile::tempdir().unwrap();
3117 let checkpoint = LogAnchor::new(0, LogHash::ZERO);
3118 let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
3119 &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3120 "node-1",
3121 ExecutionProfile::Sqlite,
3122 checkpoint,
3123 ));
3124 let stage = root.path().join(".restore-stage-4242-1");
3125 std::fs::create_dir_all(stage.join("sqlite")).unwrap();
3126 write_repair_artifact_ownership(&stage, RepairArtifactRole::Staging, &identity).unwrap();
3127 let quarantine = root.path().join(".rebuildable-quarantine-4242-2");
3128 std::fs::create_dir_all(quarantine.join("sqlite")).unwrap();
3129 write_repair_artifact_ownership(&quarantine, RepairArtifactRole::Quarantine, &identity)
3130 .unwrap();
3131
3132 super::cleanup_owned_recovery_artifacts(root.path(), &identity).unwrap();
3133 assert!(!stage.exists());
3134 assert!(!quarantine.exists());
3135 }
3136
3137 #[test]
3138 fn rejoin_artifact_cleanup_keeps_foreign_prefix_artifact_without_mutation() {
3139 let root = tempfile::tempdir().unwrap();
3140 let spoof = root.path().join(".restore-stage-foreign");
3141 std::fs::create_dir(&spoof).unwrap();
3142 let identity = RecoveryArtifactIdentity::Restore(super::restore_intent_identity(
3143 &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3144 "node-1",
3145 ExecutionProfile::Sqlite,
3146 LogAnchor::new(0, LogHash::ZERO),
3147 ));
3148
3149 assert!(matches!(
3150 super::cleanup_owned_recovery_artifacts(root.path(), &identity),
3151 Err(DurabilityError::DataDirNotFresh(_))
3152 ));
3153 assert!(spoof.is_dir());
3154 }
3155
3156 #[cfg(unix)]
3157 #[test]
3158 fn successor_completion_keeps_existing_complete_symlink_and_intent() {
3159 use std::os::unix::fs::symlink;
3160
3161 let root = tempfile::tempdir().unwrap();
3162 let receipt = br#"{"cluster_id":"rhiza:sql:cluster-a","epoch":1,"target_config_id":2,"recovery_generation":1,"node_id":"node-1","membership_digest":"0000000000000000000000000000000000000000000000000000000000000000","predecessor_config_id":1,"stop_index":0,"stop_hash":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
3163 let intent = root.path().join(SUCCESSOR_RESTORE_INTENT_FILE);
3164 std::fs::write(&intent, receipt).unwrap();
3165 let target = root.path().join("target");
3166 std::fs::write(&target, b"do not replace").unwrap();
3167 let complete = root.path().join(SUCCESSOR_RESTORE_COMPLETE_FILE);
3168 symlink(&target, &complete).unwrap();
3169 let lock = std::fs::OpenOptions::new()
3170 .read(true)
3171 .write(true)
3172 .create(true)
3173 .truncate(false)
3174 .open(root.path().join(SUCCESSOR_RESTORE_LOCK_FILE))
3175 .unwrap();
3176 let preparation = SuccessorRestorePreparation {
3177 tip: CheckpointTip::new(0, LogHash::ZERO),
3178 data_dir: root.path().to_path_buf(),
3179 identity: receipt.to_vec(),
3180 requires_recorder_install: true,
3181 _lock: lock,
3182 };
3183
3184 assert!(preparation.complete().is_err());
3185 assert_eq!(std::fs::read(&target).unwrap(), b"do not replace");
3186 assert_eq!(std::fs::read(&intent).unwrap(), receipt);
3187 assert!(std::fs::symlink_metadata(&complete)
3188 .unwrap()
3189 .file_type()
3190 .is_symlink());
3191 }
3192
3193 #[test]
3194 fn sqlite_restore_suffix_rejects_noncanonical_commands_during_preflight() {
3195 let payload = b"put\tnoncanonical\tkey\tvalue".to_vec();
3196 let entry = LogEntry {
3197 cluster_id: "rhiza:sql:cluster-a".into(),
3198 epoch: 1,
3199 config_id: 1,
3200 index: 1,
3201 entry_type: EntryType::Command,
3202 prev_hash: LogHash::ZERO,
3203 hash: LogEntry::calculate_hash(
3204 "rhiza:sql:cluster-a",
3205 1,
3206 1,
3207 1,
3208 EntryType::Command,
3209 LogHash::ZERO,
3210 &payload,
3211 ),
3212 payload,
3213 };
3214
3215 assert!(matches!(
3216 validate_restored_suffix(ExecutionProfile::Sqlite, &[entry]),
3217 Err(DurabilityError::SnapshotVerification(message)) if message.contains("QWAL")
3218 ));
3219 }
3220
3221 #[test]
3222 fn local_qlog_accepts_ahead_tip_when_checkpoint_entry_is_retained() {
3223 let root = tempfile::tempdir().unwrap();
3224 let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3225 let log = FileLogStore::open(
3226 root.path().join("consensus/log"),
3227 identity.cluster_id(),
3228 1,
3229 1,
3230 )
3231 .unwrap();
3232 let entry = |index, previous| {
3233 let hash = LogEntry::calculate_hash(
3234 identity.cluster_id(),
3235 index,
3236 1,
3237 1,
3238 EntryType::Noop,
3239 previous,
3240 &[],
3241 );
3242 LogEntry {
3243 cluster_id: identity.cluster_id().into(),
3244 epoch: 1,
3245 config_id: 1,
3246 index,
3247 entry_type: EntryType::Noop,
3248 payload: Vec::new(),
3249 prev_hash: previous,
3250 hash,
3251 }
3252 };
3253 let first = entry(1, LogHash::ZERO);
3254 let second = entry(2, first.hash);
3255 log.append_batch(&[first.clone(), second.clone()]).unwrap();
3256
3257 assert_eq!(
3258 validate_local_qlog(
3259 root.path(),
3260 &identity,
3261 rhiza_core::LogAnchor::new(first.index, first.hash),
3262 )
3263 .unwrap(),
3264 rhiza_core::LogAnchor::new(2, second.hash)
3265 );
3266 assert!(validate_local_qlog(
3267 root.path(),
3268 &identity,
3269 rhiza_core::LogAnchor::new(2, LogHash::digest(&[b"conflicting"])),
3270 )
3271 .is_err());
3272 assert!(validate_local_qlog(
3273 root.path(),
3274 &identity,
3275 rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"ahead checkpoint"])),
3276 )
3277 .is_err());
3278 }
3279
3280 #[test]
3281 fn local_qlog_treats_absent_log_as_genesis_only() {
3282 let root = tempfile::tempdir().unwrap();
3283 let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
3284 let genesis = rhiza_core::LogAnchor::new(0, LogHash::ZERO);
3285
3286 assert_eq!(
3287 validate_local_qlog(root.path(), &identity, genesis).unwrap(),
3288 genesis
3289 );
3290 assert!(validate_local_qlog(
3291 root.path(),
3292 &identity,
3293 rhiza_core::LogAnchor::new(1, LogHash::digest(&[b"checkpoint"])),
3294 )
3295 .is_err());
3296 }
3297
3298 #[test]
3299 fn restore_intent_remains_until_completion_marker_is_durable_and_retryable() {
3300 let root = tempfile::tempdir().unwrap();
3301 let data_dir = root.path().join("data");
3302 std::fs::create_dir_all(&data_dir).unwrap();
3303 let intent = super::encode_restore_intent(
3304 &CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1),
3305 "node-1",
3306 ExecutionProfile::Sqlite,
3307 rhiza_core::LogAnchor::new(0, LogHash::ZERO),
3308 )
3309 .unwrap();
3310 std::fs::write(data_dir.join(RESTORE_INTENT_FILE), &intent).unwrap();
3311 std::fs::create_dir(data_dir.join("identity.json")).unwrap();
3312 let staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
3313
3314 assert!(super::publish_restore_staging(
3315 &staging,
3316 &data_dir,
3317 true,
3318 Some(("identity.json", b"identity-fixture")),
3319 )
3320 .is_err());
3321 assert_eq!(
3322 std::fs::read(data_dir.join(RESTORE_INTENT_FILE)).unwrap(),
3323 intent
3324 );
3325
3326 std::fs::remove_dir(data_dir.join("identity.json")).unwrap();
3327 let retry_staging = super::create_restore_staging_dir(&data_dir, None).unwrap();
3328 super::publish_restore_staging(
3329 &retry_staging,
3330 &data_dir,
3331 true,
3332 Some(("identity.json", b"identity-fixture")),
3333 )
3334 .unwrap();
3335 assert_eq!(
3336 std::fs::read(data_dir.join("identity.json")).unwrap(),
3337 b"identity-fixture"
3338 );
3339 assert!(!data_dir.join(RESTORE_INTENT_FILE).exists());
3340 }
3341
3342 #[cfg(feature = "kv")]
3343 #[tokio::test]
3344 async fn kv_compacted_rejoin_restores_missing_or_corrupt_views_without_touching_recorder() {
3345 let root = tempfile::tempdir().unwrap();
3346 let identity = CheckpointIdentity::new("rhiza:kv:cluster-a", 1, 1, 1);
3347 let archive = ObjectArchiveStore::new_checkpoint_for_single_process(
3348 ObjStore::new(ObjStoreConfig::Local {
3349 root: root.path().join("archive"),
3350 })
3351 .unwrap(),
3352 identity.clone(),
3353 );
3354 archive.initialize_checkpoint().await.unwrap();
3355 let source_dir = root.path().join("source");
3356 let config = NodeConfig::new_embedded(
3357 "cluster-a",
3358 "node-1",
3359 source_dir,
3360 1,
3361 1,
3362 ["node-1", "node-2", "node-3"],
3363 )
3364 .unwrap()
3365 .with_execution_profile(ExecutionProfile::Kv)
3366 .unwrap()
3367 .with_recovery_generation(1)
3368 .unwrap();
3369 let consensus = Arc::new(
3370 ThreeNodeConsensus::from_recovered_tip(
3371 "rhiza:kv:cluster-a",
3372 "node-1",
3373 1,
3374 1,
3375 [
3376 root.path().join("recorders/node-1"),
3377 root.path().join("recorders/node-2"),
3378 root.path().join("recorders/node-3"),
3379 ],
3380 1,
3381 LogHash::ZERO,
3382 )
3383 .unwrap(),
3384 );
3385 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
3386 let coordinator = CheckpointCoordinator::open(archive.clone(), DurabilityMode::Sync)
3387 .await
3388 .unwrap();
3389 let committed = runtime
3390 .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"value".to_vec()).unwrap())
3391 .unwrap();
3392 coordinator.note_committed(committed.applied_index());
3393 coordinator
3394 .flush_runtime(&runtime, committed.applied_index())
3395 .await
3396 .unwrap();
3397 let checkpoint_root = runtime.checkpoint_compact(&coordinator).await.unwrap();
3398
3399 let target = root.path().join("target");
3400 restore_checkpoint_to_fresh_data_dir_for_node(archive.clone(), &target, "node-1")
3401 .await
3402 .unwrap();
3403 validate_local_recovery_view(
3404 &target,
3405 &identity,
3406 "node-1",
3407 ExecutionProfile::Kv,
3408 *checkpoint_root.compacted(),
3409 )
3410 .unwrap();
3411 std::fs::create_dir_all(target.join("recorder")).unwrap();
3412 std::fs::write(target.join("recorder/sentinel"), b"keep-me").unwrap();
3413
3414 std::fs::remove_dir_all(target.join("consensus")).unwrap();
3415 assert!(validate_local_recovery_view(
3416 &target,
3417 &identity,
3418 "node-1",
3419 ExecutionProfile::Kv,
3420 *checkpoint_root.compacted(),
3421 )
3422 .is_err());
3423 restore_checkpoint_for_rejoin_preserving_recorder(
3424 archive.clone(),
3425 &target,
3426 "node-1",
3427 ExecutionProfile::Kv,
3428 "identity.json",
3429 b"identity-fixture",
3430 )
3431 .await
3432 .unwrap();
3433
3434 std::fs::write(target.join("kv/data.redb"), b"corrupt").unwrap();
3435 assert!(validate_local_recovery_view(
3436 &target,
3437 &identity,
3438 "node-1",
3439 ExecutionProfile::Kv,
3440 *checkpoint_root.compacted(),
3441 )
3442 .is_err());
3443 restore_checkpoint_for_rejoin_preserving_recorder(
3444 archive,
3445 &target,
3446 "node-1",
3447 ExecutionProfile::Kv,
3448 "identity.json",
3449 b"identity-fixture",
3450 )
3451 .await
3452 .unwrap();
3453 assert_eq!(
3454 std::fs::read(target.join("recorder/sentinel")).unwrap(),
3455 b"keep-me"
3456 );
3457 }
3458
3459 #[test]
3460 fn concurrent_flush_completion_cannot_regress_the_durable_tip() {
3461 let newer = CheckpointTip::new(8, LogHash::digest(&[b"newer"]));
3462 let older = CheckpointTip::new(4, LogHash::digest(&[b"older"]));
3463 let mut state = CoordinatorState {
3464 durable_tip: newer,
3465 committed_index: 8,
3466 pending_lag: None,
3467 health: DurabilityHealth::Available,
3468 };
3469
3470 mark_durable_state(&mut state, older);
3471
3472 assert_eq!(state.durable_tip, newer);
3473 }
3474
3475 #[test]
3476 fn checkpoint_observation_rejects_same_index_hash_conflict_without_mutation() {
3477 let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3478 let conflicting = CheckpointTip::new(8, LogHash::digest(&[b"conflicting"]));
3479 let state = Mutex::new(CoordinatorState {
3480 durable_tip: current,
3481 committed_index: 8,
3482 pending_lag: None,
3483 health: DurabilityHealth::Available,
3484 });
3485
3486 assert!(matches!(
3487 observe_durable_tip(&state, conflicting),
3488 Err(DurabilityError::SnapshotVerification(_))
3489 ));
3490 assert_eq!(state.lock().unwrap().durable_tip, current);
3491 }
3492
3493 #[test]
3494 fn checkpoint_observation_rejects_remote_rollback_without_mutation() {
3495 let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
3496 let older = CheckpointTip::new(7, LogHash::digest(&[b"older"]));
3497 let state = Mutex::new(CoordinatorState {
3498 durable_tip: current,
3499 committed_index: 8,
3500 pending_lag: None,
3501 health: DurabilityHealth::Available,
3502 });
3503
3504 assert!(matches!(
3505 observe_durable_tip(&state, older),
3506 Err(DurabilityError::SnapshotVerification(_))
3507 ));
3508 assert_eq!(state.lock().unwrap().durable_tip, current);
3509 }
3510
3511 #[test]
3512 fn concurrent_conflicting_checkpoint_observations_accept_exactly_one_tip() {
3513 let state = Arc::new(Mutex::new(CoordinatorState {
3514 durable_tip: CheckpointTip::new(0, LogHash::ZERO),
3515 committed_index: 0,
3516 pending_lag: None,
3517 health: DurabilityHealth::Available,
3518 }));
3519 let start = Arc::new(Barrier::new(3));
3520 let results = [b"first".as_slice(), b"second".as_slice()]
3521 .into_iter()
3522 .map(|label| {
3523 let state = Arc::clone(&state);
3524 let start = Arc::clone(&start);
3525 std::thread::spawn(move || {
3526 let tip = CheckpointTip::new(1, LogHash::digest(&[label]));
3527 start.wait();
3528 (tip, observe_durable_tip(&state, tip))
3529 })
3530 })
3531 .collect::<Vec<_>>();
3532 start.wait();
3533 let results = results
3534 .into_iter()
3535 .map(|thread| thread.join().unwrap())
3536 .collect::<Vec<_>>();
3537
3538 assert_eq!(
3539 results.iter().filter(|(_, result)| result.is_ok()).count(),
3540 1
3541 );
3542 assert_eq!(
3543 results.iter().filter(|(_, result)| result.is_err()).count(),
3544 1
3545 );
3546 let accepted = results
3547 .iter()
3548 .find_map(|(tip, result)| result.is_ok().then_some(*tip))
3549 .unwrap();
3550 assert_eq!(state.lock().unwrap().durable_tip, accepted);
3551 }
3552
3553 #[test]
3554 fn durable_progress_clears_lag_only_after_reaching_the_committed_index() {
3555 let mut state = CoordinatorState {
3556 durable_tip: CheckpointTip::new(2, LogHash::digest(&[b"two"])),
3557 committed_index: 4,
3558 pending_lag: Some(PendingLag::Recovered),
3559 health: DurabilityHealth::Unavailable,
3560 };
3561 let partial = CheckpointTip::new(3, LogHash::digest(&[b"three"]));
3562 mark_durable_state(&mut state, partial);
3563 assert!(state.pending_lag.is_some());
3564 assert_eq!(state.health, DurabilityHealth::Unavailable);
3565
3566 let complete = CheckpointTip::new(4, LogHash::digest(&[b"four"]));
3567 mark_durable_state(&mut state, complete);
3568
3569 assert_eq!(state.durable_tip, complete);
3570 assert!(state.pending_lag.is_none());
3571 assert_eq!(state.health, DurabilityHealth::Available);
3572 }
3573}