1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ArchiveRecoveryMode {
21 RequireFullWatermark,
24 ForceWithEvidence,
27}
28
29#[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 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#[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#[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#[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#[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#[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 RestoreRequired,
213 RestoreFailed(String),
215 ChecksumMismatch {
217 archive_id: String,
218 expected: u32,
219 computed: u32,
220 },
221 RestoredRangeMismatch {
223 expected_collection: CollectionId,
224 expected_range_id: RangeId,
225 restored_collection: CollectionId,
226 restored_range_id: RangeId,
227 },
228 WatermarkGap {
231 restored_watermark: CommitWatermark,
232 latest_commit_watermark: CommitWatermark,
233 skipped_lsn: u64,
234 },
235 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
280pub 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}