1use super::*;
4
5impl ClusterController {
6 pub async fn bind_recovery_round(
13 &self,
14 round: RecoveryRound,
15 ) -> Result<RecoveryRound, RecoveryControlError> {
16 self.audit_recovery_driver_proof(&round, "topology binding")
17 .await?;
18 let authority = self
19 .checkpoint_authority()
20 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
21 let context = self
22 .snapshot
23 .as_deref()
24 .zip(self.process_lease_authority.get().map(Arc::as_ref));
25 let binding = Box::pin(authority.recovery_topology_binding(&round, context))
26 .await
27 .map_err(RecoveryControlError::from_authority)?;
28 let round = round
29 .bind_topology(binding)
30 .map_err(RecoveryControlError::Conflict)?;
31 self.audit_recovery_topology(&round, None).await?;
32 self.audit_recovery_driver_proof(&round, "topology binding read-back")
33 .await?;
34 Ok(round)
35 }
36
37 pub async fn audit_recovery_topology(
44 &self,
45 round: &RecoveryRound,
46 epoch: Option<u64>,
47 ) -> Result<(), RecoveryControlError> {
48 round.validate().map_err(RecoveryControlError::Conflict)?;
49 let authority = self
50 .checkpoint_authority()
51 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
52 let context = self
53 .snapshot
54 .as_deref()
55 .zip(self.process_lease_authority.get().map(Arc::as_ref));
56 Box::pin(authority.audit_recovery_topology(round, epoch, context))
57 .await
58 .map_err(RecoveryControlError::from_authority)
59 }
60
61 pub(super) async fn durable_recovery_proof_is_current(
62 &self,
63 proof: &LeaderProof,
64 ) -> Result<bool, String> {
65 if !proof.is_canonical() {
66 return Ok(false);
67 }
68 let authority = self
69 .checkpoint_authority()
70 .map_err(|error| format!("durable recovery authority is unavailable: {error}"))?;
71 let Some(lease) = authority
72 .load()
73 .await
74 .map_err(|error| format!("durable recovery authority read failed: {error}"))?
75 else {
76 return Ok(false);
77 };
78 Ok(lease.matches_proof(proof))
79 }
80
81 pub(super) async fn recovery_driver_proof_is_current(
82 &self,
83 round: &RecoveryRound,
84 ) -> Result<bool, String> {
85 Ok(round.id.driver == self.instance_id
86 && self.proof_is_live(&round.leader_proof)
87 && self
88 .durable_recovery_proof_is_current(&round.leader_proof)
89 .await?)
90 }
91
92 pub(super) async fn require_recovery_driver_proof(
93 &self,
94 round: &RecoveryRound,
95 boundary: &str,
96 ) -> Result<(), String> {
97 if self.recovery_driver_proof_is_current(round).await? {
98 Ok(())
99 } else {
100 Err(format!(
101 "recovery driver proof is no longer live at {boundary}"
102 ))
103 }
104 }
105
106 pub async fn audit_recovery_driver_proof(
115 &self,
116 round: &RecoveryRound,
117 boundary: &str,
118 ) -> Result<(), RecoveryControlError> {
119 if round.id.driver != self.instance_id || !self.proof_is_live(&round.leader_proof) {
120 return Err(RecoveryControlError::Superseded(format!(
121 "recovery driver proof is no longer live at {boundary}"
122 )));
123 }
124 let authority = self
125 .checkpoint_authority()
126 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
127 let current = authority.load().await.map_err(|error| match error {
128 super::super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
129 error => RecoveryControlError::Conflict(error.to_string()),
130 })?;
131 if !current.is_some_and(|lease| lease.matches_proof(&round.leader_proof)) {
132 return Err(RecoveryControlError::Superseded(format!(
133 "durable recovery driver proof changed at {boundary}"
134 )));
135 }
136 if round.id.driver != self.instance_id || !self.proof_is_live(&round.leader_proof) {
137 return Err(RecoveryControlError::Superseded(format!(
138 "recovery driver proof changed while auditing {boundary}"
139 )));
140 }
141 Ok(())
142 }
143
144 pub(super) async fn recovery_evidence_roster_matches(
145 &self,
146 round: &RecoveryRound,
147 ) -> Result<bool, String> {
148 let candidates = round
149 .faults
150 .iter()
151 .filter(|fault| !round.assignment_fence.contains(fault.reporter.0))
152 .map(|fault| fault.reporter.0)
153 .collect::<Vec<_>>();
154 let available = if candidates.is_empty() {
155 Vec::new()
156 } else {
157 self.available_recovery_participant_incarnations(&candidates)
158 .await?
159 };
160 Ok(available == round.evidence_participants)
161 }
162
163 pub async fn announce_recover_prepare(&self, round: &RecoveryRound) -> Result<(), String> {
168 round.validate()?;
169 self.audit_recovery_topology(round, None)
170 .await
171 .map_err(|error| error.to_string())?;
172 if round.id.driver != self.instance_id {
173 return Err("only the current leader may prepare its recovery round".into());
174 }
175 self.require_recovery_driver_proof(round, "Prepare preflight")
176 .await?;
177 if !self.recovery_evidence_roster_matches(round).await? {
178 return Err("available recovery evidence roster changed before Prepare".into());
179 }
180 if !self
181 .recovery_stopped_incarnations_match(round)
182 .await
183 .map_err(|error| error.to_string())?
184 {
185 return Err("recovery stopped-process roster changed before Prepare".into());
186 }
187 let _guard = self.recovery_writes.lock().await;
188 self.audit_recovery_topology(round, None)
189 .await
190 .map_err(|error| error.to_string())?;
191 self.require_recovery_driver_proof(round, "Prepare publication")
192 .await?;
193 if !self.recovery_evidence_roster_matches(round).await? {
194 return Err("available recovery evidence roster changed during Prepare".into());
195 }
196 if let Some(raw) = self
197 .read_recovery_value(self.instance_id, "control:recover")
198 .await?
199 {
200 if let Some(active) = parse_recovery_announcement(&raw)? {
201 if active.round.has_terminal_fault() && active.round != *round {
202 return Err("active terminal recovery Prepare cannot be superseded".into());
203 }
204 }
205 }
206 let announcement = RecoveryAnnouncement {
207 round: round.clone(),
208 phase: RecoverPhase::Prepare,
209 };
210 let encoded = encode_recovery_announcement(&announcement)?;
211 self.write_recovery_value_exact("control:recover", encoded)
212 .await?;
213 self.require_recovery_driver_proof(round, "Prepare read-back")
214 .await
215 }
216
217 pub async fn announce_recover_start(
223 &self,
224 round: &RecoveryRound,
225 epoch: u64,
226 ) -> Result<(), String> {
227 round.validate()?;
228 self.audit_recovery_topology(round, Some(epoch))
229 .await
230 .map_err(|error| error.to_string())?;
231 if round.has_terminal_fault() {
232 return Err("terminal recovery fault is permanently retained in Prepare".into());
233 }
234 self.audit_recovery_faults_control(round)
235 .await
236 .map_err(|error| error.to_string())?;
237 if round.id.driver != self.instance_id {
238 return Err("only the current leader may start its recovery round".into());
239 }
240 self.require_recovery_driver_proof(round, "Start preflight")
241 .await?;
242 let _guard = self.recovery_writes.lock().await;
243 if !self.recovery_incarnations_match(round).await? {
244 return Err(
245 "recovery driver or process-incarnation roster changed before Start".into(),
246 );
247 }
248 let current = self
249 .read_recovery_value(self.instance_id, "control:recover")
250 .await?
251 .ok_or_else(|| "recovery Prepare disappeared before Start".to_string())?;
252 let prepared = parse_recovery_announcement(¤t)?
253 .ok_or_else(|| "recovery Prepare was cleared before Start".to_string())?;
254 if prepared.round != *round || prepared.phase != RecoverPhase::Prepare {
255 return Err("recovery Start does not match the exact active Prepare".into());
256 }
257 self.audit_recovery_faults_control(round)
258 .await
259 .map_err(|error| error.to_string())?;
260 self.require_recovery_driver_proof(round, "Start publication")
261 .await?;
262 self.audit_recovery_topology(round, Some(epoch))
263 .await
264 .map_err(|error| error.to_string())?;
265 let announcement = RecoveryAnnouncement {
266 round: round.clone(),
267 phase: RecoverPhase::Start { epoch },
268 };
269 let encoded = encode_recovery_announcement(&announcement)?;
270 self.write_recovery_value_exact("control:recover", encoded)
271 .await?;
272 self.require_recovery_driver_proof(round, "Start read-back")
273 .await
274 }
275
276 pub async fn announce_recover_release(
282 &self,
283 round: &RecoveryRound,
284 epoch: u64,
285 ) -> Result<(), String> {
286 round.validate()?;
287 self.audit_recovery_topology(round, Some(epoch))
288 .await
289 .map_err(|error| error.to_string())?;
290 if round.has_terminal_fault() {
291 return Err("terminal recovery fault cannot advance to Release".into());
292 }
293 if round.id.driver != self.instance_id {
294 return Err("only the current leader may release its recovery round".into());
295 }
296 self.require_recovery_driver_proof(round, "Release preflight")
297 .await?;
298 let _guard = self.recovery_writes.lock().await;
299 if !self.recovery_incarnations_match(round).await? {
300 return Err(
301 "recovery driver or process-incarnation roster changed before Release".into(),
302 );
303 }
304 let current = self
305 .read_recovery_value(self.instance_id, "control:recover")
306 .await?
307 .ok_or_else(|| "recovery Start disappeared before Release".to_string())?;
308 let started = parse_recovery_announcement(¤t)?
309 .ok_or_else(|| "recovery Start was cleared before Release".to_string())?;
310 if started.round != *round || started.phase != (RecoverPhase::Start { epoch }) {
311 return Err("recovery Release does not match the exact active Start target".into());
312 }
313 self.require_recovery_driver_proof(round, "Release publication")
314 .await?;
315 self.audit_recovery_topology(round, Some(epoch))
316 .await
317 .map_err(|error| error.to_string())?;
318 let release = RecoveryAnnouncement {
319 round: round.clone(),
320 phase: RecoverPhase::Release { epoch },
321 };
322 let encoded = encode_recovery_announcement(&release)?;
323 self.write_recovery_value_exact("control:recover", encoded)
324 .await?;
325 self.require_recovery_driver_proof(round, "Release read-back")
326 .await
327 }
328
329 pub async fn try_commit_recover_release(
339 &self,
340 release: &RecoveryAnnouncement,
341 ) -> Result<ReleaseCommitStatus, RecoveryControlError> {
342 release.validate().map_err(RecoveryControlError::Conflict)?;
343 let RecoverPhase::Release { epoch } = release.phase else {
344 return Err(RecoveryControlError::Conflict(
345 "release commit must bind a pending Release target".into(),
346 ));
347 };
348 let round = &release.round;
349 if round.id.driver != self.instance_id {
350 return Err(RecoveryControlError::Superseded(
351 "only the current leader may commit its recovery Release".into(),
352 ));
353 }
354 self.audit_recovery_driver_proof(round, "Release commit preflight")
355 .await?;
356 match self.read_release_ready(release).await? {
357 ReleaseReadyStatus::Complete => {}
358 ReleaseReadyStatus::Pending { missing } => {
359 self.audit_pending_release_faults_control(release).await?;
360 return Ok(ReleaseCommitStatus::Pending { missing });
361 }
362 }
363 let committed = RecoveryAnnouncement {
364 round: round.clone(),
365 phase: RecoverPhase::ReleaseCommitted { epoch },
366 };
367 let authority = self
368 .checkpoint_authority()
369 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
370 let reference = authority
371 .stage_recovery_release_terminal(&committed)
372 .await
373 .map_err(RecoveryControlError::from_authority)?;
374
375 let _guard = self.recovery_writes.lock().await;
376 if !self.recovery_incarnations_match_control(round).await? {
377 return Err(RecoveryControlError::Superseded(
378 "recovery process-incarnation roster changed before Release commit".into(),
379 ));
380 }
381 self.audit_recovery_faults_control(round).await?;
382 let Some(current) = self
383 .read_recovery_value(self.instance_id, "control:recover")
384 .await
385 .map_err(RecoveryControlError::Uncertain)?
386 else {
387 return Err(RecoveryControlError::Superseded(
388 "pending recovery Release disappeared before commit".into(),
389 ));
390 };
391 let active = match parse_recovery_announcement(¤t) {
392 Ok(Some(active)) => active,
393 Ok(None) => {
394 return Err(RecoveryControlError::Superseded(
395 "pending recovery Release was cleared before commit".into(),
396 ));
397 }
398 Err(reason) => return Err(RecoveryControlError::Conflict(reason)),
399 };
400 if active != *release {
401 let error = if active.round.id.generation > round.id.generation {
402 RecoveryControlError::Superseded(
403 "a newer recovery intent replaced the pending Release".into(),
404 )
405 } else {
406 RecoveryControlError::Conflict(
407 "recovery Release commit does not match the exact pending intent".into(),
408 )
409 };
410 return Err(error);
411 }
412 self.audit_recovery_driver_proof(round, "Release commit publication")
413 .await?;
414 let recorded = if round.topology_binding().is_some() {
415 let context = self
416 .snapshot
417 .as_deref()
418 .zip(self.process_lease_authority.get().map(Arc::as_ref));
419 Box::pin(authority.record_recovery_release_commit_with_topology(
420 &round.leader_proof,
421 reference,
422 context,
423 ))
424 .await
425 } else {
426 Box::pin(authority.record_recovery_release_commit(&round.leader_proof, reference)).await
427 };
428 match recorded.map_err(RecoveryControlError::from_authority)? {
429 super::super::leader_lease::RecordRecoveryReleaseCommitResult::Created(_)
430 | super::super::leader_lease::RecordRecoveryReleaseCommitResult::Unchanged(_) => {
431 Ok(ReleaseCommitStatus::Committed {
432 terminal: committed,
433 })
434 }
435 super::super::leader_lease::RecordRecoveryReleaseCommitResult::Conflict { winner } => {
436 if winner.generation() > round.id.generation {
437 Err(RecoveryControlError::Superseded(format!(
438 "recovery release generation {} replaced generation {}",
439 winner.generation(),
440 round.id.generation
441 )))
442 } else {
443 Err(RecoveryControlError::Conflict(format!(
444 "recovery release generation {} has a different durable winner",
445 round.id.generation
446 )))
447 }
448 }
449 super::super::leader_lease::RecordRecoveryReleaseCommitResult::FaultsChanged => {
450 Err(RecoveryControlError::Superseded(
451 "recovery fault inventory changed at Release authority admission".into(),
452 ))
453 }
454 }
455 }
456
457 async fn recovery_announcement_for_driver(
458 &self,
459 driver: NodeId,
460 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
461 let Some(raw) = self
462 .read_recovery_value(driver, "control:recover")
463 .await
464 .map_err(RecoveryControlError::Uncertain)?
465 else {
466 return Ok(None);
467 };
468 let Some(announcement) =
469 parse_recovery_announcement(&raw).map_err(RecoveryControlError::Conflict)?
470 else {
471 return Ok(None);
472 };
473 if matches!(announcement.phase, RecoverPhase::ReleaseCommitted { .. }) {
474 return Err(RecoveryControlError::Conflict(
475 "committed recovery release appeared in the mutable intent slot".into(),
476 ));
477 }
478 if announcement.round.id.driver != driver {
479 return Err(RecoveryControlError::Conflict(format!(
480 "recovery publisher {driver} is not declared driver {}",
481 announcement.round.id.driver
482 )));
483 }
484 Ok(Some(announcement))
485 }
486
487 async fn load_recovery_authority_for_observation(
488 &self,
489 ) -> Result<Option<super::super::LeaderLease>, RecoveryControlError> {
490 let authority = self
491 .checkpoint_authority()
492 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
493 authority.load().await.map_err(|error| match error {
494 super::super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
495 error => RecoveryControlError::Conflict(error.to_string()),
496 })
497 }
498
499 async fn recovery_authority_matches_for_observation(
500 &self,
501 proof: &LeaderProof,
502 ) -> Result<bool, RecoveryControlError> {
503 Ok(self
504 .load_recovery_authority_for_observation()
505 .await?
506 .is_some_and(|lease| lease.matches_proof(proof)))
507 }
508
509 pub async fn observe_recover_control(
514 &self,
515 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
516 let Some(current_driver) = self.current_leader() else {
517 return Ok(None);
518 };
519 let Some(announcement) = self
520 .recovery_announcement_for_driver(current_driver)
521 .await?
522 else {
523 return Ok(None);
524 };
525 let authority = self.load_recovery_authority_for_observation().await?;
526 if !authority
527 .as_ref()
528 .is_some_and(|lease| lease.matches_proof(&announcement.round.leader_proof))
529 {
530 let older_own_term = authority.is_some_and(|lease| {
535 lease.owner.node == current_driver
536 && announcement.round.leader_proof.owner.node_id == current_driver.0
537 && announcement.round.leader_proof.fencing_token < lease.token
538 });
539 if older_own_term && !announcement.round.has_terminal_fault() {
540 return Ok(None);
541 }
542 return Err(RecoveryControlError::Superseded(format!(
543 "recovery phase from {current_driver} does not match durable leader authority"
544 )));
545 }
546 if self.current_leader() != Some(current_driver)
547 || !self
548 .recovery_authority_matches_for_observation(&announcement.round.leader_proof)
549 .await?
550 {
551 return Err(RecoveryControlError::Superseded(format!(
552 "recovery authority changed while observing {current_driver}"
553 )));
554 }
555 if !self
556 .checkpoint_authority()
557 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
558 .recovery_round_topology_is_current(&announcement.round)
559 .await
560 .map_err(RecoveryControlError::from_authority)?
561 {
562 return Err(RecoveryControlError::Superseded(
563 "recovery announcement differs from the committed catalog target".into(),
564 ));
565 }
566 Ok(Some(announcement))
567 }
568
569 pub async fn observe_recover_control_for_durable_proof(
578 &self,
579 proof: &LeaderProof,
580 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
581 if !proof.is_canonical() {
582 return Err(RecoveryControlError::Conflict(
583 "recovery observation requires a canonical durable leader proof".into(),
584 ));
585 }
586 if !self
587 .recovery_authority_matches_for_observation(proof)
588 .await?
589 {
590 return Err(RecoveryControlError::Superseded(
591 "durable recovery authority does not match the audited proof".into(),
592 ));
593 }
594 let driver = NodeId(proof.owner.node_id);
595 let Some(announcement) = self.recovery_announcement_for_driver(driver).await? else {
596 return Ok(None);
597 };
598 if announcement.round.leader_proof != *proof
599 || !self
600 .recovery_authority_matches_for_observation(proof)
601 .await?
602 {
603 return Err(RecoveryControlError::Superseded(format!(
604 "recovery authority changed while observing {driver} through its durable proof"
605 )));
606 }
607 if !self
608 .checkpoint_authority()
609 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
610 .recovery_round_topology_is_current(&announcement.round)
611 .await
612 .map_err(RecoveryControlError::from_authority)?
613 {
614 return Err(RecoveryControlError::Superseded(
615 "recovery announcement differs from the committed catalog target".into(),
616 ));
617 }
618 Ok(Some(announcement))
619 }
620
621 pub async fn latest_committed_recover_release(
627 &self,
628 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
629 self.checkpoint_authority()
630 .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
631 .latest_recovery_release_terminal()
632 .await
633 .map_err(RecoveryControlError::from_authority)
634 }
635
636 pub async fn observe_committed_recover_release(
643 &self,
644 round: &RecoveryRound,
645 epoch: u64,
646 ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
647 round.validate().map_err(RecoveryControlError::Conflict)?;
648 let expected = RecoveryAnnouncement {
649 round: round.clone(),
650 phase: RecoverPhase::ReleaseCommitted { epoch },
651 };
652 let Some(terminal) = self.latest_committed_recover_release().await? else {
653 return Ok(None);
654 };
655 match terminal.round.id.generation.cmp(&round.id.generation) {
656 std::cmp::Ordering::Less => Ok(None),
657 std::cmp::Ordering::Greater => Err(RecoveryControlError::Superseded(format!(
658 "recovery release generation {} replaced generation {}",
659 terminal.round.id.generation, round.id.generation
660 ))),
661 std::cmp::Ordering::Equal if terminal == expected => Ok(Some(terminal)),
662 std::cmp::Ordering::Equal => Err(RecoveryControlError::Conflict(format!(
663 "committed recovery release generation {} differs from the expected round",
664 round.id.generation
665 ))),
666 }
667 }
668
669 pub async fn retire_committed_recover_release_hint(
676 &self,
677 round: &RecoveryRound,
678 epoch: u64,
679 ) -> Result<bool, RecoveryControlError> {
680 round.validate().map_err(RecoveryControlError::Conflict)?;
681 if round.id.driver != self.instance_id {
682 return Err(RecoveryControlError::Conflict(
683 "only the publishing driver may retire its recovery Release hint".into(),
684 ));
685 }
686 if self
687 .observe_committed_recover_release(round, epoch)
688 .await?
689 .is_none()
690 {
691 return Ok(false);
692 }
693 let pending = RecoveryAnnouncement {
694 round: round.clone(),
695 phase: RecoverPhase::Release { epoch },
696 };
697 let _guard = self.recovery_writes.lock().await;
698 let Some(raw) = self
699 .read_recovery_value(self.instance_id, "control:recover")
700 .await
701 .map_err(RecoveryControlError::Uncertain)?
702 else {
703 return Ok(false);
704 };
705 let active = parse_recovery_announcement(&raw)
706 .map_err(RecoveryControlError::Conflict)?
707 .ok_or_else(|| {
708 RecoveryControlError::Conflict(
709 "recovery Release hint decoded as an empty announcement".into(),
710 )
711 })?;
712 if active != pending {
713 return if active.round.id.generation > round.id.generation {
714 Err(RecoveryControlError::Superseded(
715 "a newer recovery intent replaced the committed Release hint".into(),
716 ))
717 } else {
718 Err(RecoveryControlError::Conflict(
719 "mutable recovery intent differs from its committed Release".into(),
720 ))
721 };
722 }
723 self.write_recovery_value_exact("control:recover", String::new())
724 .await
725 .map_err(RecoveryControlError::Uncertain)?;
726 Ok(true)
727 }
728
729 pub async fn observe_recover(&self) -> Result<Option<RecoveryAnnouncement>, String> {
734 self.observe_recover_control()
735 .await
736 .map_err(|error| error.to_string())
737 }
738
739 #[must_use]
741 pub fn recovery_driver_is_current(&self, round: &RecoveryRound) -> bool {
742 self.current_leader() == Some(round.id.driver)
743 }
744
745 #[must_use]
747 pub fn recovery_round_contains_current_process(&self, round: &RecoveryRound) -> bool {
748 round.owner_incarnation(self.instance_id) == Some(self.recovery_incarnation)
749 }
750
751 #[must_use]
753 pub fn recovery_round_requires_current_process_stop(&self, round: &RecoveryRound) -> bool {
754 round.stopped_participant_incarnation(self.instance_id) == Some(self.recovery_incarnation)
755 }
756
757 pub async fn announce_stopped(&self, round: &RecoveryRound) -> Result<(), String> {
762 round.validate()?;
763 if !round.contains_stopped_participant(self.instance_id) {
764 return Err("node outside recovery stopped roster cannot acknowledge Prepare".into());
765 }
766 if !self.recovery_round_requires_current_process_stop(round) {
767 return Err("Prepare acknowledgement has a stale local process incarnation".into());
768 }
769 if !self.recovery_incarnation_is_current().await? {
770 return Err("Prepare acknowledgement came from a superseded local process".into());
771 }
772 let report = RecoveryStoppedReport::new(
773 round,
774 CheckpointParticipant {
775 node_id: self.instance_id.0,
776 boot_incarnation: self.recovery_incarnation,
777 },
778 )?;
779 let encoded = encode_recovery_stopped_report(&report, round)?;
780 self.write_recovery_value_exact(RECOVERY_STOPPED_REPORT_KEY, encoded)
781 .await
782 }
783
784 pub async fn read_stopped(
790 &self,
791 round: &RecoveryRound,
792 participants: &[NodeId],
793 ) -> Result<Vec<RecoveryStoppedReport>, RecoveryControlError> {
794 if participants.windows(2).any(|pair| pair[0].0 >= pair[1].0)
795 || participants.iter().any(NodeId::is_unassigned)
796 {
797 return Err(RecoveryControlError::Conflict(
798 "recovery stopped-report subset requires canonical participants".into(),
799 ));
800 }
801 let roster = participants
802 .iter()
803 .map(|node| {
804 round
805 .stopped_participant_incarnation(*node)
806 .map(|boot_incarnation| CheckpointParticipant {
807 node_id: node.0,
808 boot_incarnation,
809 })
810 .ok_or_else(|| {
811 RecoveryControlError::Conflict(format!(
812 "node {node} is outside the recovery stopped roster"
813 ))
814 })
815 })
816 .collect::<Result<Vec<_>, _>>()?;
817 self.read_recovery_stopped_reports(round, &roster).await
818 }
819
820 pub async fn clear_recover(&self, round: &RecoveryRound) -> Result<bool, String> {
826 if round.has_terminal_fault() {
827 return Err("terminal recovery Prepare cannot be cleared automatically".into());
828 }
829 let _guard = self.recovery_writes.lock().await;
830 let Some(raw) = self
831 .read_recovery_value(self.instance_id, "control:recover")
832 .await?
833 else {
834 return Ok(false);
835 };
836 let Some(active) = parse_recovery_announcement(&raw)? else {
837 return Ok(false);
838 };
839 if active.round != *round {
840 return Ok(false);
841 }
842 if matches!(active.phase, RecoverPhase::Release { .. }) {
843 return Ok(false);
844 }
845 self.write_recovery_value_exact("control:recover", String::new())
846 .await?;
847 Ok(true)
848 }
849
850 #[cfg(feature = "cluster")]
860 pub async fn wait_for_barrier<F>(
861 &self,
862 mut pred: F,
863 timeout: Duration,
864 ) -> Result<Option<BarrierAnnouncement>, String>
865 where
866 F: FnMut(&BarrierAnnouncement) -> bool,
867 {
868 let mut watch = self.barrier.announcement_watch();
869 let poll_for = |watch: &Option<_>| {
872 if watch.is_some() {
873 Duration::from_millis(250)
874 } else {
875 Duration::from_millis(25)
876 }
877 };
878 let deadline = tokio::time::Instant::now() + timeout;
879 loop {
880 let observed =
881 match tokio::time::timeout_at(deadline, self.observe_barrier_matching(&mut pred))
882 .await
883 {
884 Ok(observed) => observed?,
885 Err(_) => return Ok(None),
886 };
887 if let Some(ann) = observed {
888 return Ok(Some(ann));
889 }
890 if tokio::time::Instant::now() >= deadline {
891 return Ok(None);
892 }
893 let poll = poll_for(&watch);
894 let pushed = async {
895 match watch.as_mut() {
896 Some(w) => w.changed().await.is_ok(),
897 None => std::future::pending().await,
898 }
899 };
900 tokio::select! {
901 ok = pushed => {
902 if !ok {
903 watch = None;
906 }
907 }
908 () = tokio::time::sleep(poll) => {}
909 () = tokio::time::sleep_until(deadline) => return Ok(None),
910 }
911 }
912 }
913
914 pub async fn wait_for_quorum(
916 &self,
917 prepare: &BarrierAnnouncement,
918 expected: &[NodeId],
919 deadline: Duration,
920 ) -> QuorumOutcome {
921 self.barrier
922 .wait_for_quorum(prepare, expected, deadline)
923 .await
924 }
925
926 #[must_use]
928 pub fn snapshot_store(&self) -> Option<&AssignmentSnapshotStore> {
929 self.snapshot.as_deref()
930 }
931}