Skip to main content

laminar_core/cluster/control/controller/
recovery_protocol.rs

1//! Recovery prepare, start, release, stopped quorum, and terminal cleanup.
2
3use 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    /// Audit that this process still owns the recovery round's exact local and durable leader
52    /// proof. A same-node fencing-term rotation is supersession just like a driver transfer.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`RecoveryControlError::Superseded`] when the exact proof is no longer current,
57    /// [`RecoveryControlError::Uncertain`] for retryable authority I/O, and
58    /// [`RecoveryControlError::Conflict`] for unavailable or invalid authority state.
59    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    /// Announce phase 1 with the immutable stopped/evidence roster.
109    ///
110    /// # Errors
111    /// Returns an error unless this node is the round's current leader and driver.
112    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    /// Transition the identical prepared round to `Start` with a target bound into the
157    /// announcement. A missing or different `Prepare` is never upgraded.
158    ///
159    /// # Errors
160    /// Returns an error on lost leadership, an invalid round, or a mismatched prior phase.
161    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(&current)?
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    /// Transition the identical `Start` to a pending `Release`. Source gates remain closed until
210    /// the leader commits the exact compact readiness roster.
211    ///
212    /// # Errors
213    /// Returns an error on lost leadership, a changed incarnation roster, or a mismatched Start.
214    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(&current)?
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    /// Commit a pending release after every frozen owner published its compact readiness record.
257    ///
258    /// A pending attempt audits the frozen fault set before returning incomplete readiness. Once
259    /// complete, the process roster and fault set are validated under the driver's phase-transition
260    /// mutex before admitting the content-addressed terminal into durable leader authority.
261    ///
262    /// # Errors
263    /// Returns a classified uncertain, conflict, or superseded outcome. Missing readiness remains
264    /// a normal pending status.
265    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(&current) {
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    /// Active recovery announcement with semantic failures separated from uncertain I/O.
421    ///
422    /// # Errors
423    /// Classifies malformed state, superseded authority, and retryable durable I/O separately.
424    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    /// Observe a nonterminal recovery announcement through an exact durable leader proof.
457    ///
458    /// This is intentionally independent of the local gossip candidacy view. It permits a stopped
459    /// process to finish publishing an already-audited recovery topology after its driver loses
460    /// candidacy, while the durable fencing term remains unchanged.
461    ///
462    /// # Errors
463    /// Classifies malformed state, superseded authority, and retryable durable I/O separately.
464    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    /// Latest irrevocable recovery release admitted by the append-only leader authority.
498    ///
499    /// # Errors
500    /// Classifies missing/corrupt terminal state as conflict, takeover as supersession, and
501    /// durable I/O as uncertainty.
502    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    /// Resolve the exact committed terminal for one pending release intent across leader
513    /// takeover. An older terminal is unrelated; a same-generation divergence is corruption and a
514    /// newer terminal supersedes the caller.
515    ///
516    /// # Errors
517    /// Returns a classified conflict, supersession, or uncertain durable read.
518    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    /// Best-effort cleanup for this driver's mutable `Release` discovery hint after the exact
546    /// terminal is irrevocably present in leader authority. Cleanup never contributes to commit
547    /// validity; followers resolve the terminal from authority even if this write is uncertain.
548    ///
549    /// # Errors
550    /// Classifies malformed or divergent local intent separately from retryable I/O.
551    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    /// Active nonterminal recovery announcement from the locally elected driver.
606    ///
607    /// # Errors
608    /// Returns a display-stable classified control error.
609    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    /// Whether the round's declared driver is the current elected leader in this local view.
616    #[must_use]
617    pub fn recovery_driver_is_current(&self, round: &RecoveryRound) -> bool {
618        self.current_leader() == Some(round.id.driver)
619    }
620
621    /// Whether the assignment-owner quorum names this exact process, not only its stable node id.
622    #[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    /// Whether this exact owner or evidence-reporter process must stop for the round's Prepare.
628    #[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    /// Ack phase 1 for the exact frozen round.
634    ///
635    /// # Errors
636    /// Returns an error for invalid state or when this node is outside the stopped roster.
637    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    /// Point-read only the still-missing members of an exact stopped roster.
661    ///
662    /// # Errors
663    /// Returns a conflict for a noncanonical or out-of-round subset and preserves the same
664    /// uncertainty/conflict/supersession classification used by recovery quorum polling.
665    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    /// Clear only this driver's still-identical recovery announcement. The per-controller lock
697    /// makes the read/clear conditional with respect to a concurrent local phase transition.
698    ///
699    /// # Errors
700    /// Returns an error when the visible local announcement is malformed.
701    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    /// Wait until the merged barrier history yields an announcement matching `pred`, or `timeout`
727    /// expires (→ `Ok(None)`). Observation remains side-effect free; event-time progress must come
728    /// from immutable checkpoint authority. Push-driven off the gRPC announcement watch when available; gossip-KV-only
729    /// deployments (and KV-only announcements) are covered by a
730    /// fallback poll — 250ms with the watch, 25ms without.
731    ///
732    /// # Errors
733    /// Returns the first observation error instead of converting a known protocol or transport
734    /// failure into a timeout.
735    #[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        // Recomputed per iteration: when the watch sender drops
746        // mid-wait, the fallback must tighten to the no-watch cadence.
747        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                        // Sender gone (server shutdown) — degrade to
780                        // polling instead of spinning on the error.
781                        watch = None;
782                    }
783                }
784                () = tokio::time::sleep(poll) => {}
785                () = tokio::time::sleep_until(deadline) => return Ok(None),
786            }
787        }
788    }
789
790    /// Leader-side: poll until quorum or `deadline`.
791    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    /// Assignment snapshot store, if configured.
803    #[must_use]
804    pub fn snapshot_store(&self) -> Option<&AssignmentSnapshotStore> {
805        self.snapshot.as_deref()
806    }
807}