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