laminar_core/cluster/control/controller/
recovery_faults.rs1use super::*;
4
5impl ClusterController {
6 pub fn next_recovery_fault_request(&self) -> Result<RecoveryFaultRequest, String> {
14 let sequence = self
15 .recovery_fault_request_sequence
16 .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
17 current.checked_add(1)
18 })
19 .map_err(|_| "recovery fault request sequence exhausted".to_string())?;
20 Ok(RecoveryFaultRequest {
21 sequence: std::num::NonZeroU64::new(sequence)
22 .ok_or_else(|| "recovery fault request allocator produced zero".to_string())?,
23 })
24 }
25
26 pub fn recovery_fault_request(&self, sequence: u64) -> Result<RecoveryFaultRequest, String> {
31 let sequence = std::num::NonZeroU64::new(sequence)
32 .ok_or_else(|| "recovery fault request sequence must be nonzero".to_string())?;
33 if sequence.get() >= self.recovery_fault_request_sequence.load(Ordering::Acquire) {
34 return Err("recovery fault request was not allocated by this process".into());
35 }
36 Ok(RecoveryFaultRequest { sequence })
37 }
38
39 pub(super) fn recovery_fault_publisher(&self) -> Result<RecoveryFaultPublisher, String> {
40 let publisher = RecoveryFaultPublisher {
41 participant: CheckpointParticipant {
42 node_id: self.instance_id.0,
43 boot_incarnation: self.recovery_incarnation,
44 },
45 process_term: self.recovery_process_term.load(Ordering::Acquire),
46 };
47 publisher.validate()?;
48 Ok(publisher)
49 }
50
51 pub(super) fn capture_live_local_process_authority(
52 &self,
53 ) -> Result<LocalProcessAuthorityIdentity, String> {
54 let _transition = self.process_authority_transition.lock();
55 self.capture_live_local_process_authority_locked()
56 }
57
58 pub(super) fn capture_live_local_process_authority_locked(
59 &self,
60 ) -> Result<LocalProcessAuthorityIdentity, String> {
61 if !self.recovery_process_lease_is_live() {
62 return Err("local process lease authority is not live".into());
63 }
64 let publisher = self.recovery_fault_publisher()?;
65 if !self.recovery_process_lease_is_live() {
66 return Err("local process lease authority changed while sampling identity".into());
67 }
68 Ok(LocalProcessAuthorityIdentity {
69 participant: publisher.participant,
70 process_term: publisher.process_term,
71 })
72 }
73
74 pub(super) async fn recovery_fault_publisher_is_current(
75 &self,
76 publisher: RecoveryFaultPublisher,
77 ) -> Result<bool, RecoveryControlError> {
78 if !self.recovery_process_lease_is_live() {
79 return Ok(false);
80 }
81 let authority = self.process_lease_authority.get().ok_or_else(|| {
82 RecoveryControlError::Conflict("process lease authority is not installed".into())
83 })?;
84 let deadline = tokio::time::Instant::now()
85 .checked_add(RECOVERY_CONTROL_IO_TIMEOUT)
86 .ok_or_else(|| {
87 RecoveryControlError::Conflict(
88 "recovery fault process-lease deadline overflow".into(),
89 )
90 })?;
91 let current = authority
92 .verify_current_participant_term(
93 publisher.participant,
94 publisher.process_term,
95 deadline,
96 )
97 .await
98 .map_err(RecoveryControlError::from_process_authority)?;
99 Ok(current && self.recovery_process_lease_is_live())
100 }
101
102 pub(super) async fn recovery_incarnation_is_current(&self) -> Result<bool, String> {
103 let Some(raw) = self
104 .read_recovery_value(self.instance_id, RECOVERY_INCARNATION_KEY)
105 .await?
106 else {
107 return Ok(false);
108 };
109 let observed = Uuid::parse_str(&raw)
110 .map_err(|error| format!("invalid local recovery incarnation: {error}"))?;
111 Ok(observed == self.recovery_incarnation)
112 }
113
114 pub async fn recovery_participant_incarnations(
119 &self,
120 participants: &[u64],
121 ) -> Result<Vec<CheckpointParticipant>, String> {
122 let available = self
123 .available_recovery_participant_incarnations(participants)
124 .await?;
125 if available.len() != participants.len() {
126 let available_ids: std::collections::BTreeSet<u64> = available
127 .iter()
128 .map(|participant| participant.node_id)
129 .collect();
130 let missing = participants
131 .iter()
132 .find(|node_id| !available_ids.contains(node_id))
133 .copied()
134 .unwrap_or(0);
135 return Err(format!(
136 "node {missing} has no current recovery incarnation"
137 ));
138 }
139 Ok(available)
140 }
141
142 pub async fn available_recovery_participant_incarnations(
149 &self,
150 participants: &[u64],
151 ) -> Result<Vec<CheckpointParticipant>, String> {
152 if participants.is_empty()
153 || participants.contains(&0)
154 || participants.windows(2).any(|pair| pair[0] >= pair[1])
155 {
156 return Err("recovery incarnation roster requires canonical participants".into());
157 }
158 let expected: std::collections::BTreeSet<u64> = participants.iter().copied().collect();
159 let mut reported = std::collections::BTreeMap::new();
160 for (node, raw) in self.scan_recovery_values(RECOVERY_INCARNATION_KEY).await? {
161 if !expected.contains(&node.0) {
162 continue;
163 }
164 let incarnation = Uuid::parse_str(&raw).map_err(|error| {
165 format!("invalid recovery incarnation published by {node}: {error}")
166 })?;
167 if incarnation.is_nil() {
168 return Err(format!("nil recovery incarnation published by {node}"));
169 }
170 if reported.insert(node.0, incarnation).is_some() {
171 return Err(format!(
172 "duplicate recovery incarnation published by {node}"
173 ));
174 }
175 }
176 Ok(participants
177 .iter()
178 .filter_map(|node_id| {
179 reported
180 .get(node_id)
181 .copied()
182 .map(|incarnation| CheckpointParticipant {
183 node_id: *node_id,
184 boot_incarnation: incarnation,
185 })
186 })
187 .collect())
188 }
189
190 pub async fn recovery_incarnations_match(&self, round: &RecoveryRound) -> Result<bool, String> {
195 Ok(self
196 .recovery_participant_incarnations(&round.assignment_fence.participant_ids())
197 .await?
198 == round.assignment_fence.participants)
199 }
200
201 pub async fn recovery_stopped_incarnations_match(
209 &self,
210 round: &RecoveryRound,
211 ) -> Result<bool, RecoveryControlError> {
212 self.recovery_roster_incarnations_match_control(&round.stopped_roster())
213 .await
214 }
215
216 pub(super) async fn recovery_incarnations_match_control(
217 &self,
218 round: &RecoveryRound,
219 ) -> Result<bool, RecoveryControlError> {
220 self.recovery_roster_incarnations_match_control(&round.assignment_fence.participants)
221 .await
222 }
223
224 pub(super) async fn recovery_roster_incarnations_match_control(
225 &self,
226 roster: &[CheckpointParticipant],
227 ) -> Result<bool, RecoveryControlError> {
228 let expected: std::collections::BTreeSet<u64> = roster
229 .iter()
230 .map(|participant| participant.node_id)
231 .collect();
232 let mut reported = std::collections::BTreeMap::new();
233 for (node, raw) in self
234 .scan_recovery_values(RECOVERY_INCARNATION_KEY)
235 .await
236 .map_err(RecoveryControlError::Uncertain)?
237 {
238 if !expected.contains(&node.0) {
239 continue;
240 }
241 let incarnation = Uuid::parse_str(&raw).map_err(|error| {
242 RecoveryControlError::Conflict(format!(
243 "invalid recovery incarnation published by {node}: {error}"
244 ))
245 })?;
246 if incarnation.is_nil() || reported.insert(node.0, incarnation).is_some() {
247 return Err(RecoveryControlError::Conflict(format!(
248 "noncanonical recovery incarnation published by {node}"
249 )));
250 }
251 }
252 if reported.len() != roster.len() {
253 return Ok(false);
254 }
255 Ok(roster.iter().all(|participant| {
256 reported.get(&participant.node_id) == Some(&participant.boot_incarnation)
257 }))
258 }
259
260 pub(super) async fn read_recovery_fault_inventory_control(
261 &self,
262 ) -> Result<RecoveryFaultInventory, RecoveryControlError> {
263 self.checkpoint_authority()
264 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
265 .recovery_fault_inventory()
266 .await
267 .map_err(RecoveryControlError::from_authority)
268 }
269
270 pub async fn read_recovery_fault_inventory(&self) -> Result<RecoveryFaultInventory, String> {
275 self.read_recovery_fault_inventory_control()
276 .await
277 .map_err(|error| error.to_string())
278 }
279
280 pub async fn read_recovery_admission_snapshot(
285 &self,
286 ) -> Result<RecoveryAdmissionSnapshot, RecoveryControlError> {
287 self.checkpoint_authority()
288 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
289 .recovery_admission_snapshot()
290 .await
291 .map_err(RecoveryControlError::from_authority)
292 }
293
294 pub async fn recovery_admission_is_current(
300 &self,
301 snapshot: &RecoveryAdmissionSnapshot,
302 leader_proof: &LeaderProof,
303 ) -> Result<bool, RecoveryControlError> {
304 self.checkpoint_authority()
305 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
306 .recovery_admission_is_current(snapshot, leader_proof)
307 .await
308 .map_err(RecoveryControlError::from_authority)
309 }
310
311 pub async fn report_fault(
316 &self,
317 request: RecoveryFaultRequest,
318 ) -> Result<RecoveryFaultReportOutcome, String> {
319 self.report_fault_with_disposition(request, RecoveryFaultDisposition::Recoverable)
320 .await
321 }
322
323 pub async fn report_terminal_fault(
331 &self,
332 request: RecoveryFaultRequest,
333 ) -> Result<RecoveryFaultReportOutcome, String> {
334 self.report_fault_with_disposition(request, RecoveryFaultDisposition::Terminal)
335 .await
336 }
337
338 pub(super) async fn report_fault_with_disposition(
339 &self,
340 request: RecoveryFaultRequest,
341 disposition: RecoveryFaultDisposition,
342 ) -> Result<RecoveryFaultReportOutcome, String> {
343 let seq = request.sequence();
344 let _guard = self.recovery_writes.lock().await;
345 let publisher = self.recovery_fault_publisher()?;
346 if !self
347 .recovery_fault_publisher_is_current(publisher)
348 .await
349 .map_err(|error| error.to_string())?
350 {
351 return Err("stable node process lease is no longer current".into());
352 }
353 let authority = self
354 .checkpoint_authority()
355 .map_err(|error| error.to_string())?;
356 let result =
357 Box::pin(authority.record_recovery_fault_with_disposition(publisher, seq, disposition))
358 .await
359 .map_err(|error| RecoveryControlError::from_authority(error).to_string())?;
360 if !self
361 .recovery_fault_publisher_is_current(publisher)
362 .await
363 .map_err(|error| error.to_string())?
364 {
365 return Err("stable node process lease changed while publishing recovery fault".into());
366 }
367 match result {
368 super::super::leader_lease::RecordRecoveryFaultResult::Active => {
369 Ok(RecoveryFaultReportOutcome::Active)
370 }
371 super::super::leader_lease::RecordRecoveryFaultResult::AlreadyCleared => {
372 Ok(RecoveryFaultReportOutcome::AlreadyCleared)
373 }
374 super::super::leader_lease::RecordRecoveryFaultResult::CoveredByNewerRequest => {
375 Ok(RecoveryFaultReportOutcome::CoveredByNewerRequest)
376 }
377 super::super::leader_lease::RecordRecoveryFaultResult::TerminalFenceActive => {
378 Ok(RecoveryFaultReportOutcome::TerminalFenceActive)
379 }
380 super::super::leader_lease::RecordRecoveryFaultResult::Superseded => {
381 Err("recovery fault request was superseded by a newer local process".into())
382 }
383 }
384 }
385
386 pub async fn begin_recovery_release(
396 &self,
397 terminal: &RecoveryAnnouncement,
398 ) -> Result<Option<RecoveryReleaseGuard<'_>>, RecoveryControlError> {
399 terminal
400 .validate()
401 .map_err(RecoveryControlError::Conflict)?;
402 if !matches!(terminal.phase, RecoverPhase::ReleaseCommitted { .. }) {
403 return Err(RecoveryControlError::Conflict(
404 "recovery fault consumption requires a terminal Release".into(),
405 ));
406 }
407 let guard = self.recovery_writes.lock().await;
408 let publisher = self
409 .recovery_fault_publisher()
410 .map_err(RecoveryControlError::Conflict)?;
411 if !self.recovery_fault_publisher_is_current(publisher).await? {
412 return Err(RecoveryControlError::Superseded(
413 "stable node process lease is no longer current".into(),
414 ));
415 }
416 let authority = self
417 .checkpoint_authority()
418 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
419 let authorized = if terminal.round.topology_binding().is_some() {
420 let context = self
421 .snapshot
422 .as_deref()
423 .zip(self.process_lease_authority.get().map(Arc::as_ref));
424 Box::pin(
425 authority.authorize_recovery_release_with_topology(publisher, terminal, context),
426 )
427 .await
428 } else {
429 Box::pin(authority.authorize_recovery_release(publisher, terminal)).await
430 }
431 .map_err(RecoveryControlError::from_authority)?;
432 if !authorized {
433 return Ok(None);
434 }
435 if !self.recovery_fault_publisher_is_current(publisher).await? {
436 return Err(RecoveryControlError::Superseded(
437 "stable node process lease changed while authorizing recovery Release".into(),
438 ));
439 }
440 Ok(Some(RecoveryReleaseGuard {
441 _write_guard: guard,
442 }))
443 }
444
445 pub async fn read_local_fault_report_control(
450 &self,
451 ) -> Result<Option<u64>, RecoveryControlError> {
452 Ok(self
453 .read_local_recovery_fault_control()
454 .await?
455 .map(|fault| fault.sequence))
456 }
457
458 pub async fn read_local_recovery_fault_control(
463 &self,
464 ) -> Result<Option<RecoveryFault>, RecoveryControlError> {
465 Ok(self
466 .read_recovery_fault_inventory_control()
467 .await?
468 .faults
469 .into_iter()
470 .find(|fault| fault.reporter == self.instance_id))
471 }
472
473 pub async fn read_local_fault_report(&self) -> Result<Option<u64>, String> {
478 self.read_local_fault_report_control()
479 .await
480 .map_err(|error| error.to_string())
481 }
482
483 pub async fn read_fault_reports(&self) -> Result<Vec<(NodeId, u64)>, String> {
488 Ok(self
489 .read_recovery_fault_inventory()
490 .await?
491 .faults
492 .into_iter()
493 .map(|fault| (fault.reporter, fault.sequence))
494 .collect())
495 }
496
497 pub(super) async fn audit_recovery_faults_control(
498 &self,
499 round: &RecoveryRound,
500 ) -> Result<(), RecoveryControlError> {
501 let inventory = self.read_recovery_fault_inventory_control().await?;
502 if inventory.revision == round.fault_revision && inventory.faults == round.faults {
503 Ok(())
504 } else {
505 Err(RecoveryControlError::Superseded(
506 "recovery fault set changed before Release commit".into(),
507 ))
508 }
509 }
510
511 pub(super) async fn audit_pending_release_faults_control(
512 &self,
513 release: &RecoveryAnnouncement,
514 ) -> Result<(), RecoveryControlError> {
515 let release_id =
516 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
517 let mut cached = self.pending_release_fault_audit.lock().await;
518 let now = tokio::time::Instant::now();
519 if cached
520 .as_ref()
521 .is_some_and(|(audited, valid_until)| audited == &release_id && now < *valid_until)
522 {
523 return Ok(());
524 }
525 self.audit_recovery_faults_control(&release.round).await?;
526 *cached = Some((
527 release_id,
528 tokio::time::Instant::now() + PENDING_RELEASE_FAULT_AUDIT_INTERVAL,
529 ));
530 Ok(())
531 }
532
533 pub async fn announce_recovered(&self, start: &RecoveryAnnouncement) -> Result<(), String> {
538 start.validate()?;
539 if !matches!(start.phase, RecoverPhase::Start { .. }) {
540 return Err("restore acknowledgement must bind a Start target".into());
541 }
542 if !start.round.contains_owner(self.instance_id) {
543 return Err("node outside recovery quorum cannot acknowledge restore".into());
544 }
545 if start.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
546 return Err("restore acknowledgement has a stale local process incarnation".into());
547 }
548 if !self.recovery_incarnation_is_current().await? {
549 return Err("restore acknowledgement came from a superseded local process".into());
550 }
551 let encoded = serde_json::to_string(&RecoveryAnnouncementAck {
552 announcement: start.clone(),
553 incarnation: self.recovery_incarnation,
554 })
555 .map_err(|error| format!("could not encode recovery ack: {error}"))?;
556 self.write_recovery_value_exact("control:recovered", encoded)
557 .await
558 }
559
560 pub async fn read_recovered(&self) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
565 self.read_recovery_announcement_map("control:recovered")
566 .await
567 }
568
569 pub async fn announce_release_ready(
577 &self,
578 release: &RecoveryAnnouncement,
579 ) -> Result<(), RecoveryControlError> {
580 let release_id =
581 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
582 if release.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
583 return Err(RecoveryControlError::Superseded(
584 "release readiness has a stale local process incarnation".into(),
585 ));
586 }
587 if !self
588 .recovery_incarnation_is_current()
589 .await
590 .map_err(RecoveryControlError::Uncertain)?
591 {
592 return Err(RecoveryControlError::Superseded(
593 "release readiness came from a superseded local process".into(),
594 ));
595 }
596 if self.read_local_fault_report_control().await?
597 != release.round.fault_sequence(self.instance_id)
598 {
599 return Err(RecoveryControlError::Superseded(
600 "local fault set changed before release readiness".into(),
601 ));
602 }
603 match self.observe_recover_control().await? {
604 Some(active) if active == *release => {}
605 _ => {
606 return Err(RecoveryControlError::Superseded(
607 "release readiness no longer matches the active intent".into(),
608 ));
609 }
610 }
611 let participant = CheckpointParticipant {
612 node_id: self.instance_id.0,
613 boot_incarnation: self.recovery_incarnation,
614 };
615 let encoded = encode_release_ready_ack(&RecoveryReleaseReadyAck {
616 release: release_id,
617 participant,
618 })
619 .map_err(RecoveryControlError::Conflict)?;
620 self.write_recovery_value_exact(RELEASE_READY_ACK_KEY, encoded)
621 .await
622 .map_err(RecoveryControlError::Uncertain)
623 }
624
625 pub(super) async fn read_release_ready(
633 &self,
634 release: &RecoveryAnnouncement,
635 ) -> Result<ReleaseReadyStatus, RecoveryControlError> {
636 let expected =
637 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
638 let reads = futures::stream::iter(
639 release
640 .round
641 .assignment_fence
642 .participants
643 .iter()
644 .copied()
645 .map(|participant| async move {
646 let value = self
647 .read_recovery_value(NodeId(participant.node_id), RELEASE_READY_ACK_KEY)
648 .await;
649 (participant, value)
650 }),
651 )
652 .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
653 .collect::<Vec<_>>()
654 .await;
655 let mut reads = reads;
656 reads.sort_unstable_by_key(|(participant, _)| participant.node_id);
657 let mut missing = Vec::new();
658 for (participant, value) in reads {
659 let raw = value.map_err(RecoveryControlError::Uncertain)?;
660 let Some(raw) = raw else {
661 missing.push(NodeId(participant.node_id));
662 continue;
663 };
664 let ack = parse_release_ready_ack(&raw, NodeId(participant.node_id))
665 .map_err(RecoveryControlError::Conflict)?;
666 if ack.participant != participant {
667 return Err(RecoveryControlError::Conflict(format!(
668 "release readiness from {} does not match the frozen process",
669 participant.node_id
670 )));
671 }
672 if ack.release != expected {
673 if ack.release.generation < expected.generation {
674 missing.push(NodeId(participant.node_id));
675 continue;
676 }
677 if ack.release.generation > expected.generation {
678 return Err(RecoveryControlError::Superseded(format!(
679 "release readiness from {} has newer generation {}",
680 participant.node_id, ack.release.generation
681 )));
682 }
683 return Err(RecoveryControlError::Conflict(format!(
684 "release readiness from {} conflicts with generation {}",
685 participant.node_id, expected.generation
686 )));
687 }
688 }
689 if !missing.is_empty() {
690 return Ok(ReleaseReadyStatus::Pending { missing });
691 }
692 Ok(ReleaseReadyStatus::Complete)
693 }
694}