1use super::*;
4
5impl ClusterController {
6 pub(super) async fn durable_recovery_proof_is_current(
7 &self,
8 proof: &LeaderProof,
9 ) -> Result<bool, String> {
10 if !proof.is_canonical() {
11 return Ok(false);
12 }
13 let authority = self
14 .checkpoint_authority()
15 .map_err(|error| format!("durable recovery authority is unavailable: {error}"))?;
16 let Some(lease) = authority
17 .load()
18 .await
19 .map_err(|error| format!("durable recovery authority read failed: {error}"))?
20 else {
21 return Ok(false);
22 };
23 Ok(lease.matches_proof(proof))
24 }
25
26 pub(super) async fn recovery_driver_proof_is_current(
27 &self,
28 round: &RecoveryRound,
29 ) -> Result<bool, String> {
30 Ok(round.id.driver == self.instance_id
31 && self.proof_is_live(&round.leader_proof)
32 && self
33 .durable_recovery_proof_is_current(&round.leader_proof)
34 .await?)
35 }
36
37 pub(super) async fn require_recovery_driver_proof(
38 &self,
39 round: &RecoveryRound,
40 boundary: &str,
41 ) -> Result<(), String> {
42 if self.recovery_driver_proof_is_current(round).await? {
43 Ok(())
44 } else {
45 Err(format!(
46 "recovery driver proof is no longer live at {boundary}"
47 ))
48 }
49 }
50
51 pub async fn audit_recovery_driver_proof(
60 &self,
61 round: &RecoveryRound,
62 boundary: &str,
63 ) -> Result<(), RecoveryControlError> {
64 if round.id.driver != self.instance_id || !self.proof_is_live(&round.leader_proof) {
65 return Err(RecoveryControlError::Superseded(format!(
66 "recovery driver proof is no longer live at {boundary}"
67 )));
68 }
69 let authority = self
70 .checkpoint_authority()
71 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
72 let current = authority.load().await.map_err(|error| match error {
73 super::super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
74 error => RecoveryControlError::Conflict(error.to_string()),
75 })?;
76 if !current.is_some_and(|lease| lease.matches_proof(&round.leader_proof)) {
77 return Err(RecoveryControlError::Superseded(format!(
78 "durable recovery driver proof changed at {boundary}"
79 )));
80 }
81 if round.id.driver != self.instance_id || !self.proof_is_live(&round.leader_proof) {
82 return Err(RecoveryControlError::Superseded(format!(
83 "recovery driver proof changed while auditing {boundary}"
84 )));
85 }
86 Ok(())
87 }
88
89 pub(super) async fn recovery_evidence_roster_matches(
90 &self,
91 round: &RecoveryRound,
92 ) -> Result<bool, String> {
93 let candidates = round
94 .faults
95 .iter()
96 .filter(|fault| !round.assignment_fence.contains(fault.reporter.0))
97 .map(|fault| fault.reporter.0)
98 .collect::<Vec<_>>();
99 let available = if candidates.is_empty() {
100 Vec::new()
101 } else {
102 self.available_recovery_participant_incarnations(&candidates)
103 .await?
104 };
105 Ok(available == round.evidence_participants)
106 }
107
108 pub async fn announce_recover_prepare(&self, round: &RecoveryRound) -> Result<(), String> {
113 round.validate()?;
114 if round.id.driver != self.instance_id {
115 return Err("only the current leader may prepare its recovery round".into());
116 }
117 self.require_recovery_driver_proof(round, "Prepare preflight")
118 .await?;
119 if !self.recovery_evidence_roster_matches(round).await? {
120 return Err("available recovery evidence roster changed before Prepare".into());
121 }
122 if !self
123 .recovery_stopped_incarnations_match(round)
124 .await
125 .map_err(|error| error.to_string())?
126 {
127 return Err("recovery stopped-process roster changed before Prepare".into());
128 }
129 let _guard = self.recovery_writes.lock().await;
130 self.require_recovery_driver_proof(round, "Prepare publication")
131 .await?;
132 if !self.recovery_evidence_roster_matches(round).await? {
133 return Err("available recovery evidence roster changed during Prepare".into());
134 }
135 if let Some(raw) = self
136 .read_recovery_value(self.instance_id, "control:recover")
137 .await?
138 {
139 if let Some(active) = parse_recovery_announcement(&raw)? {
140 if active.round.has_terminal_fault() && active.round != *round {
141 return Err("active terminal recovery Prepare cannot be superseded".into());
142 }
143 }
144 }
145 let announcement = RecoveryAnnouncement {
146 round: round.clone(),
147 phase: RecoverPhase::Prepare,
148 };
149 let encoded = encode_recovery_announcement(&announcement)?;
150 self.write_recovery_value_exact("control:recover", encoded)
151 .await?;
152 self.require_recovery_driver_proof(round, "Prepare read-back")
153 .await
154 }
155
156 pub async fn announce_recover_start(
162 &self,
163 round: &RecoveryRound,
164 epoch: u64,
165 ) -> Result<(), String> {
166 round.validate()?;
167 if round.has_terminal_fault() {
168 return Err("terminal recovery fault is permanently retained in Prepare".into());
169 }
170 self.audit_recovery_faults_control(round)
171 .await
172 .map_err(|error| error.to_string())?;
173 if round.id.driver != self.instance_id {
174 return Err("only the current leader may start its recovery round".into());
175 }
176 self.require_recovery_driver_proof(round, "Start preflight")
177 .await?;
178 let _guard = self.recovery_writes.lock().await;
179 if !self.recovery_incarnations_match(round).await? {
180 return Err(
181 "recovery driver or process-incarnation roster changed before Start".into(),
182 );
183 }
184 let current = self
185 .read_recovery_value(self.instance_id, "control:recover")
186 .await?
187 .ok_or_else(|| "recovery Prepare disappeared before Start".to_string())?;
188 let prepared = parse_recovery_announcement(¤t)?
189 .ok_or_else(|| "recovery Prepare was cleared before Start".to_string())?;
190 if prepared.round != *round || prepared.phase != RecoverPhase::Prepare {
191 return Err("recovery Start does not match the exact active Prepare".into());
192 }
193 self.audit_recovery_faults_control(round)
194 .await
195 .map_err(|error| error.to_string())?;
196 self.require_recovery_driver_proof(round, "Start publication")
197 .await?;
198 let announcement = RecoveryAnnouncement {
199 round: round.clone(),
200 phase: RecoverPhase::Start { epoch },
201 };
202 let encoded = encode_recovery_announcement(&announcement)?;
203 self.write_recovery_value_exact("control:recover", encoded)
204 .await?;
205 self.require_recovery_driver_proof(round, "Start read-back")
206 .await
207 }
208
209 pub async fn announce_recover_release(
215 &self,
216 round: &RecoveryRound,
217 epoch: u64,
218 ) -> Result<(), String> {
219 round.validate()?;
220 if round.has_terminal_fault() {
221 return Err("terminal recovery fault cannot advance to Release".into());
222 }
223 if round.id.driver != self.instance_id {
224 return Err("only the current leader may release its recovery round".into());
225 }
226 self.require_recovery_driver_proof(round, "Release preflight")
227 .await?;
228 let _guard = self.recovery_writes.lock().await;
229 if !self.recovery_incarnations_match(round).await? {
230 return Err(
231 "recovery driver or process-incarnation roster changed before Release".into(),
232 );
233 }
234 let current = self
235 .read_recovery_value(self.instance_id, "control:recover")
236 .await?
237 .ok_or_else(|| "recovery Start disappeared before Release".to_string())?;
238 let started = parse_recovery_announcement(¤t)?
239 .ok_or_else(|| "recovery Start was cleared before Release".to_string())?;
240 if started.round != *round || started.phase != (RecoverPhase::Start { epoch }) {
241 return Err("recovery Release does not match the exact active Start target".into());
242 }
243 self.require_recovery_driver_proof(round, "Release publication")
244 .await?;
245 let release = RecoveryAnnouncement {
246 round: round.clone(),
247 phase: RecoverPhase::Release { epoch },
248 };
249 let encoded = encode_recovery_announcement(&release)?;
250 self.write_recovery_value_exact("control:recover", encoded)
251 .await?;
252 self.require_recovery_driver_proof(round, "Release read-back")
253 .await
254 }
255
256 pub async fn try_commit_recover_release(
266 &self,
267 release: &RecoveryAnnouncement,
268 ) -> Result<ReleaseCommitStatus, RecoveryControlError> {
269 release.validate().map_err(RecoveryControlError::Conflict)?;
270 let RecoverPhase::Release { epoch } = release.phase else {
271 return Err(RecoveryControlError::Conflict(
272 "release commit must bind a pending Release target".into(),
273 ));
274 };
275 let round = &release.round;
276 if round.id.driver != self.instance_id {
277 return Err(RecoveryControlError::Superseded(
278 "only the current leader may commit its recovery Release".into(),
279 ));
280 }
281 self.audit_recovery_driver_proof(round, "Release commit preflight")
282 .await?;
283 match self.read_release_ready(release).await? {
284 ReleaseReadyStatus::Complete => {}
285 ReleaseReadyStatus::Pending { missing } => {
286 self.audit_pending_release_faults_control(release).await?;
287 return Ok(ReleaseCommitStatus::Pending { missing });
288 }
289 }
290 let committed = RecoveryAnnouncement {
291 round: round.clone(),
292 phase: RecoverPhase::ReleaseCommitted { epoch },
293 };
294 let authority = self
295 .checkpoint_authority()
296 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
297 let reference = authority
298 .stage_recovery_release_terminal(&committed)
299 .await
300 .map_err(RecoveryControlError::from_authority)?;
301
302 let _guard = self.recovery_writes.lock().await;
303 if !self.recovery_incarnations_match_control(round).await? {
304 return Err(RecoveryControlError::Superseded(
305 "recovery process-incarnation roster changed before Release commit".into(),
306 ));
307 }
308 self.audit_recovery_faults_control(round).await?;
309 let Some(current) = self
310 .read_recovery_value(self.instance_id, "control:recover")
311 .await
312 .map_err(RecoveryControlError::Uncertain)?
313 else {
314 return Err(RecoveryControlError::Superseded(
315 "pending recovery Release disappeared before commit".into(),
316 ));
317 };
318 let active = match parse_recovery_announcement(¤t) {
319 Ok(Some(active)) => active,
320 Ok(None) => {
321 return Err(RecoveryControlError::Superseded(
322 "pending recovery Release was cleared before commit".into(),
323 ));
324 }
325 Err(reason) => return Err(RecoveryControlError::Conflict(reason)),
326 };
327 if active != *release {
328 let error = if active.round.id.generation > round.id.generation {
329 RecoveryControlError::Superseded(
330 "a newer recovery intent replaced the pending Release".into(),
331 )
332 } else {
333 RecoveryControlError::Conflict(
334 "recovery Release commit does not match the exact pending intent".into(),
335 )
336 };
337 return Err(error);
338 }
339 self.audit_recovery_driver_proof(round, "Release commit publication")
340 .await?;
341 match Box::pin(authority.record_recovery_release_commit(&round.leader_proof, reference))
342 .await
343 .map_err(RecoveryControlError::from_authority)?
344 {
345 super::super::leader_lease::RecordRecoveryReleaseCommitResult::Created(_)
346 | super::super::leader_lease::RecordRecoveryReleaseCommitResult::Unchanged(_) => {
347 Ok(ReleaseCommitStatus::Committed {
348 terminal: committed,
349 })
350 }
351 super::super::leader_lease::RecordRecoveryReleaseCommitResult::Conflict { winner } => {
352 if winner.generation() > round.id.generation {
353 Err(RecoveryControlError::Superseded(format!(
354 "recovery release generation {} replaced generation {}",
355 winner.generation(),
356 round.id.generation
357 )))
358 } else {
359 Err(RecoveryControlError::Conflict(format!(
360 "recovery release generation {} has a different durable winner",
361 round.id.generation
362 )))
363 }
364 }
365 super::super::leader_lease::RecordRecoveryReleaseCommitResult::FaultsChanged => {
366 Err(RecoveryControlError::Superseded(
367 "recovery fault inventory changed at Release authority admission".into(),
368 ))
369 }
370 }
371 }
372
373 async fn recovery_announcement_for_driver(
374 &self,
375 driver: NodeId,
376 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
377 let Some(raw) = self
378 .read_recovery_value(driver, "control:recover")
379 .await
380 .map_err(RecoveryControlError::Uncertain)?
381 else {
382 return Ok(None);
383 };
384 let Some(announcement) =
385 parse_recovery_announcement(&raw).map_err(RecoveryControlError::Conflict)?
386 else {
387 return Ok(None);
388 };
389 if matches!(announcement.phase, RecoverPhase::ReleaseCommitted { .. }) {
390 return Err(RecoveryControlError::Conflict(
391 "committed recovery release appeared in the mutable intent slot".into(),
392 ));
393 }
394 if announcement.round.id.driver != driver {
395 return Err(RecoveryControlError::Conflict(format!(
396 "recovery publisher {driver} is not declared driver {}",
397 announcement.round.id.driver
398 )));
399 }
400 Ok(Some(announcement))
401 }
402
403 async fn recovery_authority_matches_for_observation(
404 &self,
405 proof: &LeaderProof,
406 ) -> Result<bool, RecoveryControlError> {
407 let authority = self
408 .checkpoint_authority()
409 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
410 authority
411 .load()
412 .await
413 .map_err(|error| match error {
414 super::super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
415 error => RecoveryControlError::Conflict(error.to_string()),
416 })
417 .map(|lease| lease.is_some_and(|lease| lease.matches_proof(proof)))
418 }
419
420 pub async fn observe_recover_control(
425 &self,
426 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
427 let Some(current_driver) = self.current_leader() else {
428 return Ok(None);
429 };
430 let Some(announcement) = self
431 .recovery_announcement_for_driver(current_driver)
432 .await?
433 else {
434 return Ok(None);
435 };
436 if !self
437 .recovery_authority_matches_for_observation(&announcement.round.leader_proof)
438 .await?
439 {
440 return Err(RecoveryControlError::Superseded(format!(
441 "recovery phase from {current_driver} does not match durable leader authority"
442 )));
443 }
444 if self.current_leader() != Some(current_driver)
445 || !self
446 .recovery_authority_matches_for_observation(&announcement.round.leader_proof)
447 .await?
448 {
449 return Err(RecoveryControlError::Superseded(format!(
450 "recovery authority changed while observing {current_driver}"
451 )));
452 }
453 Ok(Some(announcement))
454 }
455
456 pub async fn observe_recover_control_for_durable_proof(
465 &self,
466 proof: &LeaderProof,
467 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
468 if !proof.is_canonical() {
469 return Err(RecoveryControlError::Conflict(
470 "recovery observation requires a canonical durable leader proof".into(),
471 ));
472 }
473 if !self
474 .recovery_authority_matches_for_observation(proof)
475 .await?
476 {
477 return Err(RecoveryControlError::Superseded(
478 "durable recovery authority does not match the audited proof".into(),
479 ));
480 }
481 let driver = NodeId(proof.owner.node_id);
482 let Some(announcement) = self.recovery_announcement_for_driver(driver).await? else {
483 return Ok(None);
484 };
485 if announcement.round.leader_proof != *proof
486 || !self
487 .recovery_authority_matches_for_observation(proof)
488 .await?
489 {
490 return Err(RecoveryControlError::Superseded(format!(
491 "recovery authority changed while observing {driver} through its durable proof"
492 )));
493 }
494 Ok(Some(announcement))
495 }
496
497 pub async fn latest_committed_recover_release(
503 &self,
504 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
505 self.checkpoint_authority()
506 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
507 .latest_recovery_release_terminal()
508 .await
509 .map_err(RecoveryControlError::from_authority)
510 }
511
512 pub async fn observe_committed_recover_release(
519 &self,
520 round: &RecoveryRound,
521 epoch: u64,
522 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
523 round.validate().map_err(RecoveryControlError::Conflict)?;
524 let expected = RecoveryAnnouncement {
525 round: round.clone(),
526 phase: RecoverPhase::ReleaseCommitted { epoch },
527 };
528 let Some(terminal) = self.latest_committed_recover_release().await? else {
529 return Ok(None);
530 };
531 match terminal.round.id.generation.cmp(&round.id.generation) {
532 std::cmp::Ordering::Less => Ok(None),
533 std::cmp::Ordering::Greater => Err(RecoveryControlError::Superseded(format!(
534 "recovery release generation {} replaced generation {}",
535 terminal.round.id.generation, round.id.generation
536 ))),
537 std::cmp::Ordering::Equal if terminal == expected => Ok(Some(terminal)),
538 std::cmp::Ordering::Equal => Err(RecoveryControlError::Conflict(format!(
539 "committed recovery release generation {} differs from the expected round",
540 round.id.generation
541 ))),
542 }
543 }
544
545 pub async fn retire_committed_recover_release_hint(
552 &self,
553 round: &RecoveryRound,
554 epoch: u64,
555 ) -> Result<bool, RecoveryControlError> {
556 round.validate().map_err(RecoveryControlError::Conflict)?;
557 if round.id.driver != self.instance_id {
558 return Err(RecoveryControlError::Conflict(
559 "only the publishing driver may retire its recovery Release hint".into(),
560 ));
561 }
562 if self
563 .observe_committed_recover_release(round, epoch)
564 .await?
565 .is_none()
566 {
567 return Ok(false);
568 }
569 let pending = RecoveryAnnouncement {
570 round: round.clone(),
571 phase: RecoverPhase::Release { epoch },
572 };
573 let _guard = self.recovery_writes.lock().await;
574 let Some(raw) = self
575 .read_recovery_value(self.instance_id, "control:recover")
576 .await
577 .map_err(RecoveryControlError::Uncertain)?
578 else {
579 return Ok(false);
580 };
581 let active = parse_recovery_announcement(&raw)
582 .map_err(RecoveryControlError::Conflict)?
583 .ok_or_else(|| {
584 RecoveryControlError::Conflict(
585 "recovery Release hint decoded as an empty announcement".into(),
586 )
587 })?;
588 if active != pending {
589 return if active.round.id.generation > round.id.generation {
590 Err(RecoveryControlError::Superseded(
591 "a newer recovery intent replaced the committed Release hint".into(),
592 ))
593 } else {
594 Err(RecoveryControlError::Conflict(
595 "mutable recovery intent differs from its committed Release".into(),
596 ))
597 };
598 }
599 self.write_recovery_value_exact("control:recover", String::new())
600 .await
601 .map_err(RecoveryControlError::Uncertain)?;
602 Ok(true)
603 }
604
605 pub async fn observe_recover(&self) -> Result<Option<RecoveryAnnouncement>, String> {
610 self.observe_recover_control()
611 .await
612 .map_err(|error| error.to_string())
613 }
614
615 #[must_use]
617 pub fn recovery_driver_is_current(&self, round: &RecoveryRound) -> bool {
618 self.current_leader() == Some(round.id.driver)
619 }
620
621 #[must_use]
623 pub fn recovery_round_contains_current_process(&self, round: &RecoveryRound) -> bool {
624 round.owner_incarnation(self.instance_id) == Some(self.recovery_incarnation)
625 }
626
627 #[must_use]
629 pub fn recovery_round_requires_current_process_stop(&self, round: &RecoveryRound) -> bool {
630 round.stopped_participant_incarnation(self.instance_id) == Some(self.recovery_incarnation)
631 }
632
633 pub async fn announce_stopped(&self, round: &RecoveryRound) -> Result<(), String> {
638 round.validate()?;
639 if !round.contains_stopped_participant(self.instance_id) {
640 return Err("node outside recovery stopped roster cannot acknowledge Prepare".into());
641 }
642 if !self.recovery_round_requires_current_process_stop(round) {
643 return Err("Prepare acknowledgement has a stale local process incarnation".into());
644 }
645 if !self.recovery_incarnation_is_current().await? {
646 return Err("Prepare acknowledgement came from a superseded local process".into());
647 }
648 let report = RecoveryStoppedReport::new(
649 round,
650 CheckpointParticipant {
651 node_id: self.instance_id.0,
652 boot_incarnation: self.recovery_incarnation,
653 },
654 )?;
655 let encoded = encode_recovery_stopped_report(&report, round)?;
656 self.write_recovery_value_exact(RECOVERY_STOPPED_REPORT_KEY, encoded)
657 .await
658 }
659
660 pub async fn read_stopped(
666 &self,
667 round: &RecoveryRound,
668 participants: &[NodeId],
669 ) -> Result<Vec<RecoveryStoppedReport>, RecoveryControlError> {
670 if participants.windows(2).any(|pair| pair[0].0 >= pair[1].0)
671 || participants.iter().any(NodeId::is_unassigned)
672 {
673 return Err(RecoveryControlError::Conflict(
674 "recovery stopped-report subset requires canonical participants".into(),
675 ));
676 }
677 let roster = participants
678 .iter()
679 .map(|node| {
680 round
681 .stopped_participant_incarnation(*node)
682 .map(|boot_incarnation| CheckpointParticipant {
683 node_id: node.0,
684 boot_incarnation,
685 })
686 .ok_or_else(|| {
687 RecoveryControlError::Conflict(format!(
688 "node {node} is outside the recovery stopped roster"
689 ))
690 })
691 })
692 .collect::<Result<Vec<_>, _>>()?;
693 self.read_recovery_stopped_reports(round, &roster).await
694 }
695
696 pub async fn clear_recover(&self, round: &RecoveryRound) -> Result<bool, String> {
702 if round.has_terminal_fault() {
703 return Err("terminal recovery Prepare cannot be cleared automatically".into());
704 }
705 let _guard = self.recovery_writes.lock().await;
706 let Some(raw) = self
707 .read_recovery_value(self.instance_id, "control:recover")
708 .await?
709 else {
710 return Ok(false);
711 };
712 let Some(active) = parse_recovery_announcement(&raw)? else {
713 return Ok(false);
714 };
715 if active.round != *round {
716 return Ok(false);
717 }
718 if matches!(active.phase, RecoverPhase::Release { .. }) {
719 return Ok(false);
720 }
721 self.write_recovery_value_exact("control:recover", String::new())
722 .await?;
723 Ok(true)
724 }
725
726 #[cfg(feature = "cluster")]
736 pub async fn wait_for_barrier<F>(
737 &self,
738 mut pred: F,
739 timeout: Duration,
740 ) -> Result<Option<BarrierAnnouncement>, String>
741 where
742 F: FnMut(&BarrierAnnouncement) -> bool,
743 {
744 let mut watch = self.barrier.announcement_watch();
745 let poll_for = |watch: &Option<_>| {
748 if watch.is_some() {
749 Duration::from_millis(250)
750 } else {
751 Duration::from_millis(25)
752 }
753 };
754 let deadline = tokio::time::Instant::now() + timeout;
755 loop {
756 let observed =
757 match tokio::time::timeout_at(deadline, self.observe_barrier_matching(&mut pred))
758 .await
759 {
760 Ok(observed) => observed?,
761 Err(_) => return Ok(None),
762 };
763 if let Some(ann) = observed {
764 return Ok(Some(ann));
765 }
766 if tokio::time::Instant::now() >= deadline {
767 return Ok(None);
768 }
769 let poll = poll_for(&watch);
770 let pushed = async {
771 match watch.as_mut() {
772 Some(w) => w.changed().await.is_ok(),
773 None => std::future::pending().await,
774 }
775 };
776 tokio::select! {
777 ok = pushed => {
778 if !ok {
779 watch = None;
782 }
783 }
784 () = tokio::time::sleep(poll) => {}
785 () = tokio::time::sleep_until(deadline) => return Ok(None),
786 }
787 }
788 }
789
790 pub async fn wait_for_quorum(
792 &self,
793 prepare: &BarrierAnnouncement,
794 expected: &[NodeId],
795 deadline: Duration,
796 ) -> QuorumOutcome {
797 self.barrier
798 .wait_for_quorum(prepare, expected, deadline)
799 .await
800 }
801
802 #[must_use]
804 pub fn snapshot_store(&self) -> Option<&AssignmentSnapshotStore> {
805 self.snapshot.as_deref()
806 }
807}