1use std::{
2 error::Error,
3 fmt, fs,
4 future::Future,
5 io::Write,
6 path::{Path, PathBuf},
7 process,
8 sync::{
9 atomic::{AtomicU64, Ordering},
10 Arc, Mutex, MutexGuard,
11 },
12 time::{Duration, Instant},
13};
14
15use rhiza_archive::{
16 CheckpointIdentity, CheckpointPublisher, CheckpointPublisherOptions, CheckpointTip,
17 ObjectArchiveStore, RestoredCheckpoint,
18};
19#[cfg(any(feature = "graph", feature = "kv"))]
20use rhiza_core::SnapshotIdentity;
21use rhiza_core::{
22 ConfigurationState, ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
23};
24#[cfg(feature = "graph")]
25use rhiza_graph::{
26 decode_snapshot as decode_graph_snapshot, encode_snapshot as encode_graph_snapshot,
27 restore_snapshot_file as restore_graph_snapshot_file,
28};
29#[cfg(feature = "kv")]
30use rhiza_kv::{
31 decode_snapshot as decode_kv_snapshot, encode_snapshot as encode_kv_snapshot,
32 restore_snapshot_file as restore_kv_snapshot_file, RedbStateMachine,
33};
34use rhiza_log::{FileLogStore, IndexRange, LogStore};
35#[cfg(feature = "sql")]
36use rhiza_sql::{restore_recovery_snapshot_file, sql_executor_fingerprint};
37use serde::Serialize;
38
39use crate::{Materializer, NodeConfig, NodeRuntime};
40
41const FLUSH_BATCH_ENTRIES: LogIndex = 32;
42const RESTORE_INTENT_FILE: &str = ".rhiza-restore-v1";
43const RESTORE_INTENT_CONTENTS: &[u8] = b"rhiza restore in progress\n";
44const RESTORE_STAGING_PREFIX: &str = ".restore-stage-";
45const RESTORE_MARKER_TMP_PREFIX: &str = ".restore-marker-tmp-";
46const SUCCESSOR_RESTORE_LOCK_FILE: &str = ".successor-restore.lock";
47const SUCCESSOR_RESTORE_INTENT_FILE: &str = ".successor-restore.intent";
48const SUCCESSOR_RESTORE_COMPLETE_FILE: &str = ".successor-restore.complete";
49static RESTORE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
50
51#[derive(Serialize)]
52struct SuccessorRestoreIdentity<'a> {
53 version: u32,
54 cluster_id: &'a str,
55 epoch: u64,
56 target_config_id: u64,
57 recovery_generation: u64,
58 node_id: &'a str,
59 membership_digest: String,
60 predecessor_config_id: u64,
61 stop_index: LogIndex,
62 stop_hash: String,
63 checkpoint_index: LogIndex,
64 checkpoint_hash: String,
65}
66
67pub struct SuccessorRestorePreparation {
68 tip: CheckpointTip,
69 data_dir: PathBuf,
70 identity: Vec<u8>,
71 requires_recorder_install: bool,
72 _lock: fs::File,
73}
74
75impl SuccessorRestorePreparation {
76 pub const fn tip(&self) -> CheckpointTip {
77 self.tip
78 }
79
80 pub const fn requires_recorder_install(&self) -> bool {
81 self.requires_recorder_install
82 }
83
84 pub fn complete(mut self) -> Result<CheckpointTip, DurabilityError> {
85 if !self.requires_recorder_install {
86 return Ok(self.tip);
87 }
88 let intent = self.data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
89 if fs::read(&intent)? != self.identity {
90 return Err(DurabilityError::SnapshotVerification(
91 "successor restore intent changed before completion".into(),
92 ));
93 }
94 fs::rename(intent, self.data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE))?;
95 sync_directory(&self.data_dir)?;
96 self.requires_recorder_install = false;
97 Ok(self.tip)
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum DurabilityMode {
103 Sync,
104 Bounded { max_lag: Duration },
105 Periodic { interval: Duration },
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum DurabilityHealth {
110 Available,
111 Unavailable,
112}
113
114impl DurabilityMode {
115 pub fn validate(&self) -> Result<(), DurabilityError> {
116 match self {
117 Self::Sync => Ok(()),
118 Self::Bounded { max_lag } if max_lag.is_zero() => {
119 Err(DurabilityError::InvalidDuration { mode: "bounded" })
120 }
121 Self::Periodic { interval } if interval.is_zero() => {
122 Err(DurabilityError::InvalidDuration { mode: "periodic" })
123 }
124 Self::Bounded { .. } | Self::Periodic { .. } => Ok(()),
125 }
126 }
127}
128
129#[derive(Debug)]
130pub enum DurabilityError {
131 InvalidDuration {
132 mode: &'static str,
133 },
134 MissingCheckpoint,
135 Unavailable,
136 LagExceeded {
137 committed_index: LogIndex,
138 durable_index: LogIndex,
139 max_lag: Duration,
140 },
141 ArchiveAheadOfLocal {
142 durable_index: LogIndex,
143 local_index: LogIndex,
144 },
145 SnapshotRequired {
146 anchor: Box<RecoveryAnchor>,
147 },
148 LocalLogGap {
149 expected: LogIndex,
150 actual: Option<LogIndex>,
151 },
152 LocalLogConflict {
153 index: LogIndex,
154 },
155 SnapshotVerification(String),
156 PreconditionFailed,
157 DataDirNotFresh(PathBuf),
158 Archive(rhiza_archive::Error),
159 Log(rhiza_log::Error),
160 Io(std::io::Error),
161}
162
163impl fmt::Display for DurabilityError {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 match self {
166 Self::InvalidDuration { mode } => {
167 write!(f, "{mode} durability duration must be non-zero")
168 }
169 Self::MissingCheckpoint => write!(f, "checkpoint manifest is missing"),
170 Self::Unavailable => write!(f, "sync durability is unavailable"),
171 Self::LagExceeded {
172 committed_index,
173 durable_index,
174 max_lag,
175 } => write!(
176 f,
177 "checkpoint lag exceeded {max_lag:?}: committed index {committed_index}, durable index {durable_index}"
178 ),
179 Self::ArchiveAheadOfLocal {
180 durable_index,
181 local_index,
182 } => write!(
183 f,
184 "checkpoint tip {durable_index} is ahead of local qlog tip {local_index}"
185 ),
186 Self::SnapshotRequired { anchor } => write!(
187 f,
188 "snapshot restore required at qlog anchor {} before checkpoint flush",
189 anchor.compacted().index()
190 ),
191 Self::LocalLogGap { expected, actual } => {
192 write!(f, "local qlog gap: expected index {expected}, got {actual:?}")
193 }
194 Self::LocalLogConflict { index } => {
195 write!(f, "local qlog hash chain conflicts at index {index}")
196 }
197 Self::SnapshotVerification(message) => {
198 write!(f, "checkpoint snapshot verification failed: {message}")
199 }
200 Self::PreconditionFailed => write!(f, "checkpoint precondition failed"),
201 Self::DataDirNotFresh(path) => write!(
202 f,
203 "restore data directory contains existing state: {}",
204 path.display()
205 ),
206 Self::Archive(error) => error.fmt(f),
207 Self::Log(error) => error.fmt(f),
208 Self::Io(error) => error.fmt(f),
209 }
210 }
211}
212
213impl Error for DurabilityError {
214 fn source(&self) -> Option<&(dyn Error + 'static)> {
215 match self {
216 Self::Archive(error) => Some(error),
217 Self::Log(error) => Some(error),
218 Self::Io(error) => Some(error),
219 _ => None,
220 }
221 }
222}
223
224impl From<rhiza_archive::Error> for DurabilityError {
225 fn from(error: rhiza_archive::Error) -> Self {
226 Self::Archive(error)
227 }
228}
229
230impl From<rhiza_log::Error> for DurabilityError {
231 fn from(error: rhiza_log::Error) -> Self {
232 Self::Log(error)
233 }
234}
235
236impl From<std::io::Error> for DurabilityError {
237 fn from(error: std::io::Error) -> Self {
238 Self::Io(error)
239 }
240}
241
242#[derive(Debug)]
243enum PendingLag {
244 New(Instant),
245 Recovered,
246}
247
248#[derive(Debug)]
249struct CoordinatorState {
250 durable_tip: CheckpointTip,
251 committed_index: LogIndex,
252 pending_lag: Option<PendingLag>,
253 health: DurabilityHealth,
254}
255
256pub struct CheckpointCoordinator {
257 store: ObjectArchiveStore,
258 publisher: CheckpointPublisher,
259 mode: DurabilityMode,
260 state: Mutex<CoordinatorState>,
261 publication_attempts: AtomicU64,
262}
263
264struct RuntimeCheckpointSnapshot {
265 anchor: RecoveryAnchor,
266 archive_bytes: Vec<u8>,
267}
268
269#[cfg(any(feature = "graph", feature = "kv"))]
270struct EngineSnapshotIdentity<'a> {
271 cluster_id: &'a str,
272 epoch: u64,
273 config_id: u64,
274 applied_index: LogIndex,
275 applied_hash: LogHash,
276}
277
278impl CheckpointCoordinator {
279 pub async fn open(
280 store: ObjectArchiveStore,
281 mode: DurabilityMode,
282 ) -> Result<Self, DurabilityError> {
283 Self::open_with_holder(store, mode, "anonymous-node").await
284 }
285
286 pub async fn open_with_holder(
287 store: ObjectArchiveStore,
288 mode: DurabilityMode,
289 holder: impl AsRef<str>,
290 ) -> Result<Self, DurabilityError> {
291 Self::open_with_holder_and_options(
292 store,
293 mode,
294 holder,
295 CheckpointPublisherOptions::default(),
296 )
297 .await
298 }
299
300 pub async fn open_with_holder_and_options(
301 store: ObjectArchiveStore,
302 mode: DurabilityMode,
303 holder: impl AsRef<str>,
304 publisher_options: CheckpointPublisherOptions,
305 ) -> Result<Self, DurabilityError> {
306 mode.validate()?;
307 store
308 .load_checkpoint()
309 .await?
310 .ok_or(DurabilityError::MissingCheckpoint)?;
311 let identity = store.checkpoint_identity()?;
312 let holder = format!(
313 "checkpoint-coordinator-{}-{}-{}-{}-{}",
314 identity.cluster_id(),
315 identity.epoch(),
316 identity.config_id(),
317 identity.recovery_generation(),
318 holder.as_ref()
319 );
320 let publisher = store
321 .open_checkpoint_publisher(holder, publisher_options)
322 .await?;
323 let loaded = publisher.cached_checkpoint().await;
324 let durable_tip = *loaded.manifest().tip();
325 let restored = store.restore_checkpoint_v2().await?;
326 let restored_tip = *restored.tip();
327 if restored_tip != durable_tip {
328 return Err(DurabilityError::Archive(
329 rhiza_archive::Error::InvalidCheckpoint(
330 "restored entries changed while verifying the loaded manifest".into(),
331 ),
332 ));
333 }
334 Ok(Self {
335 store,
336 publisher,
337 mode,
338 state: Mutex::new(CoordinatorState {
339 durable_tip,
340 committed_index: durable_tip.index(),
341 pending_lag: None,
342 health: DurabilityHealth::Available,
343 }),
344 publication_attempts: AtomicU64::new(0),
345 })
346 }
347
348 pub const fn mode(&self) -> &DurabilityMode {
349 &self.mode
350 }
351
352 pub fn durable_tip(&self) -> CheckpointTip {
353 self.lock_state().durable_tip
354 }
355
356 pub async fn refresh_durable_tip(&self) -> Result<CheckpointTip, DurabilityError> {
357 let loaded = self.publisher.observe_checkpoint().await?;
358 let accepted = self.publisher.cache_observed_checkpoint(loaded).await?;
359 observe_durable_tip(&self.state, *accepted.manifest().tip())
360 }
361
362 pub fn health(&self) -> DurabilityHealth {
363 self.lock_state().health
364 }
365
366 #[doc(hidden)]
367 pub fn checkpoint_publication_attempts(&self) -> u64 {
368 self.publication_attempts.load(Ordering::Relaxed)
369 }
370
371 pub fn note_committed(&self, index: LogIndex) {
372 let mut state = self.lock_state();
373 if index <= state.committed_index {
374 return;
375 }
376 state.committed_index = index;
377 if index > state.durable_tip.index() && state.pending_lag.is_none() {
378 state.pending_lag = Some(PendingLag::New(Instant::now()));
379 }
380 }
381
382 pub fn note_recovered_committed(&self, index: LogIndex) {
383 let mut state = self.lock_state();
384 state.committed_index = state.committed_index.max(index);
385 if state.committed_index > state.durable_tip.index() {
386 state.pending_lag = Some(PendingLag::Recovered);
387 }
388 }
389
390 pub fn write_allowed(&self) -> Result<(), DurabilityError> {
391 if matches!(self.mode, DurabilityMode::Sync)
392 && self.health() == DurabilityHealth::Unavailable
393 {
394 return Err(DurabilityError::Unavailable);
395 }
396 let DurabilityMode::Bounded { max_lag } = self.mode else {
397 return Ok(());
398 };
399 let state = self.lock_state();
400 let exceeded = state.committed_index > state.durable_tip.index()
401 && match state.pending_lag {
402 Some(PendingLag::Recovered) => true,
403 Some(PendingLag::New(pending)) => pending.elapsed() >= max_lag,
404 None => false,
405 };
406 if exceeded {
407 return Err(DurabilityError::LagExceeded {
408 committed_index: state.committed_index,
409 durable_index: state.durable_tip.index(),
410 max_lag,
411 });
412 }
413 Ok(())
414 }
415
416 pub async fn flush_runtime(
417 &self,
418 runtime: &NodeRuntime,
419 target_index: LogIndex,
420 ) -> Result<CheckpointTip, DurabilityError> {
421 let result = self.flush_runtime_inner(runtime, target_index).await;
422 if result.is_err() {
423 self.mark_unavailable();
424 }
425 result
426 }
427
428 async fn flush_runtime_inner(
429 &self,
430 runtime: &NodeRuntime,
431 target_index: LogIndex,
432 ) -> Result<CheckpointTip, DurabilityError> {
433 let log_state = runtime.log_store().logical_state()?;
434 let local_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
435 let mut durable_tip = self.durable_tip();
436 if durable_tip.index() > local_index {
437 return Err(DurabilityError::ArchiveAheadOfLocal {
438 durable_index: durable_tip.index(),
439 local_index,
440 });
441 }
442 let target_index = target_index.min(local_index);
443 if target_index <= durable_tip.index() {
444 return Ok(durable_tip);
445 }
446 if let Some(anchor) = log_state.anchor {
447 if durable_tip.index() < anchor.compacted().index() {
448 return Err(DurabilityError::SnapshotRequired {
449 anchor: Box::new(anchor),
450 });
451 }
452 }
453
454 let mut next =
455 durable_tip
456 .index()
457 .checked_add(1)
458 .ok_or_else(|| DurabilityError::LocalLogGap {
459 expected: durable_tip.index(),
460 actual: None,
461 })?;
462 while next <= target_index {
463 let end = next
464 .saturating_add(FLUSH_BATCH_ENTRIES - 1)
465 .min(target_index);
466 let entries = runtime
467 .log_store()
468 .read_range(IndexRange::new(next, end)?)?;
469 validate_local_batch(&entries, next, end, durable_tip)?;
470 self.publication_attempts.fetch_add(1, Ordering::Relaxed);
471 let published = self.publisher.publish_committed(&entries).await?;
472 durable_tip = *published.manifest().tip();
473 self.mark_durable(durable_tip);
474 if durable_tip.index() >= target_index {
475 break;
476 }
477 next =
478 durable_tip
479 .index()
480 .checked_add(1)
481 .ok_or_else(|| DurabilityError::LocalLogGap {
482 expected: durable_tip.index(),
483 actual: None,
484 })?;
485 }
486 Ok(durable_tip)
487 }
488
489 pub async fn checkpoint_compact(
490 &self,
491 runtime: &NodeRuntime,
492 ) -> Result<RecoveryAnchor, DurabilityError> {
493 self.checkpoint_compact_inner(runtime, None).await
494 }
495
496 pub async fn checkpoint_compact_fenced(
497 &self,
498 runtime: &NodeRuntime,
499 expected_config_id: u64,
500 expected_recovery_generation: u64,
501 expected_root: LogAnchor,
502 ) -> Result<RecoveryAnchor, DurabilityError> {
503 self.checkpoint_compact_inner(
504 runtime,
505 Some((
506 expected_config_id,
507 expected_recovery_generation,
508 expected_root,
509 )),
510 )
511 .await
512 }
513
514 async fn checkpoint_compact_inner(
515 &self,
516 runtime: &NodeRuntime,
517 fence: Option<(u64, u64, LogAnchor)>,
518 ) -> Result<RecoveryAnchor, DurabilityError> {
519 let (target, snapshot, _fence) = {
520 let _commit = runtime.commit.lock().map_err(|_| {
521 DurabilityError::SnapshotVerification("commit mutex is poisoned".into())
522 })?;
523 runtime
524 .ensure_ready()
525 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
526 let configuration = runtime
527 .configuration_state()
528 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
529 if !configuration.is_active() && configuration.stop().is_none() {
530 return Err(DurabilityError::SnapshotVerification(
531 "runtime configuration is not compactable".into(),
532 ));
533 }
534 if let Some((config_id, generation, root)) = fence {
535 let actual_config_id = configuration.config_id();
536 let actual_generation = runtime.config.recovery_generation();
537 let actual_root = runtime.log_root_unlocked().ok();
538 if actual_config_id != config_id
539 || actual_generation != generation
540 || actual_root != Some(root)
541 {
542 eprintln!(
543 "checkpoint fence mismatch: config {actual_config_id}/{config_id}, \
544 generation {actual_generation}/{generation}, root {actual_root:?}/{root:?}"
545 );
546 return Err(DurabilityError::PreconditionFailed);
547 }
548 }
549 if runtime
550 .checkpointing
551 .swap(true, std::sync::atomic::Ordering::AcqRel)
552 {
553 return Err(DurabilityError::SnapshotVerification(
554 "checkpoint transition is already in progress".into(),
555 ));
556 }
557 let fence = CheckpointFence(&runtime.checkpointing);
558 let (target, target_hash) = runtime
559 .ensure_materialized_tip()
560 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
561 let snapshot =
562 create_runtime_checkpoint_snapshot(runtime, target, target_hash, &configuration)?;
563 (target, snapshot, fence)
564 };
565 self.flush_runtime(runtime, target).await?;
566 let anchor = snapshot.anchor.clone();
567 self.publisher
568 .publish_checkpoint_snapshot(anchor.clone(), &snapshot.archive_bytes)
569 .await?;
570 let restored = self.store.restore_checkpoint_v2().await?;
571 let published = restored.snapshot().ok_or_else(|| {
572 DurabilityError::SnapshotVerification("published V2 root has no snapshot".into())
573 })?;
574 if published.anchor() != &anchor || published.bytes() != snapshot.archive_bytes {
575 return Err(DurabilityError::SnapshotVerification(
576 "read-back anchor or snapshot bytes differ".into(),
577 ));
578 }
579 runtime.log_store.compact_prefix(&anchor)?;
580 runtime
581 .compact_embedded_log_before(anchor.compacted().index())
582 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
583 self.mark_durable(CheckpointTip::new(
584 anchor.compacted().index(),
585 anchor.compacted().hash(),
586 ));
587 Ok(anchor)
588 }
589
590 pub async fn run_background<F>(
591 self: Arc<Self>,
592 runtime: Arc<NodeRuntime>,
593 shutdown: F,
594 ) -> Result<(), DurabilityError>
595 where
596 F: Future<Output = ()> + Send,
597 {
598 let cadence = match self.mode {
599 DurabilityMode::Sync => return Ok(()),
600 DurabilityMode::Bounded { max_lag } => {
601 let half = max_lag / 2;
602 half.min(Duration::from_secs(1))
603 }
604 DurabilityMode::Periodic { interval } => interval,
605 };
606 tokio::pin!(shutdown);
607 loop {
608 tokio::select! {
609 () = &mut shutdown => return Ok(()),
610 () = tokio::time::sleep(cadence) => {
611 match self.flush_runtime(&runtime, LogIndex::MAX).await {
612 Ok(_) | Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {}
613 Err(error) => return Err(error),
614 }
615 }
616 }
617 }
618 }
619
620 fn mark_durable(&self, durable_tip: CheckpointTip) {
621 let mut state = self.lock_state();
622 mark_durable_state(&mut state, durable_tip);
623 }
624
625 fn mark_unavailable(&self) {
626 let mut state = self.lock_state();
627 if state.committed_index > state.durable_tip.index() {
628 state.health = DurabilityHealth::Unavailable;
629 }
630 }
631
632 fn lock_state(&self) -> MutexGuard<'_, CoordinatorState> {
633 self.state
634 .lock()
635 .unwrap_or_else(|poisoned| poisoned.into_inner())
636 }
637}
638
639fn create_runtime_checkpoint_snapshot(
640 runtime: &NodeRuntime,
641 target: LogIndex,
642 target_hash: LogHash,
643 configuration: &ConfigurationState,
644) -> Result<RuntimeCheckpointSnapshot, DurabilityError> {
645 #[cfg(not(any(feature = "graph", feature = "kv")))]
646 let _ = target_hash;
647 let materializer = runtime
648 .lock_materializer()
649 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
650 match &*materializer {
651 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
652 Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
653 #[cfg(feature = "sql")]
654 Materializer::Sql(state) => {
655 let snapshot = state
656 .create_recovery_snapshot(runtime.config().recovery_generation())
657 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
658 if snapshot.anchor().compacted().index() != target
659 || snapshot.anchor().configuration_state() != configuration
660 {
661 return Err(DurabilityError::SnapshotVerification(
662 "SQLite snapshot does not match the compacted runtime state".into(),
663 ));
664 }
665 Ok(RuntimeCheckpointSnapshot {
666 anchor: snapshot.anchor().clone(),
667 archive_bytes: snapshot.db_bytes().to_vec(),
668 })
669 }
670 #[cfg(feature = "graph")]
671 Materializer::Graph(state) => {
672 let snapshot = state
673 .create_snapshot(target)
674 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
675 validate_engine_snapshot_identity(
676 runtime,
677 configuration,
678 EngineSnapshotIdentity {
679 cluster_id: snapshot.cluster_id(),
680 epoch: snapshot.epoch(),
681 config_id: snapshot.config_id(),
682 applied_index: snapshot.applied_index(),
683 applied_hash: snapshot.applied_hash(),
684 },
685 target,
686 target_hash,
687 )?;
688 let archive_bytes = encode_graph_snapshot(&snapshot)
689 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
690 Ok(RuntimeCheckpointSnapshot {
691 anchor: engine_recovery_anchor(
692 runtime,
693 configuration,
694 target,
695 snapshot.applied_hash(),
696 snapshot.materializer_fingerprint(),
697 &archive_bytes,
698 )?,
699 archive_bytes,
700 })
701 }
702 #[cfg(feature = "kv")]
703 Materializer::Kv(state) => {
704 let snapshot = state
705 .create_snapshot(target)
706 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
707 validate_engine_snapshot_identity(
708 runtime,
709 configuration,
710 EngineSnapshotIdentity {
711 cluster_id: snapshot.cluster_id(),
712 epoch: snapshot.epoch(),
713 config_id: snapshot.config_id(),
714 applied_index: snapshot.applied_index(),
715 applied_hash: snapshot.applied_hash(),
716 },
717 target,
718 target_hash,
719 )?;
720 let archive_bytes = encode_kv_snapshot(&snapshot)
721 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
722 Ok(RuntimeCheckpointSnapshot {
723 anchor: engine_recovery_anchor(
724 runtime,
725 configuration,
726 target,
727 snapshot.applied_hash(),
728 snapshot.materializer_fingerprint(),
729 &archive_bytes,
730 )?,
731 archive_bytes,
732 })
733 }
734 }
735}
736
737#[cfg(any(feature = "graph", feature = "kv"))]
738fn validate_engine_snapshot_identity(
739 runtime: &NodeRuntime,
740 configuration: &ConfigurationState,
741 snapshot: EngineSnapshotIdentity<'_>,
742 expected_index: LogIndex,
743 expected_hash: LogHash,
744) -> Result<(), DurabilityError> {
745 let config = runtime.config();
746 if snapshot.cluster_id != config.cluster_id()
747 || snapshot.epoch != config.epoch()
748 || snapshot.config_id != configuration.config_id()
749 || snapshot.applied_index == 0
750 || snapshot.applied_index != expected_index
751 || snapshot.applied_hash != expected_hash
752 {
753 return Err(DurabilityError::SnapshotVerification(
754 "engine snapshot identity does not match the compacted runtime state".into(),
755 ));
756 }
757 Ok(())
758}
759
760#[cfg(any(feature = "graph", feature = "kv"))]
761fn engine_recovery_anchor(
762 runtime: &NodeRuntime,
763 configuration: &ConfigurationState,
764 applied_index: LogIndex,
765 applied_hash: LogHash,
766 materializer_fingerprint: LogHash,
767 archive_bytes: &[u8],
768) -> Result<RecoveryAnchor, DurabilityError> {
769 let size_bytes = u64::try_from(archive_bytes.len()).map_err(|_| {
770 DurabilityError::SnapshotVerification("snapshot envelope size exceeds u64".into())
771 })?;
772 Ok(RecoveryAnchor::new_with_configuration(
773 runtime.config().cluster_id(),
774 runtime.config().epoch(),
775 configuration.clone(),
776 runtime.config().recovery_generation(),
777 LogAnchor::new(applied_index, applied_hash),
778 SnapshotIdentity::new(
779 format!("snapshot-{applied_index:020}"),
780 LogHash::digest(&[archive_bytes]),
781 size_bytes,
782 )
783 .with_executor_fingerprint(materializer_fingerprint),
784 ))
785}
786
787fn mark_durable_state(state: &mut CoordinatorState, durable_tip: CheckpointTip) {
788 if durable_tip.index() > state.durable_tip.index() {
789 state.durable_tip = durable_tip;
790 }
791 if state.committed_index <= state.durable_tip.index() {
792 state.pending_lag = None;
793 state.health = DurabilityHealth::Available;
794 }
795}
796
797fn observe_durable_tip(
798 state: &Mutex<CoordinatorState>,
799 observed: CheckpointTip,
800) -> Result<CheckpointTip, DurabilityError> {
801 let mut state = state
802 .lock()
803 .unwrap_or_else(|poisoned| poisoned.into_inner());
804 let current = state.durable_tip;
805 if observed.index() < current.index() {
806 return Err(DurabilityError::SnapshotVerification(format!(
807 "checkpoint tip rolled back from index {} to {}",
808 current.index(),
809 observed.index()
810 )));
811 }
812 if observed.index() == current.index() && observed.hash() != current.hash() {
813 return Err(DurabilityError::SnapshotVerification(format!(
814 "checkpoint tip hash changed at index {}",
815 observed.index()
816 )));
817 }
818 mark_durable_state(&mut state, observed);
819 Ok(state.durable_tip)
820}
821
822struct CheckpointFence<'a>(&'a std::sync::atomic::AtomicBool);
823
824impl Drop for CheckpointFence<'_> {
825 fn drop(&mut self) {
826 self.0.store(false, std::sync::atomic::Ordering::Release);
827 }
828}
829
830pub async fn restore_checkpoint_to_fresh_data_dir(
831 store: ObjectArchiveStore,
832 data_dir: impl AsRef<Path>,
833) -> Result<CheckpointTip, DurabilityError> {
834 restore_checkpoint_to_fresh_data_dir_with_target(store, data_dir.as_ref(), None, None).await
835}
836
837pub async fn restore_checkpoint_to_fresh_data_dir_for_node(
838 store: ObjectArchiveStore,
839 data_dir: impl AsRef<Path>,
840 target_node_id: &str,
841) -> Result<CheckpointTip, DurabilityError> {
842 if target_node_id.is_empty() {
843 return Err(DurabilityError::SnapshotVerification(
844 "target node_id is empty".into(),
845 ));
846 }
847 restore_checkpoint_to_fresh_data_dir_with_target(
848 store,
849 data_dir.as_ref(),
850 Some(target_node_id),
851 None,
852 )
853 .await
854}
855
856pub async fn restore_checkpoint_to_fresh_data_dir_for_node_with_marker(
857 store: ObjectArchiveStore,
858 data_dir: impl AsRef<Path>,
859 target_node_id: &str,
860 marker_name: &str,
861 marker_contents: &[u8],
862) -> Result<CheckpointTip, DurabilityError> {
863 if target_node_id.is_empty() {
864 return Err(DurabilityError::SnapshotVerification(
865 "target node_id is empty".into(),
866 ));
867 }
868 validate_restore_marker_name(marker_name)?;
869 restore_checkpoint_to_fresh_data_dir_with_target(
870 store,
871 data_dir.as_ref(),
872 Some(target_node_id),
873 Some((marker_name, marker_contents)),
874 )
875 .await
876}
877
878pub fn checkpoint_restore_in_progress(data_dir: impl AsRef<Path>) -> Result<bool, DurabilityError> {
879 let intent = data_dir.as_ref().join(RESTORE_INTENT_FILE);
880 let metadata = match fs::symlink_metadata(&intent) {
881 Ok(metadata) => metadata,
882 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
883 Err(error) => return Err(error.into()),
884 };
885 if metadata.file_type().is_symlink()
886 || !metadata.is_file()
887 || fs::read(intent)? != RESTORE_INTENT_CONTENTS
888 {
889 return Err(DurabilityError::SnapshotVerification(
890 "local checkpoint restore intent is invalid".into(),
891 ));
892 }
893 Ok(true)
894}
895
896pub fn validate_local_recovery_view(
897 data_dir: impl AsRef<Path>,
898 identity: &CheckpointIdentity,
899 target_node_id: &str,
900 execution_profile: ExecutionProfile,
901 checkpoint_root: LogAnchor,
902) -> Result<(), DurabilityError> {
903 let data_dir = data_dir.as_ref();
904 if checkpoint_restore_in_progress(data_dir)? {
905 return Err(DurabilityError::SnapshotVerification(
906 "local recovery view has an incomplete checkpoint restore intent".into(),
907 ));
908 }
909 #[cfg(not(feature = "kv"))]
910 let _ = target_node_id;
911 if snapshot_profile(identity.cluster_id())? != execution_profile {
912 return Err(DurabilityError::SnapshotVerification(
913 "local recovery view profile does not match checkpoint identity".into(),
914 ));
915 }
916 let validate_qlog = match execution_profile {
917 ExecutionProfile::Sqlite => {
918 #[cfg(feature = "sql")]
919 {
920 let state =
921 rhiza_sql::SqliteStateMachine::open_existing(data_dir.join("sqlite/db.sqlite"))
922 .map_err(|error| {
923 DurabilityError::SnapshotVerification(error.to_string())
924 })?;
925 validate_materializer_tip(
926 "SQL",
927 LogAnchor::new(
928 state.applied_index_value().map_err(|error| {
929 DurabilityError::SnapshotVerification(error.to_string())
930 })?,
931 state.applied_hash_value().map_err(|error| {
932 DurabilityError::SnapshotVerification(error.to_string())
933 })?,
934 ),
935 checkpoint_root,
936 )?;
937 true
938 }
939 #[cfg(not(feature = "sql"))]
940 return Err(DurabilityError::SnapshotVerification(
941 "sql execution profile is not compiled in".into(),
942 ));
943 }
944 ExecutionProfile::Kv => {
945 #[cfg(feature = "kv")]
946 {
947 let path = data_dir.join("kv/data.redb");
948 if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
949 return Err(DurabilityError::SnapshotVerification(
950 "KV materializer is missing or is not a regular file".into(),
951 ));
952 }
953 let state = RedbStateMachine::open(
954 &path,
955 identity.cluster_id(),
956 target_node_id,
957 identity.epoch(),
958 identity.config_id(),
959 )
960 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
961 validate_materializer_tip(
962 "KV",
963 state.applied_tip().map_err(|error| {
964 DurabilityError::SnapshotVerification(error.to_string())
965 })?,
966 checkpoint_root,
967 )?;
968 true
969 }
970 #[cfg(not(feature = "kv"))]
971 return Err(DurabilityError::SnapshotVerification(
972 "kv execution profile is not compiled in".into(),
973 ));
974 }
975 ExecutionProfile::Graph => false,
976 };
977
978 if validate_qlog {
979 validate_local_qlog(data_dir, identity, checkpoint_root)
980 } else {
981 Ok(())
982 }
983}
984
985pub async fn restore_checkpoint_for_rejoin_preserving_recorder(
986 store: ObjectArchiveStore,
987 data_dir: impl AsRef<Path>,
988 target_node_id: &str,
989 execution_profile: ExecutionProfile,
990 marker_name: &str,
991 marker_contents: &[u8],
992) -> Result<CheckpointTip, DurabilityError> {
993 if target_node_id.is_empty() {
994 return Err(DurabilityError::SnapshotVerification(
995 "target node_id is empty".into(),
996 ));
997 }
998 validate_restore_marker_name(marker_name)?;
999 let data_dir = data_dir.as_ref();
1000 let identity = store.checkpoint_identity()?.clone();
1001 if snapshot_profile(identity.cluster_id())? != execution_profile
1002 || execution_profile == ExecutionProfile::Graph
1003 {
1004 return Err(DurabilityError::SnapshotVerification(
1005 "rejoin recovery only replaces matching SQL or KV recovery views".into(),
1006 ));
1007 }
1008 let restored = store.restore_checkpoint_v2().await?;
1009 publish_restore_marker(data_dir, RESTORE_INTENT_FILE, RESTORE_INTENT_CONTENTS)?;
1010 install_restored_checkpoint(
1011 &identity,
1012 &restored,
1013 data_dir,
1014 Some(target_node_id),
1015 true,
1016 true,
1017 Some((marker_name, marker_contents)),
1018 )
1019}
1020
1021async fn restore_checkpoint_to_fresh_data_dir_with_target(
1022 store: ObjectArchiveStore,
1023 data_dir: &Path,
1024 target_node_id: Option<&str>,
1025 completion_marker: Option<(&str, &[u8])>,
1026) -> Result<CheckpointTip, DurabilityError> {
1027 let identity = store.checkpoint_identity()?.clone();
1028 store
1029 .load_checkpoint()
1030 .await?
1031 .ok_or(DurabilityError::MissingCheckpoint)?;
1032 prepare_fresh_restore_data_dir(data_dir, completion_marker.map(|(name, _)| name))?;
1033 let restored = store.restore_checkpoint_v2().await?;
1034 publish_restore_marker(data_dir, RESTORE_INTENT_FILE, RESTORE_INTENT_CONTENTS)?;
1035 install_restored_checkpoint(
1036 &identity,
1037 &restored,
1038 data_dir,
1039 target_node_id,
1040 true,
1041 false,
1042 completion_marker,
1043 )
1044}
1045
1046fn install_restored_checkpoint(
1047 identity: &CheckpointIdentity,
1048 restored: &RestoredCheckpoint,
1049 data_dir: &Path,
1050 target_node_id: Option<&str>,
1051 remove_generic_intent: bool,
1052 replace_rebuildable: bool,
1053 completion_marker: Option<(&str, &[u8])>,
1054) -> Result<CheckpointTip, DurabilityError> {
1055 let tip = *restored.tip();
1056 let profile = snapshot_profile(identity.cluster_id())?;
1057 validate_restored_suffix(profile, restored.suffix())?;
1058 let staging = create_restore_staging_dir(data_dir)?;
1059 let result = (|| -> Result<(), DurabilityError> {
1060 if let Some(snapshot) = restored.snapshot() {
1061 install_profile_snapshot(identity, snapshot, &staging, target_node_id)?;
1062 }
1063
1064 if restored.snapshot().is_some() || !restored.suffix().is_empty() {
1065 let initial_configuration = restored
1066 .snapshot()
1067 .map(|snapshot| snapshot.anchor().configuration_state().clone())
1068 .unwrap_or_else(|| ConfigurationState::active(identity.config_id(), LogHash::ZERO));
1069 let log = FileLogStore::open_with_configuration(
1070 staging.join("consensus/log"),
1071 identity.cluster_id(),
1072 identity.epoch(),
1073 initial_configuration,
1074 )?;
1075 if let Some(snapshot) = restored.snapshot() {
1076 log.install_recovery_anchor(
1077 snapshot.anchor(),
1078 identity.recovery_generation(),
1079 snapshot.anchor().configuration_state(),
1080 )?;
1081 }
1082 for batch in restored.suffix().chunks(FLUSH_BATCH_ENTRIES as usize) {
1083 log.append_batch(batch)?;
1084 }
1085 let installed_tip = log.logical_state()?.tip;
1086 if installed_tip.as_ref().map(|tip| (tip.index(), tip.hash()))
1087 != Some((tip.index(), tip.hash()))
1088 {
1089 return Err(DurabilityError::SnapshotVerification(
1090 "installed qlog tip does not match checkpoint tip".into(),
1091 ));
1092 }
1093 }
1094 if replace_rebuildable {
1095 quarantine_rebuildable_view(data_dir, profile)?;
1096 }
1097 publish_restore_staging(&staging, data_dir, remove_generic_intent, completion_marker)
1098 })();
1099 if result.is_err() {
1100 let _ = fs::remove_dir_all(&staging);
1101 }
1102 result?;
1103 Ok(tip)
1104}
1105
1106fn validate_restored_suffix(
1107 profile: ExecutionProfile,
1108 suffix: &[LogEntry],
1109) -> Result<(), DurabilityError> {
1110 for entry in suffix {
1111 crate::validate_profile_entry_shape(profile, entry)
1112 .map_err(DurabilityError::SnapshotVerification)?;
1113 }
1114 Ok(())
1115}
1116
1117#[cfg(any(feature = "sql", feature = "kv", test))]
1118fn validate_materializer_tip(
1119 label: &str,
1120 actual: LogAnchor,
1121 checkpoint_root: LogAnchor,
1122) -> Result<(), DurabilityError> {
1123 if actual != checkpoint_root {
1124 return Err(DurabilityError::SnapshotVerification(format!(
1125 "{label} materializer tip {}/{} does not exactly match checkpoint root {}/{}",
1126 actual.index(),
1127 actual.hash().to_hex(),
1128 checkpoint_root.index(),
1129 checkpoint_root.hash().to_hex(),
1130 )));
1131 }
1132 Ok(())
1133}
1134
1135fn validate_local_qlog(
1136 data_dir: &Path,
1137 identity: &CheckpointIdentity,
1138 checkpoint_root: LogAnchor,
1139) -> Result<(), DurabilityError> {
1140 let path = data_dir.join("consensus/log");
1141 if !path_has_state(&path)? {
1142 return Err(DurabilityError::SnapshotVerification(
1143 "local qlog is missing or empty".into(),
1144 ));
1145 }
1146 let log = FileLogStore::open(
1147 path,
1148 identity.cluster_id(),
1149 identity.epoch(),
1150 identity.config_id(),
1151 )?;
1152 let state = log.logical_state()?;
1153 let tip = state
1154 .tip
1155 .ok_or_else(|| DurabilityError::SnapshotVerification("local qlog has no tip".into()))?;
1156 if tip != checkpoint_root {
1157 return Err(DurabilityError::SnapshotVerification(format!(
1158 "local qlog tip {}/{} does not exactly match checkpoint root {}/{}",
1159 tip.index(),
1160 tip.hash().to_hex(),
1161 checkpoint_root.index(),
1162 checkpoint_root.hash().to_hex(),
1163 )));
1164 }
1165 if checkpoint_root.index() == 0 {
1166 if checkpoint_root.hash() != LogHash::ZERO {
1167 return Err(DurabilityError::SnapshotVerification(
1168 "checkpoint genesis hash is not zero".into(),
1169 ));
1170 }
1171 return Ok(());
1172 }
1173 let included_hash = match state.anchor.as_ref() {
1174 Some(anchor) if anchor.compacted().index() == checkpoint_root.index() => {
1175 Some(anchor.compacted().hash())
1176 }
1177 Some(anchor) if anchor.compacted().index() > checkpoint_root.index() => {
1178 return Err(DurabilityError::SnapshotVerification(
1179 "local qlog compacted past checkpoint root without exact inclusion evidence".into(),
1180 ));
1181 }
1182 _ => log.read(checkpoint_root.index())?.map(|entry| entry.hash),
1183 };
1184 if included_hash != Some(checkpoint_root.hash()) {
1185 return Err(DurabilityError::SnapshotVerification(format!(
1186 "local qlog does not include checkpoint root {} with its exact hash",
1187 checkpoint_root.index(),
1188 )));
1189 }
1190 Ok(())
1191}
1192
1193fn install_profile_snapshot(
1194 identity: &CheckpointIdentity,
1195 snapshot: &rhiza_archive::RestoredCheckpointSnapshot,
1196 staging: &Path,
1197 target_node_id: Option<&str>,
1198) -> Result<(), DurabilityError> {
1199 match snapshot_profile(identity.cluster_id())? {
1200 ExecutionProfile::Sqlite => {
1201 #[cfg(feature = "sql")]
1202 {
1203 validate_anchor_fingerprint(
1204 snapshot.anchor(),
1205 sql_executor_fingerprint().map_err(|error| {
1206 DurabilityError::SnapshotVerification(error.to_string())
1207 })?,
1208 )?;
1209 let path = staging.join("sqlite/db.sqlite");
1210 let node_id = target_node_id.ok_or_else(|| {
1211 DurabilityError::SnapshotVerification(
1212 "SQLite QWAL snapshot restore requires a target node_id".into(),
1213 )
1214 })?;
1215 restore_recovery_snapshot_file(path, snapshot.bytes(), snapshot.anchor(), node_id)
1216 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1217 }
1218 #[cfg(not(feature = "sql"))]
1219 Err(DurabilityError::SnapshotVerification(
1220 "sql execution profile is not compiled in".into(),
1221 ))
1222 }
1223 ExecutionProfile::Graph => {
1224 #[cfg(feature = "graph")]
1225 {
1226 let decoded = decode_graph_snapshot(snapshot.bytes())
1227 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1228 validate_decoded_snapshot_anchor(
1229 snapshot.anchor(),
1230 decoded.cluster_id(),
1231 decoded.epoch(),
1232 decoded.config_id(),
1233 decoded.applied_index(),
1234 decoded.applied_hash(),
1235 decoded.materializer_fingerprint(),
1236 )?;
1237 let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1238 restore_graph_snapshot_file(
1239 staging.join("ladybug/graph.lbug"),
1240 &decoded,
1241 target_node_id,
1242 )
1243 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1244 }
1245 #[cfg(not(feature = "graph"))]
1246 Err(DurabilityError::SnapshotVerification(
1247 "graph recovery support is not compiled in".into(),
1248 ))
1249 }
1250 ExecutionProfile::Kv => {
1251 #[cfg(feature = "kv")]
1252 {
1253 let decoded = decode_kv_snapshot(snapshot.bytes())
1254 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1255 validate_decoded_snapshot_anchor(
1256 snapshot.anchor(),
1257 decoded.cluster_id(),
1258 decoded.epoch(),
1259 decoded.config_id(),
1260 decoded.applied_index(),
1261 decoded.applied_hash(),
1262 decoded.materializer_fingerprint(),
1263 )?;
1264 let target_node_id = target_node_id.unwrap_or(decoded.created_by());
1265 restore_kv_snapshot_file(staging.join("kv/data.redb"), &decoded, target_node_id)
1266 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))
1267 }
1268 #[cfg(not(feature = "kv"))]
1269 Err(DurabilityError::SnapshotVerification(
1270 "KV recovery support is not compiled in".into(),
1271 ))
1272 }
1273 }
1274}
1275
1276fn snapshot_profile(cluster_id: &str) -> Result<ExecutionProfile, DurabilityError> {
1277 if matches!(cluster_id.strip_prefix("rhiza:graph:"), Some(logical) if !logical.is_empty()) {
1278 Ok(ExecutionProfile::Graph)
1279 } else if matches!(cluster_id.strip_prefix("rhiza:kv:"), Some(logical) if !logical.is_empty()) {
1280 Ok(ExecutionProfile::Kv)
1281 } else if matches!(cluster_id.strip_prefix("rhiza:sql:"), Some(logical) if !logical.is_empty())
1282 {
1283 Ok(ExecutionProfile::Sqlite)
1284 } else {
1285 Err(DurabilityError::SnapshotVerification(
1286 "snapshot checkpoint identity has no canonical execution profile prefix".into(),
1287 ))
1288 }
1289}
1290
1291fn validate_anchor_fingerprint(
1292 anchor: &RecoveryAnchor,
1293 expected: LogHash,
1294) -> Result<(), DurabilityError> {
1295 if anchor.executor_fingerprint() != Some(expected) {
1296 return Err(DurabilityError::SnapshotVerification(
1297 "snapshot executor fingerprint does not match this binary".into(),
1298 ));
1299 }
1300 Ok(())
1301}
1302
1303#[cfg(any(feature = "graph", feature = "kv"))]
1304fn validate_decoded_snapshot_anchor(
1305 anchor: &RecoveryAnchor,
1306 cluster_id: &str,
1307 epoch: u64,
1308 config_id: u64,
1309 applied_index: LogIndex,
1310 applied_hash: LogHash,
1311 materializer_fingerprint: LogHash,
1312) -> Result<(), DurabilityError> {
1313 validate_anchor_fingerprint(anchor, materializer_fingerprint)?;
1314 if anchor.cluster_id() != cluster_id
1315 || anchor.epoch() != epoch
1316 || anchor.config_id() != config_id
1317 || anchor.compacted().index() != applied_index
1318 || anchor.compacted().hash() != applied_hash
1319 {
1320 return Err(DurabilityError::SnapshotVerification(
1321 "decoded snapshot identity does not match its recovery anchor".into(),
1322 ));
1323 }
1324 Ok(())
1325}
1326
1327pub async fn restore_successor_checkpoint_to_fresh_data_dir(
1328 store: ObjectArchiveStore,
1329 config: &NodeConfig,
1330) -> Result<SuccessorRestorePreparation, DurabilityError> {
1331 let identity = store.checkpoint_identity()?;
1332 let loaded = store
1333 .load_checkpoint()
1334 .await?
1335 .ok_or(DurabilityError::MissingCheckpoint)?;
1336 let transition = loaded.manifest().successor_transition().ok_or_else(|| {
1337 DurabilityError::SnapshotVerification(
1338 "successor startup requires transition provenance".into(),
1339 )
1340 })?;
1341 let stop = LogAnchor::new(transition.stop_entry().index, transition.stop_entry().hash);
1342 let expected_stopped = ConfigurationState::stopped(
1343 transition.predecessor().config_id(),
1344 transition.successor().predecessor_config_digest(),
1345 stop,
1346 );
1347 let expected_initial = ConfigurationState::active(
1348 transition.predecessor().config_id(),
1349 transition.successor().predecessor_config_digest(),
1350 );
1351 if identity.cluster_id() != config.cluster_id()
1352 || identity.epoch() != config.epoch()
1353 || identity.config_id() != transition.successor().config_id()
1354 || identity.recovery_generation() != config.recovery_generation()
1355 || transition.successor().cluster_id() != config.cluster_id()
1356 || transition.successor().members() != config.membership().members()
1357 || transition.successor().digest() != config.membership().digest()
1358 || config.configuration_state() != &expected_stopped
1359 || config.log_initial_configuration() != &expected_initial
1360 {
1361 return Err(DurabilityError::SnapshotVerification(
1362 "successor checkpoint does not match target node configuration".into(),
1363 ));
1364 }
1365 let receipt = serde_json::to_vec(&SuccessorRestoreIdentity {
1366 version: 1,
1367 cluster_id: config.cluster_id(),
1368 epoch: config.epoch(),
1369 target_config_id: identity.config_id(),
1370 recovery_generation: config.recovery_generation(),
1371 node_id: config.node_id(),
1372 membership_digest: config.membership().digest().to_hex(),
1373 predecessor_config_id: transition.predecessor().config_id(),
1374 stop_index: transition.stop_entry().index,
1375 stop_hash: transition.stop_entry().hash.to_hex(),
1376 checkpoint_index: loaded.manifest().tip().index(),
1377 checkpoint_hash: loaded.manifest().tip().hash().to_hex(),
1378 })
1379 .map_err(|error| DurabilityError::SnapshotVerification(error.to_string()))?;
1380 let (lock, state) = prepare_successor_restore_root(config.data_dir(), &receipt)?;
1381 if state == SuccessorRestoreRootState::Complete {
1382 return Ok(SuccessorRestorePreparation {
1383 tip: *loaded.manifest().tip(),
1384 data_dir: config.data_dir().clone(),
1385 identity: receipt,
1386 requires_recorder_install: false,
1387 _lock: lock,
1388 });
1389 }
1390
1391 let restored = store.restore_checkpoint_v2().await?;
1392 if restored.tip() != loaded.manifest().tip() {
1393 return Err(DurabilityError::SnapshotVerification(
1394 "successor checkpoint changed during restore".into(),
1395 ));
1396 }
1397 if state == SuccessorRestoreRootState::Fresh {
1398 publish_restore_marker(config.data_dir(), SUCCESSOR_RESTORE_INTENT_FILE, &receipt)?;
1399 }
1400 install_restored_checkpoint(
1401 identity,
1402 &restored,
1403 config.data_dir(),
1404 Some(config.node_id()),
1405 false,
1406 false,
1407 None,
1408 )?;
1409 Ok(SuccessorRestorePreparation {
1410 tip: *restored.tip(),
1411 data_dir: config.data_dir().clone(),
1412 identity: receipt,
1413 requires_recorder_install: true,
1414 _lock: lock,
1415 })
1416}
1417
1418fn create_restore_staging_dir(data_dir: &Path) -> Result<PathBuf, DurabilityError> {
1419 fs::create_dir_all(data_dir)?;
1420 for _ in 0..128 {
1421 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1422 let staging = data_dir.join(format!(
1423 "{RESTORE_STAGING_PREFIX}{}-{sequence}",
1424 process::id()
1425 ));
1426 match fs::create_dir(&staging) {
1427 Ok(()) => return Ok(staging),
1428 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1429 Err(error) => return Err(error.into()),
1430 }
1431 }
1432 Err(std::io::Error::new(
1433 std::io::ErrorKind::AlreadyExists,
1434 "could not allocate restore staging directory",
1435 )
1436 .into())
1437}
1438
1439fn publish_restore_staging(
1440 staging: &Path,
1441 data_dir: &Path,
1442 remove_generic_intent: bool,
1443 completion_marker: Option<(&str, &[u8])>,
1444) -> Result<(), DurabilityError> {
1445 sync_directory(staging)?;
1446 for name in ["sqlite", "ladybug", "kv", "consensus"] {
1447 let source = staging.join(name);
1448 if source.exists() {
1449 fs::rename(&source, data_dir.join(name))?;
1450 }
1451 }
1452 fs::remove_dir(staging)?;
1453 sync_directory(data_dir)?;
1454 if let Some((marker_name, marker_contents)) = completion_marker {
1455 publish_restore_marker(data_dir, marker_name, marker_contents)?;
1456 }
1457 if remove_generic_intent {
1458 fs::remove_file(data_dir.join(RESTORE_INTENT_FILE))?;
1459 }
1460 sync_directory(data_dir)
1461}
1462
1463fn quarantine_rebuildable_view(
1464 data_dir: &Path,
1465 profile: ExecutionProfile,
1466) -> Result<Option<PathBuf>, DurabilityError> {
1467 let materializer = match profile {
1468 ExecutionProfile::Sqlite => "sqlite",
1469 ExecutionProfile::Kv => "kv",
1470 ExecutionProfile::Graph => {
1471 return Err(DurabilityError::SnapshotVerification(
1472 "graph recovery view replacement is outside this recovery path".into(),
1473 ))
1474 }
1475 };
1476 let names = [materializer, "consensus"];
1477 if !names
1478 .iter()
1479 .any(|name| fs::symlink_metadata(data_dir.join(name)).is_ok())
1480 {
1481 return Ok(None);
1482 }
1483 for _ in 0..128 {
1484 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1485 let quarantine = data_dir.join(format!(
1486 ".rebuildable-quarantine-{}-{sequence}",
1487 process::id()
1488 ));
1489 match fs::create_dir(&quarantine) {
1490 Ok(()) => {
1491 for name in names {
1492 let source = data_dir.join(name);
1493 if fs::symlink_metadata(&source).is_ok() {
1494 fs::rename(source, quarantine.join(name))?;
1495 }
1496 }
1497 sync_directory(&quarantine)?;
1498 sync_directory(data_dir)?;
1499 return Ok(Some(quarantine));
1500 }
1501 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1502 Err(error) => return Err(error.into()),
1503 }
1504 }
1505 Err(std::io::Error::new(
1506 std::io::ErrorKind::AlreadyExists,
1507 "could not allocate rebuildable recovery quarantine",
1508 )
1509 .into())
1510}
1511
1512fn publish_restore_marker(
1513 data_dir: &Path,
1514 marker_name: &str,
1515 contents: &[u8],
1516) -> Result<(), DurabilityError> {
1517 validate_restore_marker_name(marker_name)?;
1518 fs::create_dir_all(data_dir)?;
1519 let sequence = RESTORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1520 let temporary = data_dir.join(format!(
1521 "{RESTORE_MARKER_TMP_PREFIX}{}-{sequence}",
1522 process::id()
1523 ));
1524 let mut file = fs::OpenOptions::new()
1525 .write(true)
1526 .create_new(true)
1527 .open(&temporary)?;
1528 file.write_all(contents)?;
1529 file.sync_all()?;
1530 fs::rename(temporary, data_dir.join(marker_name))?;
1531 sync_directory(data_dir)
1532}
1533
1534fn validate_restore_marker_name(marker_name: &str) -> Result<(), DurabilityError> {
1535 if marker_name.is_empty()
1536 || matches!(marker_name, "." | "..")
1537 || marker_name.contains(std::path::MAIN_SEPARATOR)
1538 {
1539 return Err(DurabilityError::SnapshotVerification(
1540 "restore marker name must be one local file name".into(),
1541 ));
1542 }
1543 Ok(())
1544}
1545
1546#[derive(Clone, Copy, Eq, PartialEq)]
1547enum SuccessorRestoreRootState {
1548 Fresh,
1549 Intent,
1550 Complete,
1551}
1552
1553fn prepare_successor_restore_root(
1554 data_dir: &Path,
1555 expected_identity: &[u8],
1556) -> Result<(fs::File, SuccessorRestoreRootState), DurabilityError> {
1557 fs::create_dir_all(data_dir)?;
1558 let lock = fs::OpenOptions::new()
1559 .read(true)
1560 .write(true)
1561 .create(true)
1562 .truncate(false)
1563 .open(data_dir.join(SUCCESSOR_RESTORE_LOCK_FILE))?;
1564 lock.lock()?;
1565
1566 let intent = data_dir.join(SUCCESSOR_RESTORE_INTENT_FILE);
1567 let complete = data_dir.join(SUCCESSOR_RESTORE_COMPLETE_FILE);
1568 let state = match (fs::read(&intent), fs::read(&complete)) {
1569 (Ok(actual), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1570 if actual != expected_identity {
1571 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1572 }
1573 SuccessorRestoreRootState::Intent
1574 }
1575 (Err(error), Ok(actual)) if error.kind() == std::io::ErrorKind::NotFound => {
1576 if !completed_successor_identity_matches(&actual, expected_identity) {
1577 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1578 }
1579 SuccessorRestoreRootState::Complete
1580 }
1581 (Err(intent_error), Err(complete_error))
1582 if intent_error.kind() == std::io::ErrorKind::NotFound
1583 && complete_error.kind() == std::io::ErrorKind::NotFound =>
1584 {
1585 SuccessorRestoreRootState::Fresh
1586 }
1587 _ => return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf())),
1588 };
1589
1590 for entry in fs::read_dir(data_dir)? {
1591 let entry = entry?;
1592 let name = entry.file_name();
1593 let name = name.to_string_lossy();
1594 let common =
1595 name == SUCCESSOR_RESTORE_LOCK_FILE || name.starts_with(RESTORE_MARKER_TMP_PREFIX);
1596 let allowed = match state {
1597 SuccessorRestoreRootState::Fresh => common,
1598 SuccessorRestoreRootState::Intent => {
1599 common
1600 || name == SUCCESSOR_RESTORE_INTENT_FILE
1601 || name == "sqlite"
1602 || name == "ladybug"
1603 || name == "kv"
1604 || name == "consensus"
1605 || name == "recorder"
1606 || name.starts_with(RESTORE_STAGING_PREFIX)
1607 }
1608 SuccessorRestoreRootState::Complete => {
1609 common
1610 || name == SUCCESSOR_RESTORE_COMPLETE_FILE
1611 || name == ".node.lock"
1612 || name == "sqlite"
1613 || name == "ladybug"
1614 || name == "kv"
1615 || name == "consensus"
1616 || name == "recorder"
1617 }
1618 };
1619 if !allowed {
1620 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1621 }
1622 }
1623
1624 for entry in fs::read_dir(data_dir)? {
1625 let entry = entry?;
1626 if entry
1627 .file_name()
1628 .to_string_lossy()
1629 .starts_with(RESTORE_MARKER_TMP_PREFIX)
1630 {
1631 fs::remove_file(entry.path())?;
1632 }
1633 }
1634 if state == SuccessorRestoreRootState::Intent {
1635 for name in ["sqlite", "ladybug", "kv", "consensus", "recorder"] {
1636 let path = data_dir.join(name);
1637 if path.exists() {
1638 fs::remove_dir_all(path)?;
1639 }
1640 }
1641 for entry in fs::read_dir(data_dir)? {
1642 let entry = entry?;
1643 if entry
1644 .file_name()
1645 .to_string_lossy()
1646 .starts_with(RESTORE_STAGING_PREFIX)
1647 {
1648 fs::remove_dir_all(entry.path())?;
1649 }
1650 }
1651 sync_directory(data_dir)?;
1652 }
1653 Ok((lock, state))
1654}
1655
1656fn completed_successor_identity_matches(actual: &[u8], expected: &[u8]) -> bool {
1657 let (Ok(mut actual), Ok(mut expected)) = (
1658 serde_json::from_slice::<serde_json::Value>(actual),
1659 serde_json::from_slice::<serde_json::Value>(expected),
1660 ) else {
1661 return false;
1662 };
1663 let Some(actual_index) = actual["checkpoint_index"].as_u64() else {
1664 return false;
1665 };
1666 let Some(expected_index) = expected["checkpoint_index"].as_u64() else {
1667 return false;
1668 };
1669 let (Some(actual_hash), Some(expected_hash)) = (
1670 actual["checkpoint_hash"].as_str(),
1671 expected["checkpoint_hash"].as_str(),
1672 ) else {
1673 return false;
1674 };
1675 if LogHash::from_hex(actual_hash).is_none() || LogHash::from_hex(expected_hash).is_none() {
1676 return false;
1677 }
1678 if actual_index > expected_index
1679 || (actual_index == expected_index && actual_hash != expected_hash)
1680 {
1681 return false;
1682 }
1683 for receipt in [&mut actual, &mut expected] {
1684 let Some(receipt) = receipt.as_object_mut() else {
1685 return false;
1686 };
1687 receipt.remove("checkpoint_index");
1688 receipt.remove("checkpoint_hash");
1689 }
1690 actual == expected
1691}
1692
1693fn sync_directory(path: &Path) -> Result<(), DurabilityError> {
1694 fs::File::open(path)?.sync_all()?;
1695 Ok(())
1696}
1697
1698fn validate_local_batch(
1699 entries: &[rhiza_core::LogEntry],
1700 start: LogIndex,
1701 end: LogIndex,
1702 durable_tip: CheckpointTip,
1703) -> Result<(), DurabilityError> {
1704 let expected_len =
1705 usize::try_from(end - start + 1).map_err(|_| DurabilityError::LocalLogGap {
1706 expected: start,
1707 actual: entries.first().map(|entry| entry.index),
1708 })?;
1709 if entries.len() != expected_len {
1710 let actual = entries
1711 .iter()
1712 .zip(start..=end)
1713 .find_map(|(entry, expected)| (entry.index != expected).then_some(entry.index));
1714 return Err(DurabilityError::LocalLogGap {
1715 expected: start + entries.len() as u64,
1716 actual,
1717 });
1718 }
1719
1720 let mut expected_hash = durable_tip.hash();
1721 for (expected_index, entry) in (start..).zip(entries) {
1722 if entry.index != expected_index {
1723 return Err(DurabilityError::LocalLogGap {
1724 expected: expected_index,
1725 actual: Some(entry.index),
1726 });
1727 }
1728 if entry.prev_hash != expected_hash || entry.recompute_hash() != entry.hash {
1729 return Err(DurabilityError::LocalLogConflict { index: entry.index });
1730 }
1731 expected_hash = entry.hash;
1732 }
1733 Ok(())
1734}
1735
1736fn prepare_fresh_restore_data_dir(
1737 data_dir: &Path,
1738 completion_marker_name: Option<&str>,
1739) -> Result<(), DurabilityError> {
1740 if !path_has_state(data_dir)? {
1741 return Ok(());
1742 }
1743
1744 let intent = data_dir.join(RESTORE_INTENT_FILE);
1745 if !intent.exists() {
1746 let entries = fs::read_dir(data_dir)?.collect::<Result<Vec<_>, _>>()?;
1747 if entries.iter().all(|entry| {
1748 entry
1749 .file_name()
1750 .to_string_lossy()
1751 .starts_with(RESTORE_MARKER_TMP_PREFIX)
1752 }) {
1753 for entry in entries {
1754 fs::remove_file(entry.path())?;
1755 }
1756 sync_directory(data_dir)?;
1757 return Ok(());
1758 }
1759 }
1760 if !matches!(fs::read(&intent), Ok(contents) if contents == RESTORE_INTENT_CONTENTS) {
1761 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1762 }
1763
1764 for entry in fs::read_dir(data_dir)? {
1765 let entry = entry?;
1766 let name = entry.file_name();
1767 let name = name.to_string_lossy();
1768 let owned = name == RESTORE_INTENT_FILE
1769 || completion_marker_name.is_some_and(|marker| name == marker)
1770 || name == "sqlite"
1771 || name == "ladybug"
1772 || name == "kv"
1773 || name == "consensus"
1774 || name.starts_with(RESTORE_MARKER_TMP_PREFIX)
1775 || name.starts_with(RESTORE_STAGING_PREFIX);
1776 if !owned {
1777 return Err(DurabilityError::DataDirNotFresh(data_dir.to_path_buf()));
1778 }
1779 }
1780
1781 for name in ["sqlite", "ladybug", "kv", "consensus"] {
1782 let path = data_dir.join(name);
1783 if path.exists() {
1784 fs::remove_dir_all(path)?;
1785 }
1786 }
1787 for entry in fs::read_dir(data_dir)? {
1788 let entry = entry?;
1789 let name = entry.file_name();
1790 let name = name.to_string_lossy();
1791 if name.starts_with(RESTORE_STAGING_PREFIX) {
1792 fs::remove_dir_all(entry.path())?;
1793 } else if name.starts_with(RESTORE_MARKER_TMP_PREFIX) {
1794 fs::remove_file(entry.path())?;
1795 }
1796 }
1797 fs::remove_file(intent)?;
1798 sync_directory(data_dir)?;
1799 Ok(())
1800}
1801
1802fn path_has_state(path: &Path) -> Result<bool, std::io::Error> {
1803 let metadata = match fs::symlink_metadata(path) {
1804 Ok(metadata) => metadata,
1805 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1806 Err(error) => return Err(error),
1807 };
1808 if !metadata.is_dir() {
1809 return Ok(true);
1810 }
1811 fs::read_dir(path)?
1812 .next()
1813 .transpose()
1814 .map(|entry| entry.is_some())
1815}
1816
1817#[cfg(test)]
1818mod tests {
1819 use super::{
1820 completed_successor_identity_matches, mark_durable_state, observe_durable_tip,
1821 snapshot_profile, validate_local_qlog, validate_materializer_tip, validate_restored_suffix,
1822 CheckpointTip, CoordinatorState, DurabilityError, DurabilityHealth, ExecutionProfile,
1823 LogHash, PendingLag, RESTORE_INTENT_CONTENTS, RESTORE_INTENT_FILE,
1824 };
1825 #[cfg(feature = "kv")]
1826 use crate::{KvCommandV1, NodeConfig, NodeRuntime};
1827 use rhiza_archive::CheckpointIdentity;
1828 #[cfg(feature = "kv")]
1829 use rhiza_archive::ObjectArchiveStore;
1830 use rhiza_core::{EntryType, LogEntry};
1831 use rhiza_log::{FileLogStore, LogStore};
1832 #[cfg(feature = "kv")]
1833 use rhiza_obj_store::{ObjStore, ObjStoreConfig};
1834 #[cfg(feature = "kv")]
1835 use rhiza_quepaxa::ThreeNodeConsensus;
1836 use std::sync::{Arc, Barrier, Mutex};
1837
1838 #[test]
1839 fn snapshot_profile_requires_a_canonical_effective_cluster_identity() {
1840 assert_eq!(
1841 snapshot_profile("rhiza:sql:cluster-a").unwrap(),
1842 ExecutionProfile::Sqlite
1843 );
1844 assert_eq!(
1845 snapshot_profile("rhiza:graph:cluster-a").unwrap(),
1846 ExecutionProfile::Graph
1847 );
1848 assert_eq!(
1849 snapshot_profile("rhiza:kv:cluster-a").unwrap(),
1850 ExecutionProfile::Kv
1851 );
1852 assert!(matches!(
1853 snapshot_profile("cluster-a"),
1854 Err(DurabilityError::SnapshotVerification(_))
1855 ));
1856 assert!(snapshot_profile("rhiza:graph:").is_err());
1857 }
1858
1859 #[test]
1860 fn sqlite_restore_suffix_rejects_legacy_commands_during_preflight() {
1861 let payload = b"put\tlegacy\tkey\tvalue".to_vec();
1862 let entry = LogEntry {
1863 cluster_id: "rhiza:sql:cluster-a".into(),
1864 epoch: 1,
1865 config_id: 1,
1866 index: 1,
1867 entry_type: EntryType::Command,
1868 prev_hash: LogHash::ZERO,
1869 hash: LogEntry::calculate_hash(
1870 "rhiza:sql:cluster-a",
1871 1,
1872 1,
1873 1,
1874 EntryType::Command,
1875 LogHash::ZERO,
1876 &payload,
1877 ),
1878 payload,
1879 };
1880
1881 assert!(matches!(
1882 validate_restored_suffix(ExecutionProfile::Sqlite, &[entry]),
1883 Err(DurabilityError::SnapshotVerification(message)) if message.contains("QWAL")
1884 ));
1885 }
1886
1887 #[test]
1888 fn materializer_tip_requires_exact_checkpoint_root() {
1889 let checkpoint = rhiza_core::LogAnchor::new(4, LogHash::digest(&[b"checkpoint"]));
1890 let ahead = rhiza_core::LogAnchor::new(5, LogHash::digest(&[b"ahead"]));
1891 let behind = rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"behind"]));
1892 let conflicting = rhiza_core::LogAnchor::new(4, LogHash::digest(&[b"conflicting"]));
1893
1894 assert!(validate_materializer_tip("test", ahead, checkpoint).is_err());
1895 assert!(validate_materializer_tip("test", behind, checkpoint).is_err());
1896 assert!(validate_materializer_tip("test", conflicting, checkpoint).is_err());
1897 assert!(validate_materializer_tip("test", checkpoint, checkpoint).is_ok());
1898 }
1899
1900 #[test]
1901 fn local_qlog_rejects_ahead_tip_even_when_checkpoint_entry_is_retained() {
1902 let root = tempfile::tempdir().unwrap();
1903 let identity = CheckpointIdentity::new("rhiza:sql:cluster-a", 1, 1, 1);
1904 let log = FileLogStore::open(
1905 root.path().join("consensus/log"),
1906 identity.cluster_id(),
1907 1,
1908 1,
1909 )
1910 .unwrap();
1911 let entry = |index, previous| {
1912 let hash = LogEntry::calculate_hash(
1913 identity.cluster_id(),
1914 index,
1915 1,
1916 1,
1917 EntryType::Noop,
1918 previous,
1919 &[],
1920 );
1921 LogEntry {
1922 cluster_id: identity.cluster_id().into(),
1923 epoch: 1,
1924 config_id: 1,
1925 index,
1926 entry_type: EntryType::Noop,
1927 payload: Vec::new(),
1928 prev_hash: previous,
1929 hash,
1930 }
1931 };
1932 let first = entry(1, LogHash::ZERO);
1933 let second = entry(2, first.hash);
1934 log.append_batch(&[first.clone(), second]).unwrap();
1935
1936 assert!(validate_local_qlog(
1937 root.path(),
1938 &identity,
1939 rhiza_core::LogAnchor::new(first.index, first.hash),
1940 )
1941 .is_err());
1942 assert!(validate_local_qlog(
1943 root.path(),
1944 &identity,
1945 rhiza_core::LogAnchor::new(2, LogHash::digest(&[b"conflicting"])),
1946 )
1947 .is_err());
1948 assert!(validate_local_qlog(
1949 root.path(),
1950 &identity,
1951 rhiza_core::LogAnchor::new(3, LogHash::digest(&[b"ahead checkpoint"])),
1952 )
1953 .is_err());
1954 }
1955
1956 #[test]
1957 fn restore_intent_remains_until_completion_marker_is_durable_and_retryable() {
1958 let root = tempfile::tempdir().unwrap();
1959 let data_dir = root.path().join("data");
1960 std::fs::create_dir_all(&data_dir).unwrap();
1961 std::fs::write(data_dir.join(RESTORE_INTENT_FILE), RESTORE_INTENT_CONTENTS).unwrap();
1962 std::fs::create_dir(data_dir.join("identity.json")).unwrap();
1963 let staging = super::create_restore_staging_dir(&data_dir).unwrap();
1964
1965 assert!(super::publish_restore_staging(
1966 &staging,
1967 &data_dir,
1968 true,
1969 Some(("identity.json", b"identity-v1")),
1970 )
1971 .is_err());
1972 assert_eq!(
1973 std::fs::read(data_dir.join(RESTORE_INTENT_FILE)).unwrap(),
1974 RESTORE_INTENT_CONTENTS
1975 );
1976
1977 std::fs::remove_dir(data_dir.join("identity.json")).unwrap();
1978 let retry_staging = super::create_restore_staging_dir(&data_dir).unwrap();
1979 super::publish_restore_staging(
1980 &retry_staging,
1981 &data_dir,
1982 true,
1983 Some(("identity.json", b"identity-v1")),
1984 )
1985 .unwrap();
1986 assert_eq!(
1987 std::fs::read(data_dir.join("identity.json")).unwrap(),
1988 b"identity-v1"
1989 );
1990 assert!(!data_dir.join(RESTORE_INTENT_FILE).exists());
1991 }
1992
1993 #[cfg(feature = "kv")]
1994 #[tokio::test]
1995 async fn kv_compacted_rejoin_restores_missing_or_corrupt_views_without_touching_recorder() {
1996 let root = tempfile::tempdir().unwrap();
1997 let identity = CheckpointIdentity::new("rhiza:kv:cluster-a", 1, 1, 1);
1998 let archive = ObjectArchiveStore::new_checkpoint_for_single_process(
1999 ObjStore::new(ObjStoreConfig::Local {
2000 root: root.path().join("archive"),
2001 })
2002 .unwrap(),
2003 identity.clone(),
2004 );
2005 archive.initialize_checkpoint().await.unwrap();
2006 let source_dir = root.path().join("source");
2007 let config = NodeConfig::new_embedded(
2008 "cluster-a",
2009 "node-1",
2010 source_dir,
2011 1,
2012 1,
2013 ["node-1", "node-2", "node-3"],
2014 )
2015 .unwrap()
2016 .with_execution_profile(ExecutionProfile::Kv)
2017 .unwrap()
2018 .with_recovery_generation(1)
2019 .unwrap();
2020 let consensus = Arc::new(
2021 ThreeNodeConsensus::from_recovered_tip(
2022 "rhiza:kv:cluster-a",
2023 "node-1",
2024 1,
2025 1,
2026 [
2027 root.path().join("recorders/node-1"),
2028 root.path().join("recorders/node-2"),
2029 root.path().join("recorders/node-3"),
2030 ],
2031 1,
2032 LogHash::ZERO,
2033 )
2034 .unwrap(),
2035 );
2036 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
2037 let coordinator = CheckpointCoordinator::open(archive.clone(), DurabilityMode::Sync)
2038 .await
2039 .unwrap();
2040 let committed = runtime
2041 .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"value".to_vec()).unwrap())
2042 .unwrap();
2043 coordinator.note_committed(committed.applied_index());
2044 coordinator
2045 .flush_runtime(&runtime, committed.applied_index())
2046 .await
2047 .unwrap();
2048 let checkpoint_root = runtime.checkpoint_compact(&coordinator).await.unwrap();
2049
2050 let target = root.path().join("target");
2051 restore_checkpoint_to_fresh_data_dir_for_node(archive.clone(), &target, "node-1")
2052 .await
2053 .unwrap();
2054 validate_local_recovery_view(
2055 &target,
2056 &identity,
2057 "node-1",
2058 ExecutionProfile::Kv,
2059 *checkpoint_root.compacted(),
2060 )
2061 .unwrap();
2062 std::fs::create_dir_all(target.join("recorder")).unwrap();
2063 std::fs::write(target.join("recorder/sentinel"), b"keep-me").unwrap();
2064
2065 std::fs::remove_dir_all(target.join("consensus")).unwrap();
2066 assert!(validate_local_recovery_view(
2067 &target,
2068 &identity,
2069 "node-1",
2070 ExecutionProfile::Kv,
2071 *checkpoint_root.compacted(),
2072 )
2073 .is_err());
2074 restore_checkpoint_for_rejoin_preserving_recorder(
2075 archive.clone(),
2076 &target,
2077 "node-1",
2078 ExecutionProfile::Kv,
2079 "identity.json",
2080 b"identity-v1",
2081 )
2082 .await
2083 .unwrap();
2084
2085 std::fs::write(target.join("kv/data.redb"), b"corrupt").unwrap();
2086 assert!(validate_local_recovery_view(
2087 &target,
2088 &identity,
2089 "node-1",
2090 ExecutionProfile::Kv,
2091 *checkpoint_root.compacted(),
2092 )
2093 .is_err());
2094 restore_checkpoint_for_rejoin_preserving_recorder(
2095 archive,
2096 &target,
2097 "node-1",
2098 ExecutionProfile::Kv,
2099 "identity.json",
2100 b"identity-v1",
2101 )
2102 .await
2103 .unwrap();
2104 assert_eq!(
2105 std::fs::read(target.join("recorder/sentinel")).unwrap(),
2106 b"keep-me"
2107 );
2108 }
2109
2110 fn successor_receipt(index: u64, hash_byte: char, generation: u64) -> Vec<u8> {
2111 serde_json::to_vec(&serde_json::json!({
2112 "version": 1,
2113 "cluster_id": "cluster-a",
2114 "epoch": 1,
2115 "target_config_id": 2,
2116 "recovery_generation": generation,
2117 "node_id": "node-1",
2118 "membership_digest": "digest",
2119 "predecessor_config_id": 1,
2120 "stop_index": 4,
2121 "stop_hash": "stop",
2122 "checkpoint_index": index,
2123 "checkpoint_hash": hash_byte.to_string().repeat(64),
2124 }))
2125 .unwrap()
2126 }
2127
2128 #[test]
2129 fn completed_successor_receipt_allows_only_forward_checkpoint_progress() {
2130 let expected = successor_receipt(8, '8', 1);
2131
2132 assert!(completed_successor_identity_matches(
2133 &successor_receipt(4, '4', 1),
2134 &expected
2135 ));
2136 assert!(!completed_successor_identity_matches(
2137 &successor_receipt(8, '9', 1),
2138 &expected
2139 ));
2140 assert!(!completed_successor_identity_matches(
2141 &successor_receipt(9, '9', 1),
2142 &expected
2143 ));
2144 assert!(!completed_successor_identity_matches(
2145 &successor_receipt(4, '4', 2),
2146 &expected
2147 ));
2148 let mut malformed =
2149 serde_json::from_slice::<serde_json::Value>(&successor_receipt(4, '4', 1)).unwrap();
2150 malformed["checkpoint_hash"] = serde_json::json!(7);
2151 assert!(!completed_successor_identity_matches(
2152 &serde_json::to_vec(&malformed).unwrap(),
2153 &expected
2154 ));
2155 }
2156
2157 #[test]
2158 fn concurrent_flush_completion_cannot_regress_the_durable_tip() {
2159 let newer = CheckpointTip::new(8, LogHash::digest(&[b"newer"]));
2160 let older = CheckpointTip::new(4, LogHash::digest(&[b"older"]));
2161 let mut state = CoordinatorState {
2162 durable_tip: newer,
2163 committed_index: 8,
2164 pending_lag: None,
2165 health: DurabilityHealth::Available,
2166 };
2167
2168 mark_durable_state(&mut state, older);
2169
2170 assert_eq!(state.durable_tip, newer);
2171 }
2172
2173 #[test]
2174 fn checkpoint_observation_rejects_same_index_hash_conflict_without_mutation() {
2175 let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
2176 let conflicting = CheckpointTip::new(8, LogHash::digest(&[b"conflicting"]));
2177 let state = Mutex::new(CoordinatorState {
2178 durable_tip: current,
2179 committed_index: 8,
2180 pending_lag: None,
2181 health: DurabilityHealth::Available,
2182 });
2183
2184 assert!(matches!(
2185 observe_durable_tip(&state, conflicting),
2186 Err(DurabilityError::SnapshotVerification(_))
2187 ));
2188 assert_eq!(state.lock().unwrap().durable_tip, current);
2189 }
2190
2191 #[test]
2192 fn checkpoint_observation_rejects_remote_rollback_without_mutation() {
2193 let current = CheckpointTip::new(8, LogHash::digest(&[b"current"]));
2194 let older = CheckpointTip::new(7, LogHash::digest(&[b"older"]));
2195 let state = Mutex::new(CoordinatorState {
2196 durable_tip: current,
2197 committed_index: 8,
2198 pending_lag: None,
2199 health: DurabilityHealth::Available,
2200 });
2201
2202 assert!(matches!(
2203 observe_durable_tip(&state, older),
2204 Err(DurabilityError::SnapshotVerification(_))
2205 ));
2206 assert_eq!(state.lock().unwrap().durable_tip, current);
2207 }
2208
2209 #[test]
2210 fn concurrent_conflicting_checkpoint_observations_accept_exactly_one_tip() {
2211 let state = Arc::new(Mutex::new(CoordinatorState {
2212 durable_tip: CheckpointTip::new(0, LogHash::ZERO),
2213 committed_index: 0,
2214 pending_lag: None,
2215 health: DurabilityHealth::Available,
2216 }));
2217 let start = Arc::new(Barrier::new(3));
2218 let results = [b"first".as_slice(), b"second".as_slice()]
2219 .into_iter()
2220 .map(|label| {
2221 let state = Arc::clone(&state);
2222 let start = Arc::clone(&start);
2223 std::thread::spawn(move || {
2224 let tip = CheckpointTip::new(1, LogHash::digest(&[label]));
2225 start.wait();
2226 (tip, observe_durable_tip(&state, tip))
2227 })
2228 })
2229 .collect::<Vec<_>>();
2230 start.wait();
2231 let results = results
2232 .into_iter()
2233 .map(|thread| thread.join().unwrap())
2234 .collect::<Vec<_>>();
2235
2236 assert_eq!(
2237 results.iter().filter(|(_, result)| result.is_ok()).count(),
2238 1
2239 );
2240 assert_eq!(
2241 results.iter().filter(|(_, result)| result.is_err()).count(),
2242 1
2243 );
2244 let accepted = results
2245 .iter()
2246 .find_map(|(tip, result)| result.is_ok().then_some(*tip))
2247 .unwrap();
2248 assert_eq!(state.lock().unwrap().durable_tip, accepted);
2249 }
2250
2251 #[test]
2252 fn durable_progress_clears_lag_only_after_reaching_the_committed_index() {
2253 let mut state = CoordinatorState {
2254 durable_tip: CheckpointTip::new(2, LogHash::digest(&[b"two"])),
2255 committed_index: 4,
2256 pending_lag: Some(PendingLag::Recovered),
2257 health: DurabilityHealth::Unavailable,
2258 };
2259 let partial = CheckpointTip::new(3, LogHash::digest(&[b"three"]));
2260 mark_durable_state(&mut state, partial);
2261 assert!(state.pending_lag.is_some());
2262 assert_eq!(state.health, DurabilityHealth::Unavailable);
2263
2264 let complete = CheckpointTip::new(4, LogHash::digest(&[b"four"]));
2265 mark_durable_state(&mut state, complete);
2266
2267 assert_eq!(state.durable_tip, complete);
2268 assert!(state.pending_lag.is_none());
2269 assert_eq!(state.health, DurabilityHealth::Available);
2270 }
2271}