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