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 .fetch_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 authorized = self
417 .checkpoint_authority()
418 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
419 .authorize_recovery_release(publisher, terminal)
420 .await
421 .map_err(RecoveryControlError::from_authority)?;
422 if !authorized {
423 return Ok(None);
424 }
425 if !self.recovery_fault_publisher_is_current(publisher).await? {
426 return Err(RecoveryControlError::Superseded(
427 "stable node process lease changed while authorizing recovery Release".into(),
428 ));
429 }
430 Ok(Some(RecoveryReleaseGuard {
431 _write_guard: guard,
432 }))
433 }
434
435 pub async fn read_local_fault_report_control(
440 &self,
441 ) -> Result<Option<u64>, RecoveryControlError> {
442 Ok(self
443 .read_local_recovery_fault_control()
444 .await?
445 .map(|fault| fault.sequence))
446 }
447
448 pub async fn read_local_recovery_fault_control(
453 &self,
454 ) -> Result<Option<RecoveryFault>, RecoveryControlError> {
455 Ok(self
456 .read_recovery_fault_inventory_control()
457 .await?
458 .faults
459 .into_iter()
460 .find(|fault| fault.reporter == self.instance_id))
461 }
462
463 pub async fn read_local_fault_report(&self) -> Result<Option<u64>, String> {
468 self.read_local_fault_report_control()
469 .await
470 .map_err(|error| error.to_string())
471 }
472
473 pub async fn read_fault_reports(&self) -> Result<Vec<(NodeId, u64)>, String> {
478 Ok(self
479 .read_recovery_fault_inventory()
480 .await?
481 .faults
482 .into_iter()
483 .map(|fault| (fault.reporter, fault.sequence))
484 .collect())
485 }
486
487 pub(super) async fn audit_recovery_faults_control(
488 &self,
489 round: &RecoveryRound,
490 ) -> Result<(), RecoveryControlError> {
491 let inventory = self.read_recovery_fault_inventory_control().await?;
492 if inventory.revision == round.fault_revision && inventory.faults == round.faults {
493 Ok(())
494 } else {
495 Err(RecoveryControlError::Superseded(
496 "recovery fault set changed before Release commit".into(),
497 ))
498 }
499 }
500
501 pub(super) async fn audit_pending_release_faults_control(
502 &self,
503 release: &RecoveryAnnouncement,
504 ) -> Result<(), RecoveryControlError> {
505 let release_id =
506 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
507 let mut cached = self.pending_release_fault_audit.lock().await;
508 let now = tokio::time::Instant::now();
509 if cached
510 .as_ref()
511 .is_some_and(|(audited, valid_until)| audited == &release_id && now < *valid_until)
512 {
513 return Ok(());
514 }
515 self.audit_recovery_faults_control(&release.round).await?;
516 *cached = Some((
517 release_id,
518 tokio::time::Instant::now() + PENDING_RELEASE_FAULT_AUDIT_INTERVAL,
519 ));
520 Ok(())
521 }
522
523 pub async fn announce_recovered(&self, start: &RecoveryAnnouncement) -> Result<(), String> {
528 start.validate()?;
529 if !matches!(start.phase, RecoverPhase::Start { .. }) {
530 return Err("restore acknowledgement must bind a Start target".into());
531 }
532 if !start.round.contains_owner(self.instance_id) {
533 return Err("node outside recovery quorum cannot acknowledge restore".into());
534 }
535 if start.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
536 return Err("restore acknowledgement has a stale local process incarnation".into());
537 }
538 if !self.recovery_incarnation_is_current().await? {
539 return Err("restore acknowledgement came from a superseded local process".into());
540 }
541 let encoded = serde_json::to_string(&RecoveryAnnouncementAck {
542 announcement: start.clone(),
543 incarnation: self.recovery_incarnation,
544 })
545 .map_err(|error| format!("could not encode recovery ack: {error}"))?;
546 self.write_recovery_value_exact("control:recovered", encoded)
547 .await
548 }
549
550 pub async fn read_recovered(&self) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
555 self.read_recovery_announcement_map("control:recovered")
556 .await
557 }
558
559 pub async fn announce_release_ready(
567 &self,
568 release: &RecoveryAnnouncement,
569 ) -> Result<(), RecoveryControlError> {
570 let release_id =
571 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
572 if release.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
573 return Err(RecoveryControlError::Superseded(
574 "release readiness has a stale local process incarnation".into(),
575 ));
576 }
577 if !self
578 .recovery_incarnation_is_current()
579 .await
580 .map_err(RecoveryControlError::Uncertain)?
581 {
582 return Err(RecoveryControlError::Superseded(
583 "release readiness came from a superseded local process".into(),
584 ));
585 }
586 if self.read_local_fault_report_control().await?
587 != release.round.fault_sequence(self.instance_id)
588 {
589 return Err(RecoveryControlError::Superseded(
590 "local fault set changed before release readiness".into(),
591 ));
592 }
593 match self.observe_recover_control().await? {
594 Some(active) if active == *release => {}
595 _ => {
596 return Err(RecoveryControlError::Superseded(
597 "release readiness no longer matches the active intent".into(),
598 ));
599 }
600 }
601 let participant = CheckpointParticipant {
602 node_id: self.instance_id.0,
603 boot_incarnation: self.recovery_incarnation,
604 };
605 let encoded = encode_release_ready_ack(&RecoveryReleaseReadyAck {
606 release: release_id,
607 participant,
608 })
609 .map_err(RecoveryControlError::Conflict)?;
610 self.write_recovery_value_exact(RELEASE_READY_ACK_KEY, encoded)
611 .await
612 .map_err(RecoveryControlError::Uncertain)
613 }
614
615 pub(super) async fn read_release_ready(
623 &self,
624 release: &RecoveryAnnouncement,
625 ) -> Result<ReleaseReadyStatus, RecoveryControlError> {
626 let expected =
627 RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
628 let reads = futures::stream::iter(
629 release
630 .round
631 .assignment_fence
632 .participants
633 .iter()
634 .copied()
635 .map(|participant| async move {
636 let value = self
637 .read_recovery_value(NodeId(participant.node_id), RELEASE_READY_ACK_KEY)
638 .await;
639 (participant, value)
640 }),
641 )
642 .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
643 .collect::<Vec<_>>()
644 .await;
645 let mut reads = reads;
646 reads.sort_unstable_by_key(|(participant, _)| participant.node_id);
647 let mut missing = Vec::new();
648 for (participant, value) in reads {
649 let raw = value.map_err(RecoveryControlError::Uncertain)?;
650 let Some(raw) = raw else {
651 missing.push(NodeId(participant.node_id));
652 continue;
653 };
654 let ack = parse_release_ready_ack(&raw, NodeId(participant.node_id))
655 .map_err(RecoveryControlError::Conflict)?;
656 if ack.participant != participant {
657 return Err(RecoveryControlError::Conflict(format!(
658 "release readiness from {} does not match the frozen process",
659 participant.node_id
660 )));
661 }
662 if ack.release != expected {
663 if ack.release.generation < expected.generation {
664 missing.push(NodeId(participant.node_id));
665 continue;
666 }
667 if ack.release.generation > expected.generation {
668 return Err(RecoveryControlError::Superseded(format!(
669 "release readiness from {} has newer generation {}",
670 participant.node_id, ack.release.generation
671 )));
672 }
673 return Err(RecoveryControlError::Conflict(format!(
674 "release readiness from {} conflicts with generation {}",
675 participant.node_id, expected.generation
676 )));
677 }
678 }
679 if !missing.is_empty() {
680 return Ok(ReleaseReadyStatus::Pending { missing });
681 }
682 Ok(ReleaseReadyStatus::Complete)
683 }
684}