Skip to main content

reddb_server/cluster/
archive_recovery.rs

1//! Archive-backed range recovery for disaster failover.
2//!
3//! A compressed archive copy is not a hot mirror and cannot become a range owner
4//! directly. This module models the explicit recovery path in between: restore
5//! the archive payload, validate its checksum, prove which range commit
6//! watermark the restored copy covers, and only then delegate the ownership move
7//! to the forced-transition path that records the disaster-recovery audit.
8
9use super::identity::NodeIdentity;
10use super::ownership::{CollectionId, RangeId, ShardOwnershipCatalog};
11use super::ownership_force::{
12    force_transition, ForceTransitionCapability, ForcedTransitionAudit,
13    ForcedTransitionDisposition, ForcedTransitionRequest, OperatorReason,
14};
15use super::ownership_transition::CommitWatermark;
16
17/// Whether archive recovery must prove full watermark coverage or may proceed
18/// while reporting an RPO gap.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ArchiveRecoveryMode {
21    /// Refuse recovery unless the restored archive covers the latest committed
22    /// watermark. Successful recovery is zero-RPO.
23    RequireFullWatermark,
24    /// Proceed through the forced ownership path even when the restored archive
25    /// is behind, but report the skipped committed range as RPO evidence.
26    ForceWithEvidence,
27}
28
29/// Compressed archive data for one range.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ArchivedRangeReplica {
32    collection: CollectionId,
33    range_id: RangeId,
34    archive_id: String,
35    compressed_bytes: Vec<u8>,
36    expected_checksum: u32,
37    covered_watermark: CommitWatermark,
38}
39
40impl ArchivedRangeReplica {
41    pub fn new(
42        collection: CollectionId,
43        range_id: RangeId,
44        archive_id: impl Into<String>,
45        compressed_bytes: Vec<u8>,
46        expected_checksum: u32,
47        covered_watermark: CommitWatermark,
48    ) -> Self {
49        Self {
50            collection,
51            range_id,
52            archive_id: archive_id.into(),
53            compressed_bytes,
54            expected_checksum,
55            covered_watermark,
56        }
57    }
58
59    /// Restore the compressed payload and validate the checksum before it can be
60    /// used as a recovered range seed.
61    pub fn restore(&self) -> Result<RestoredArchiveReplica, ArchiveRecoveryError> {
62        let seed_bytes = zstd::stream::decode_all(self.compressed_bytes.as_slice())
63            .map_err(|err| ArchiveRecoveryError::RestoreFailed(err.to_string()))?;
64        let computed_checksum = crc32fast::hash(&seed_bytes);
65        if computed_checksum != self.expected_checksum {
66            return Err(ArchiveRecoveryError::ChecksumMismatch {
67                archive_id: self.archive_id.clone(),
68                expected: self.expected_checksum,
69                computed: computed_checksum,
70            });
71        }
72        Ok(RestoredArchiveReplica {
73            collection: self.collection.clone(),
74            range_id: self.range_id,
75            archive_id: self.archive_id.clone(),
76            seed_bytes,
77            checksum: computed_checksum,
78            covered_watermark: self.covered_watermark,
79        })
80    }
81}
82
83/// A range seed that exists only after an explicit restore and checksum
84/// validation step.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RestoredArchiveReplica {
87    collection: CollectionId,
88    range_id: RangeId,
89    archive_id: String,
90    seed_bytes: Vec<u8>,
91    checksum: u32,
92    covered_watermark: CommitWatermark,
93}
94
95impl RestoredArchiveReplica {
96    pub fn archive_id(&self) -> &str {
97        &self.archive_id
98    }
99
100    pub fn seed_bytes(&self) -> &[u8] {
101        &self.seed_bytes
102    }
103
104    pub fn checksum(&self) -> u32 {
105        self.checksum
106    }
107
108    pub fn covered_watermark(&self) -> CommitWatermark {
109        self.covered_watermark
110    }
111}
112
113/// Request to recover a range from a restored archive seed and move ownership to
114/// the recovered target.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ArchiveRecoveryRequest {
117    collection: CollectionId,
118    range_id: RangeId,
119    target: NodeIdentity,
120    new_replicas: Vec<NodeIdentity>,
121    latest_commit_watermark: CommitWatermark,
122    mode: ArchiveRecoveryMode,
123    capability: ForceTransitionCapability,
124    reason: OperatorReason,
125    restored: Option<RestoredArchiveReplica>,
126}
127
128impl ArchiveRecoveryRequest {
129    pub fn new(
130        collection: CollectionId,
131        range_id: RangeId,
132        target: NodeIdentity,
133        latest_commit_watermark: CommitWatermark,
134        mode: ArchiveRecoveryMode,
135        capability: ForceTransitionCapability,
136        reason: OperatorReason,
137    ) -> Self {
138        Self {
139            collection,
140            range_id,
141            target,
142            new_replicas: Vec::new(),
143            latest_commit_watermark,
144            mode,
145            capability,
146            reason,
147            restored: None,
148        }
149    }
150
151    pub fn with_replicas(mut self, replicas: impl IntoIterator<Item = NodeIdentity>) -> Self {
152        self.new_replicas = replicas.into_iter().collect();
153        self
154    }
155
156    pub fn with_restored_archive(mut self, restored: RestoredArchiveReplica) -> Self {
157        self.restored = Some(restored);
158        self
159    }
160}
161
162/// The RPO gap proved by a forced archive recovery whose restored watermark is
163/// behind the latest committed watermark.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub struct SkippedDataEvidence {
166    pub restored_watermark: CommitWatermark,
167    pub latest_commit_watermark: CommitWatermark,
168    pub skipped_lsn: u64,
169}
170
171/// Operator-facing RPO evidence: the restored archive watermark, latest known
172/// committed watermark, and any forced recovery gap.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct ArchiveRecoveryRpoEvidence {
175    pub restored_watermark: CommitWatermark,
176    pub latest_commit_watermark: CommitWatermark,
177    pub skipped: Option<SkippedDataEvidence>,
178}
179
180impl ArchiveRecoveryRpoEvidence {
181    pub fn is_zero_rpo(&self) -> bool {
182        self.skipped.is_none()
183    }
184
185    pub fn rpo_lsn(&self) -> u64 {
186        self.skipped.map(|skipped| skipped.skipped_lsn).unwrap_or(0)
187    }
188}
189
190/// A successful archive recovery: the validated seed that may initialize the
191/// recovered range, the ownership-force audit, and the RPO evidence.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ArchiveRecoveryOutcome {
194    pub seed: RestoredArchiveReplica,
195    pub audit: ForcedTransitionAudit,
196    pub evidence: ArchiveRecoveryRpoEvidence,
197}
198
199impl ArchiveRecoveryOutcome {
200    pub fn is_zero_rpo(&self) -> bool {
201        self.evidence.is_zero_rpo()
202    }
203
204    pub fn rpo_lsn(&self) -> u64 {
205        self.evidence.rpo_lsn()
206    }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ArchiveRecoveryError {
211    /// Ownership recovery was attempted without first attaching a restored seed.
212    RestoreRequired,
213    /// The compressed archive payload could not be decompressed.
214    RestoreFailed(String),
215    /// The restored bytes do not match the archive manifest checksum.
216    ChecksumMismatch {
217        archive_id: String,
218        expected: u32,
219        computed: u32,
220    },
221    /// The restored seed belongs to a different range than the recovery request.
222    RestoredRangeMismatch {
223        expected_collection: CollectionId,
224        expected_range_id: RangeId,
225        restored_collection: CollectionId,
226        restored_range_id: RangeId,
227    },
228    /// The restored archive is behind the latest committed watermark and the
229    /// request did not opt into forced RPO evidence.
230    WatermarkGap {
231        restored_watermark: CommitWatermark,
232        latest_commit_watermark: CommitWatermark,
233        skipped_lsn: u64,
234    },
235    /// The underlying forced ownership transition was denied or failed.
236    ForcedOwnership(ForcedTransitionAudit),
237}
238
239impl std::fmt::Display for ArchiveRecoveryError {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::RestoreRequired => write!(f, "archive recovery requires an explicit restore step"),
243            Self::RestoreFailed(err) => write!(f, "archive restore failed: {err}"),
244            Self::ChecksumMismatch {
245                archive_id,
246                expected,
247                computed,
248            } => write!(
249                f,
250                "archive {archive_id} checksum mismatch: expected {expected:#010x}, computed {computed:#010x}"
251            ),
252            Self::RestoredRangeMismatch {
253                expected_collection,
254                expected_range_id,
255                restored_collection,
256                restored_range_id,
257            } => write!(
258                f,
259                "restored archive covers {restored_collection}/{restored_range_id}, expected {expected_collection}/{expected_range_id}"
260            ),
261            Self::WatermarkGap {
262                restored_watermark,
263                latest_commit_watermark,
264                skipped_lsn,
265            } => write!(
266                f,
267                "restored archive covers watermark term {} lsn {}, latest committed watermark is term {} lsn {}; forced recovery would skip {skipped_lsn} LSNs",
268                restored_watermark.term,
269                restored_watermark.lsn,
270                latest_commit_watermark.term,
271                latest_commit_watermark.lsn,
272            ),
273            Self::ForcedOwnership(audit) => write!(f, "{audit}"),
274        }
275    }
276}
277
278impl std::error::Error for ArchiveRecoveryError {}
279
280/// Recover a range from a validated archive seed and move ownership to `target`.
281///
282/// The request must carry a [`RestoredArchiveReplica`], which can only be
283/// obtained from [`ArchivedRangeReplica::restore`]. A restored seed that does not
284/// cover the latest committed watermark is refused unless the caller explicitly
285/// opts into [`ArchiveRecoveryMode::ForceWithEvidence`], in which case the
286/// returned outcome reports the RPO gap.
287pub fn recover_archive_replica(
288    catalog: &mut ShardOwnershipCatalog,
289    request: &ArchiveRecoveryRequest,
290    now_ms: u64,
291) -> Result<ArchiveRecoveryOutcome, ArchiveRecoveryError> {
292    let restored = request
293        .restored
294        .clone()
295        .ok_or(ArchiveRecoveryError::RestoreRequired)?;
296
297    if restored.collection != request.collection || restored.range_id != request.range_id {
298        return Err(ArchiveRecoveryError::RestoredRangeMismatch {
299            expected_collection: request.collection.clone(),
300            expected_range_id: request.range_id,
301            restored_collection: restored.collection,
302            restored_range_id: restored.range_id,
303        });
304    }
305
306    let skipped = if watermark_covers(restored.covered_watermark, request.latest_commit_watermark) {
307        None
308    } else {
309        let skipped_lsn = skipped_lsn(restored.covered_watermark, request.latest_commit_watermark);
310        match request.mode {
311            ArchiveRecoveryMode::RequireFullWatermark => {
312                return Err(ArchiveRecoveryError::WatermarkGap {
313                    restored_watermark: restored.covered_watermark,
314                    latest_commit_watermark: request.latest_commit_watermark,
315                    skipped_lsn,
316                });
317            }
318            ArchiveRecoveryMode::ForceWithEvidence => Some(SkippedDataEvidence {
319                restored_watermark: restored.covered_watermark,
320                latest_commit_watermark: request.latest_commit_watermark,
321                skipped_lsn,
322            }),
323        }
324    };
325
326    let force_request = ForcedTransitionRequest::new(
327        request.collection.clone(),
328        request.range_id,
329        request.target.clone(),
330    )
331    .with_replicas(request.new_replicas.clone())
332    .with_capability(request.capability.clone())
333    .with_reason(request.reason.clone());
334    let audit = force_transition(catalog, &force_request, now_ms);
335    if !matches!(
336        audit.disposition(),
337        ForcedTransitionDisposition::Allowed { .. }
338    ) {
339        return Err(ArchiveRecoveryError::ForcedOwnership(audit));
340    }
341
342    Ok(ArchiveRecoveryOutcome {
343        seed: restored.clone(),
344        audit,
345        evidence: ArchiveRecoveryRpoEvidence {
346            restored_watermark: restored.covered_watermark,
347            latest_commit_watermark: request.latest_commit_watermark,
348            skipped,
349        },
350    })
351}
352
353fn watermark_covers(restored: CommitWatermark, latest: CommitWatermark) -> bool {
354    restored.term > latest.term || (restored.term == latest.term && restored.lsn >= latest.lsn)
355}
356
357fn skipped_lsn(restored: CommitWatermark, latest: CommitWatermark) -> u64 {
358    if restored.term == latest.term {
359        latest.lsn.saturating_sub(restored.lsn)
360    } else {
361        latest.lsn
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::cluster::ownership::{
369        OwnershipEpoch, PlacementMetadata, RangeBounds, RangeOwnership, ShardKeyMode,
370    };
371
372    fn collection(name: &str) -> CollectionId {
373        CollectionId::new(name).unwrap()
374    }
375
376    fn ident(cn: &str) -> NodeIdentity {
377        NodeIdentity::from_certificate_subject(cn).unwrap()
378    }
379
380    fn catalog_with(owner: &str) -> (ShardOwnershipCatalog, CollectionId) {
381        let orders = collection("orders");
382        let mut catalog = ShardOwnershipCatalog::new();
383        catalog
384            .apply_update(RangeOwnership::establish(
385                orders.clone(),
386                RangeId::new(1),
387                ShardKeyMode::Hash,
388                RangeBounds::full(),
389                ident(owner),
390                Vec::new(),
391                PlacementMetadata::with_replication_factor(3),
392            ))
393            .unwrap();
394        (catalog, orders)
395    }
396
397    fn capability() -> ForceTransitionCapability {
398        ForceTransitionCapability::granted_to(ident("CN=operator-root"))
399    }
400
401    fn reason() -> OperatorReason {
402        OperatorReason::new("recover range from validated archive replica").unwrap()
403    }
404
405    fn compressed_archive(
406        orders: &CollectionId,
407        payload: &[u8],
408        covered_watermark: CommitWatermark,
409    ) -> ArchivedRangeReplica {
410        let compressed = zstd::stream::encode_all(&payload[..], 0).unwrap();
411        ArchivedRangeReplica::new(
412            orders.clone(),
413            RangeId::new(1),
414            "archive-2026-06-29T08",
415            compressed,
416            crc32fast::hash(payload),
417            covered_watermark,
418        )
419    }
420
421    fn request(
422        orders: &CollectionId,
423        latest_commit_watermark: CommitWatermark,
424        mode: ArchiveRecoveryMode,
425    ) -> ArchiveRecoveryRequest {
426        ArchiveRecoveryRequest::new(
427            orders.clone(),
428            RangeId::new(1),
429            ident("CN=node-z"),
430            latest_commit_watermark,
431            mode,
432            capability(),
433            reason(),
434        )
435    }
436
437    #[test]
438    fn valid_restored_archive_moves_ownership_and_reports_zero_rpo() {
439        let (mut catalog, orders) = catalog_with("CN=node-a");
440        let archive = compressed_archive(&orders, b"range seed", CommitWatermark::new(7, 200));
441        let restored = archive.restore().expect("restore validates checksum");
442        let req = request(
443            &orders,
444            CommitWatermark::new(7, 200),
445            ArchiveRecoveryMode::RequireFullWatermark,
446        )
447        .with_restored_archive(restored);
448
449        let outcome = recover_archive_replica(&mut catalog, &req, 1_000)
450            .expect("validated archive recovery should move ownership");
451
452        assert_eq!(
453            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
454            &ident("CN=node-z")
455        );
456        assert_eq!(
457            catalog
458                .range(&orders, RangeId::new(1))
459                .unwrap()
460                .epoch()
461                .value(),
462            OwnershipEpoch::initial().value() + 1
463        );
464        assert!(outcome.audit.is_allowed());
465        assert!(outcome.is_zero_rpo());
466        assert_eq!(outcome.rpo_lsn(), 0);
467        assert_eq!(outcome.seed.seed_bytes(), b"range seed");
468        assert_eq!(
469            outcome.evidence.restored_watermark,
470            CommitWatermark::new(7, 200)
471        );
472    }
473
474    #[test]
475    fn recovery_requires_explicit_restore_before_ownership_moves() {
476        let (mut catalog, orders) = catalog_with("CN=node-a");
477        let req = request(
478            &orders,
479            CommitWatermark::new(7, 200),
480            ArchiveRecoveryMode::RequireFullWatermark,
481        );
482
483        let err = recover_archive_replica(&mut catalog, &req, 1_000).unwrap_err();
484
485        assert_eq!(err, ArchiveRecoveryError::RestoreRequired);
486        assert_eq!(
487            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
488            &ident("CN=node-a")
489        );
490    }
491
492    #[test]
493    fn restored_archive_checksum_failure_cannot_seed_recovery() {
494        let (_catalog, orders) = catalog_with("CN=node-a");
495        let payload = b"range seed";
496        let compressed = zstd::stream::encode_all(&payload[..], 0).unwrap();
497        let archive = ArchivedRangeReplica::new(
498            orders,
499            RangeId::new(1),
500            "archive-corrupt",
501            compressed,
502            crc32fast::hash(b"different seed"),
503            CommitWatermark::new(7, 200),
504        );
505
506        let err = archive.restore().unwrap_err();
507
508        match err {
509            ArchiveRecoveryError::ChecksumMismatch {
510                archive_id,
511                expected,
512                computed,
513            } => {
514                assert_eq!(archive_id, "archive-corrupt");
515                assert_eq!(expected, crc32fast::hash(b"different seed"));
516                assert_eq!(computed, crc32fast::hash(payload));
517            }
518            other => panic!("expected checksum mismatch, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn watermark_gap_without_force_refuses_recovery_and_leaves_owner() {
524        let (mut catalog, orders) = catalog_with("CN=node-a");
525        let archive = compressed_archive(&orders, b"range seed", CommitWatermark::new(7, 170));
526        let restored = archive.restore().unwrap();
527        let req = request(
528            &orders,
529            CommitWatermark::new(7, 200),
530            ArchiveRecoveryMode::RequireFullWatermark,
531        )
532        .with_restored_archive(restored);
533
534        let err = recover_archive_replica(&mut catalog, &req, 1_000).unwrap_err();
535
536        assert_eq!(
537            err,
538            ArchiveRecoveryError::WatermarkGap {
539                restored_watermark: CommitWatermark::new(7, 170),
540                latest_commit_watermark: CommitWatermark::new(7, 200),
541                skipped_lsn: 30,
542            }
543        );
544        assert_eq!(
545            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
546            &ident("CN=node-a")
547        );
548    }
549
550    #[test]
551    fn forced_archive_recovery_reports_rpo_and_skipped_data_evidence() {
552        let (mut catalog, orders) = catalog_with("CN=node-a");
553        let archive = compressed_archive(&orders, b"range seed", CommitWatermark::new(7, 170));
554        let restored = archive.restore().unwrap();
555        let req = request(
556            &orders,
557            CommitWatermark::new(7, 200),
558            ArchiveRecoveryMode::ForceWithEvidence,
559        )
560        .with_restored_archive(restored);
561
562        let outcome = recover_archive_replica(&mut catalog, &req, 2_000)
563            .expect("forced recovery should move ownership with evidence");
564
565        assert_eq!(
566            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
567            &ident("CN=node-z")
568        );
569        assert!(!outcome.is_zero_rpo());
570        assert_eq!(outcome.rpo_lsn(), 30);
571        assert_eq!(
572            outcome.evidence.skipped,
573            Some(SkippedDataEvidence {
574                restored_watermark: CommitWatermark::new(7, 170),
575                latest_commit_watermark: CommitWatermark::new(7, 200),
576                skipped_lsn: 30,
577            })
578        );
579        assert!(outcome.audit.to_string().contains("ALLOWED"));
580    }
581}