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    /// Freeze the current irreversible catalog target and complete process terms before Prepare.
7    /// The existing round keeps the same nonce, assignment and fault inventory throughout stop,
8    /// restore and Release. This does not choose a checkpoint or authorize runtime work.
9    ///
10    /// # Errors
11    /// Rejects obsolete driver/process/assignment evidence or damaged topology authority.
12    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    /// Revalidate this round's exact target, complete process terms and selected cut.
38    /// Pass an epoch at Start/install/Release commit; later consumption uses the immutable target
39    /// so ordinary target checkpoints cannot invalidate a healthy released runtime.
40    ///
41    /// # Errors
42    /// Classifies stale or damaged target/process/assignment/cut evidence without parent fallback.
43    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    /// Audit that this process still owns the recovery round's exact local and durable leader
107    /// proof. A same-node fencing-term rotation is supersession just like a driver transfer.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`RecoveryControlError::Superseded`] when the exact proof is no longer current,
112    /// [`RecoveryControlError::Uncertain`] for retryable authority I/O, and
113    /// [`RecoveryControlError::Conflict`] for unavailable or invalid authority state.
114    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    /// Announce phase 1 with the immutable stopped/evidence roster.
164    ///
165    /// # Errors
166    /// Returns an error unless this node is the round's current leader and driver.
167    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    /// Transition the identical prepared round to `Start` with a target bound into the
218    /// announcement. A missing or different `Prepare` is never upgraded.
219    ///
220    /// # Errors
221    /// Returns an error on lost leadership, an invalid round, or a mismatched prior phase.
222    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(&current)?
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    /// Transition the identical `Start` to a pending `Release`. Source gates remain closed until
277    /// the leader commits the exact compact readiness roster.
278    ///
279    /// # Errors
280    /// Returns an error on lost leadership, a changed incarnation roster, or a mismatched Start.
281    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(&current)?
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    /// Commit a pending release after every frozen owner published its compact readiness record.
330    ///
331    /// A pending attempt audits the frozen fault set before returning incomplete readiness. Once
332    /// complete, the process roster and fault set are validated under the driver's phase-transition
333    /// mutex before admitting the content-addressed terminal into durable leader authority.
334    ///
335    /// # Errors
336    /// Returns a classified uncertain, conflict, or superseded outcome. Missing readiness remains
337    /// a normal pending status.
338    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(&current) {
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    /// Active recovery announcement with semantic failures separated from uncertain I/O.
510    ///
511    /// # Errors
512    /// Classifies malformed state, superseded authority, and retryable durable I/O separately.
513    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            // A driver that regains leadership allocates a fresh generation exactly as a different
531            // successor would, and nobody reads a previous driver's slot. Its own round from an
532            // older term is therefore not active; reporting it as superseded would keep the new
533            // term from ever writing that fresh Prepare. Terminal rounds stay fail-closed.
534            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    /// Observe a nonterminal recovery announcement through an exact durable leader proof.
570    ///
571    /// This is intentionally independent of the local gossip candidacy view. It permits a stopped
572    /// process to finish publishing an already-audited recovery topology after its driver loses
573    /// candidacy, while the durable fencing term remains unchanged.
574    ///
575    /// # Errors
576    /// Classifies malformed state, superseded authority, and retryable durable I/O separately.
577    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    /// Latest irrevocable recovery release admitted by the append-only leader authority.
622    ///
623    /// # Errors
624    /// Classifies missing/corrupt terminal state as conflict, takeover as supersession, and
625    /// durable I/O as uncertainty.
626    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    /// Resolve the exact committed terminal for one pending release intent across leader
637    /// takeover. An older terminal is unrelated; a same-generation divergence is corruption and a
638    /// newer terminal supersedes the caller.
639    ///
640    /// # Errors
641    /// Returns a classified conflict, supersession, or uncertain durable read.
642    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    /// Best-effort cleanup for this driver's mutable `Release` discovery hint after the exact
670    /// terminal is irrevocably present in leader authority. Cleanup never contributes to commit
671    /// validity; followers resolve the terminal from authority even if this write is uncertain.
672    ///
673    /// # Errors
674    /// Classifies malformed or divergent local intent separately from retryable I/O.
675    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    /// Active nonterminal recovery announcement from the locally elected driver.
730    ///
731    /// # Errors
732    /// Returns a display-stable classified control error.
733    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    /// Whether the round's declared driver is the current elected leader in this local view.
740    #[must_use]
741    pub fn recovery_driver_is_current(&self, round: &RecoveryRound) -> bool {
742        self.current_leader() == Some(round.id.driver)
743    }
744
745    /// Whether the assignment-owner quorum names this exact process, not only its stable node id.
746    #[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    /// Whether this exact owner or evidence-reporter process must stop for the round's Prepare.
752    #[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    /// Ack phase 1 for the exact frozen round.
758    ///
759    /// # Errors
760    /// Returns an error for invalid state or when this node is outside the stopped roster.
761    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    /// Point-read only the still-missing members of an exact stopped roster.
785    ///
786    /// # Errors
787    /// Returns a conflict for a noncanonical or out-of-round subset and preserves the same
788    /// uncertainty/conflict/supersession classification used by recovery quorum polling.
789    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    /// Clear only this driver's still-identical recovery announcement. The per-controller lock
821    /// makes the read/clear conditional with respect to a concurrent local phase transition.
822    ///
823    /// # Errors
824    /// Returns an error when the visible local announcement is malformed.
825    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    /// Wait until the merged barrier history yields an announcement matching `pred`, or `timeout`
851    /// expires (→ `Ok(None)`). Observation remains side-effect free; event-time progress must come
852    /// from immutable checkpoint authority. Push-driven off the gRPC announcement watch when available; gossip-KV-only
853    /// deployments (and KV-only announcements) are covered by a
854    /// fallback poll — 250ms with the watch, 25ms without.
855    ///
856    /// # Errors
857    /// Returns the first observation error instead of converting a known protocol or transport
858    /// failure into a timeout.
859    #[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        // Recomputed per iteration: when the watch sender drops
870        // mid-wait, the fallback must tighten to the no-watch cadence.
871        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                        // Sender gone (server shutdown) — degrade to
904                        // polling instead of spinning on the error.
905                        watch = None;
906                    }
907                }
908                () = tokio::time::sleep(poll) => {}
909                () = tokio::time::sleep_until(deadline) => return Ok(None),
910            }
911        }
912    }
913
914    /// Leader-side: poll until quorum or `deadline`.
915    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    /// Assignment snapshot store, if configured.
927    #[must_use]
928    pub fn snapshot_store(&self) -> Option<&AssignmentSnapshotStore> {
929        self.snapshot.as_deref()
930    }
931}