Skip to main content

laminar_core/cluster/control/controller/
recovery_faults.rs

1//! Fault admission, inventories, release readiness, and recovered acknowledgements.
2
3use super::*;
4
5impl ClusterController {
6    /// Allocate the next process-local recovery-fault request identity.
7    ///
8    /// Shared authority converts this ordinal into a cluster-wide fault sequence. Retries retain
9    /// the original ordinal; exhaustion fails closed instead of wrapping.
10    ///
11    /// # Errors
12    /// Returns an error if the process-local sequence is exhausted or becomes noncanonical.
13    pub fn next_recovery_fault_request(&self) -> Result<RecoveryFaultRequest, String> {
14        let sequence = self
15            .recovery_fault_request_sequence
16            .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
17                current.checked_add(1)
18            })
19            .map_err(|_| "recovery fault request sequence exhausted".to_string())?;
20        Ok(RecoveryFaultRequest {
21            sequence: std::num::NonZeroU64::new(sequence)
22                .ok_or_else(|| "recovery fault request allocator produced zero".to_string())?,
23        })
24    }
25
26    /// Reconstitute one previously allocated request for a bounded retry.
27    ///
28    /// # Errors
29    /// Rejects zero and ordinals that this controller process has not allocated.
30    pub fn recovery_fault_request(&self, sequence: u64) -> Result<RecoveryFaultRequest, String> {
31        let sequence = std::num::NonZeroU64::new(sequence)
32            .ok_or_else(|| "recovery fault request sequence must be nonzero".to_string())?;
33        if sequence.get() >= self.recovery_fault_request_sequence.load(Ordering::Acquire) {
34            return Err("recovery fault request was not allocated by this process".into());
35        }
36        Ok(RecoveryFaultRequest { sequence })
37    }
38
39    pub(super) fn recovery_fault_publisher(&self) -> Result<RecoveryFaultPublisher, String> {
40        let publisher = RecoveryFaultPublisher {
41            participant: CheckpointParticipant {
42                node_id: self.instance_id.0,
43                boot_incarnation: self.recovery_incarnation,
44            },
45            process_term: self.recovery_process_term.load(Ordering::Acquire),
46        };
47        publisher.validate()?;
48        Ok(publisher)
49    }
50
51    pub(super) fn capture_live_local_process_authority(
52        &self,
53    ) -> Result<LocalProcessAuthorityIdentity, String> {
54        let _transition = self.process_authority_transition.lock();
55        self.capture_live_local_process_authority_locked()
56    }
57
58    pub(super) fn capture_live_local_process_authority_locked(
59        &self,
60    ) -> Result<LocalProcessAuthorityIdentity, String> {
61        if !self.recovery_process_lease_is_live() {
62            return Err("local process lease authority is not live".into());
63        }
64        let publisher = self.recovery_fault_publisher()?;
65        if !self.recovery_process_lease_is_live() {
66            return Err("local process lease authority changed while sampling identity".into());
67        }
68        Ok(LocalProcessAuthorityIdentity {
69            participant: publisher.participant,
70            process_term: publisher.process_term,
71        })
72    }
73
74    pub(super) async fn recovery_fault_publisher_is_current(
75        &self,
76        publisher: RecoveryFaultPublisher,
77    ) -> Result<bool, RecoveryControlError> {
78        if !self.recovery_process_lease_is_live() {
79            return Ok(false);
80        }
81        let authority = self.process_lease_authority.get().ok_or_else(|| {
82            RecoveryControlError::Conflict("process lease authority is not installed".into())
83        })?;
84        let deadline = tokio::time::Instant::now()
85            .checked_add(RECOVERY_CONTROL_IO_TIMEOUT)
86            .ok_or_else(|| {
87                RecoveryControlError::Conflict(
88                    "recovery fault process-lease deadline overflow".into(),
89                )
90            })?;
91        let current = authority
92            .verify_current_participant_term(
93                publisher.participant,
94                publisher.process_term,
95                deadline,
96            )
97            .await
98            .map_err(RecoveryControlError::from_process_authority)?;
99        Ok(current && self.recovery_process_lease_is_live())
100    }
101
102    pub(super) async fn recovery_incarnation_is_current(&self) -> Result<bool, String> {
103        let Some(raw) = self
104            .read_recovery_value(self.instance_id, RECOVERY_INCARNATION_KEY)
105            .await?
106        else {
107            return Ok(false);
108        };
109        let observed = Uuid::parse_str(&raw)
110            .map_err(|error| format!("invalid local recovery incarnation: {error}"))?;
111        Ok(observed == self.recovery_incarnation)
112    }
113
114    /// Resolve the exact live boot identity for every canonical participant.
115    ///
116    /// # Errors
117    /// Fails closed on a missing, malformed, nil, duplicate, or unexpected participant identity.
118    pub async fn recovery_participant_incarnations(
119        &self,
120        participants: &[u64],
121    ) -> Result<Vec<CheckpointParticipant>, String> {
122        let available = self
123            .available_recovery_participant_incarnations(participants)
124            .await?;
125        if available.len() != participants.len() {
126            let available_ids: std::collections::BTreeSet<u64> = available
127                .iter()
128                .map(|participant| participant.node_id)
129                .collect();
130            let missing = participants
131                .iter()
132                .find(|node_id| !available_ids.contains(node_id))
133                .copied()
134                .unwrap_or(0);
135            return Err(format!(
136                "node {missing} has no current recovery incarnation"
137            ));
138        }
139        Ok(available)
140    }
141
142    /// Resolve every currently readable boot identity among a canonical candidate set. Missing
143    /// candidates are omitted so placement can exclude lease-revoked or not-yet-started nodes
144    /// without weakening exact checkpoint/recovery roster validation.
145    ///
146    /// # Errors
147    /// Fails closed on malformed input, a durable scan error, or a malformed/duplicate identity.
148    pub async fn available_recovery_participant_incarnations(
149        &self,
150        participants: &[u64],
151    ) -> Result<Vec<CheckpointParticipant>, String> {
152        if participants.is_empty()
153            || participants.contains(&0)
154            || participants.windows(2).any(|pair| pair[0] >= pair[1])
155        {
156            return Err("recovery incarnation roster requires canonical participants".into());
157        }
158        let expected: std::collections::BTreeSet<u64> = participants.iter().copied().collect();
159        let mut reported = std::collections::BTreeMap::new();
160        for (node, raw) in self.scan_recovery_values(RECOVERY_INCARNATION_KEY).await? {
161            if !expected.contains(&node.0) {
162                continue;
163            }
164            let incarnation = Uuid::parse_str(&raw).map_err(|error| {
165                format!("invalid recovery incarnation published by {node}: {error}")
166            })?;
167            if incarnation.is_nil() {
168                return Err(format!("nil recovery incarnation published by {node}"));
169            }
170            if reported.insert(node.0, incarnation).is_some() {
171                return Err(format!(
172                    "duplicate recovery incarnation published by {node}"
173                ));
174            }
175        }
176        Ok(participants
177            .iter()
178            .filter_map(|node_id| {
179                reported
180                    .get(node_id)
181                    .copied()
182                    .map(|incarnation| CheckpointParticipant {
183                        node_id: *node_id,
184                        boot_incarnation: incarnation,
185                    })
186            })
187            .collect())
188    }
189
190    /// Whether every current assignment-owner boot identity still equals the frozen round.
191    ///
192    /// # Errors
193    /// Returns an error when the current incarnation roster is unavailable or malformed.
194    pub async fn recovery_incarnations_match(&self, round: &RecoveryRound) -> Result<bool, String> {
195        Ok(self
196            .recovery_participant_incarnations(&round.assignment_fence.participant_ids())
197            .await?
198            == round.assignment_fence.participants)
199    }
200
201    /// Whether every owner and evidence reporter still has the boot identity frozen for stopping.
202    ///
203    /// This check belongs only to the Prepare/stopped-evidence boundary. Evidence reporters do not
204    /// join restore or release liveness quorums after their stopped reports are durable.
205    ///
206    /// # Errors
207    /// Returns an error when the current incarnation roster is unavailable or malformed.
208    pub async fn recovery_stopped_incarnations_match(
209        &self,
210        round: &RecoveryRound,
211    ) -> Result<bool, RecoveryControlError> {
212        self.recovery_roster_incarnations_match_control(&round.stopped_roster())
213            .await
214    }
215
216    pub(super) async fn recovery_incarnations_match_control(
217        &self,
218        round: &RecoveryRound,
219    ) -> Result<bool, RecoveryControlError> {
220        self.recovery_roster_incarnations_match_control(&round.assignment_fence.participants)
221            .await
222    }
223
224    pub(super) async fn recovery_roster_incarnations_match_control(
225        &self,
226        roster: &[CheckpointParticipant],
227    ) -> Result<bool, RecoveryControlError> {
228        let expected: std::collections::BTreeSet<u64> = roster
229            .iter()
230            .map(|participant| participant.node_id)
231            .collect();
232        let mut reported = std::collections::BTreeMap::new();
233        for (node, raw) in self
234            .scan_recovery_values(RECOVERY_INCARNATION_KEY)
235            .await
236            .map_err(RecoveryControlError::Uncertain)?
237        {
238            if !expected.contains(&node.0) {
239                continue;
240            }
241            let incarnation = Uuid::parse_str(&raw).map_err(|error| {
242                RecoveryControlError::Conflict(format!(
243                    "invalid recovery incarnation published by {node}: {error}"
244                ))
245            })?;
246            if incarnation.is_nil() || reported.insert(node.0, incarnation).is_some() {
247                return Err(RecoveryControlError::Conflict(format!(
248                    "noncanonical recovery incarnation published by {node}"
249                )));
250            }
251        }
252        if reported.len() != roster.len() {
253            return Ok(false);
254        }
255        Ok(roster.iter().all(|participant| {
256            reported.get(&participant.node_id) == Some(&participant.boot_incarnation)
257        }))
258    }
259
260    pub(super) async fn read_recovery_fault_inventory_control(
261        &self,
262    ) -> Result<RecoveryFaultInventory, RecoveryControlError> {
263        self.checkpoint_authority()
264            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
265            .recovery_fault_inventory()
266            .await
267            .map_err(RecoveryControlError::from_authority)
268    }
269
270    /// Atomically observed shared-authority recovery-fault inventory.
271    ///
272    /// # Errors
273    /// Returns an error when the leader authority is unavailable or malformed.
274    pub async fn read_recovery_fault_inventory(&self) -> Result<RecoveryFaultInventory, String> {
275        self.read_recovery_fault_inventory_control()
276            .await
277            .map_err(|error| error.to_string())
278    }
279
280    /// Coherent committed-release and fault view from shared recovery authority.
281    ///
282    /// # Errors
283    /// Returns a classified error when the authority or immutable terminal cannot be validated.
284    pub async fn read_recovery_admission_snapshot(
285        &self,
286    ) -> Result<RecoveryAdmissionSnapshot, RecoveryControlError> {
287        self.checkpoint_authority()
288            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
289            .recovery_admission_snapshot()
290            .await
291            .map_err(RecoveryControlError::from_authority)
292    }
293
294    /// Confirm this recovery view still has the same terminal, no active faults, and the exact
295    /// audited leader term.
296    ///
297    /// # Errors
298    /// Returns a classified error when the current shared authority cannot be validated.
299    pub async fn recovery_admission_is_current(
300        &self,
301        snapshot: &RecoveryAdmissionSnapshot,
302        leader_proof: &LeaderProof,
303    ) -> Result<bool, RecoveryControlError> {
304        self.checkpoint_authority()
305            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
306            .recovery_admission_is_current(snapshot, leader_proof)
307            .await
308            .map_err(RecoveryControlError::from_authority)
309    }
310
311    /// Publish this process's fault request so the leader drives a recovery round.
312    ///
313    /// # Errors
314    /// Fails when the process lease is stale or the request cannot be ordered in shared authority.
315    pub async fn report_fault(
316        &self,
317        request: RecoveryFaultRequest,
318    ) -> Result<RecoveryFaultReportOutcome, String> {
319        self.report_fault_with_disposition(request, RecoveryFaultDisposition::Recoverable)
320            .await
321    }
322
323    /// Publish a deterministic fault that automatic recovery must never consume.
324    ///
325    /// The durable marker cannot be downgraded by a later process or ordinary fault report. The
326    /// current operational reset boundary is replacement of the cluster authority namespace.
327    ///
328    /// # Errors
329    /// Fails when the process lease is stale or the request cannot be ordered in shared authority.
330    pub async fn report_terminal_fault(
331        &self,
332        request: RecoveryFaultRequest,
333    ) -> Result<RecoveryFaultReportOutcome, String> {
334        self.report_fault_with_disposition(request, RecoveryFaultDisposition::Terminal)
335            .await
336    }
337
338    pub(super) async fn report_fault_with_disposition(
339        &self,
340        request: RecoveryFaultRequest,
341        disposition: RecoveryFaultDisposition,
342    ) -> Result<RecoveryFaultReportOutcome, String> {
343        let seq = request.sequence();
344        let _guard = self.recovery_writes.lock().await;
345        let publisher = self.recovery_fault_publisher()?;
346        if !self
347            .recovery_fault_publisher_is_current(publisher)
348            .await
349            .map_err(|error| error.to_string())?
350        {
351            return Err("stable node process lease is no longer current".into());
352        }
353        let authority = self
354            .checkpoint_authority()
355            .map_err(|error| error.to_string())?;
356        let result =
357            Box::pin(authority.record_recovery_fault_with_disposition(publisher, seq, disposition))
358                .await
359                .map_err(|error| RecoveryControlError::from_authority(error).to_string())?;
360        if !self
361            .recovery_fault_publisher_is_current(publisher)
362            .await
363            .map_err(|error| error.to_string())?
364        {
365            return Err("stable node process lease changed while publishing recovery fault".into());
366        }
367        match result {
368            super::super::leader_lease::RecordRecoveryFaultResult::Active => {
369                Ok(RecoveryFaultReportOutcome::Active)
370            }
371            super::super::leader_lease::RecordRecoveryFaultResult::AlreadyCleared => {
372                Ok(RecoveryFaultReportOutcome::AlreadyCleared)
373            }
374            super::super::leader_lease::RecordRecoveryFaultResult::CoveredByNewerRequest => {
375                Ok(RecoveryFaultReportOutcome::CoveredByNewerRequest)
376            }
377            super::super::leader_lease::RecordRecoveryFaultResult::TerminalFenceActive => {
378                Ok(RecoveryFaultReportOutcome::TerminalFenceActive)
379            }
380            super::super::leader_lease::RecordRecoveryFaultResult::Superseded => {
381                Err("recovery fault request was superseded by a newer local process".into())
382            }
383        }
384    }
385
386    /// Validate this process's atomically released fault while retaining the local fault-write
387    /// fence. The caller must hold the returned guard through its source-gate transition.
388    ///
389    /// `Ok(None)` means the terminal is no longer current, a newer active fault exists anywhere,
390    /// or the local active/tombstoned slot does not match it. An exact tombstone is idempotent
391    /// success only while the shared fault inventory remains settled.
392    ///
393    /// # Errors
394    /// Returns an error for a nonterminal release, malformed state, or failed durable I/O.
395    pub async fn begin_recovery_release(
396        &self,
397        terminal: &RecoveryAnnouncement,
398    ) -> Result<Option<RecoveryReleaseGuard<'_>>, RecoveryControlError> {
399        terminal
400            .validate()
401            .map_err(RecoveryControlError::Conflict)?;
402        if !matches!(terminal.phase, RecoverPhase::ReleaseCommitted { .. }) {
403            return Err(RecoveryControlError::Conflict(
404                "recovery fault consumption requires a terminal Release".into(),
405            ));
406        }
407        let guard = self.recovery_writes.lock().await;
408        let publisher = self
409            .recovery_fault_publisher()
410            .map_err(RecoveryControlError::Conflict)?;
411        if !self.recovery_fault_publisher_is_current(publisher).await? {
412            return Err(RecoveryControlError::Superseded(
413                "stable node process lease is no longer current".into(),
414            ));
415        }
416        let authority = self
417            .checkpoint_authority()
418            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
419        let authorized = if terminal.round.topology_binding().is_some() {
420            let context = self
421                .snapshot
422                .as_deref()
423                .zip(self.process_lease_authority.get().map(Arc::as_ref));
424            Box::pin(
425                authority.authorize_recovery_release_with_topology(publisher, terminal, context),
426            )
427            .await
428        } else {
429            Box::pin(authority.authorize_recovery_release(publisher, terminal)).await
430        }
431        .map_err(RecoveryControlError::from_authority)?;
432        if !authorized {
433            return Ok(None);
434        }
435        if !self.recovery_fault_publisher_is_current(publisher).await? {
436            return Err(RecoveryControlError::Superseded(
437                "stable node process lease changed while authorizing recovery Release".into(),
438            ));
439        }
440        Ok(Some(RecoveryReleaseGuard {
441            _write_guard: guard,
442        }))
443    }
444
445    /// This stable node's active durable fault sequence, when present.
446    ///
447    /// # Errors
448    /// Returns an error when shared authority is unavailable or malformed.
449    pub async fn read_local_fault_report_control(
450        &self,
451    ) -> Result<Option<u64>, RecoveryControlError> {
452        Ok(self
453            .read_local_recovery_fault_control()
454            .await?
455            .map(|fault| fault.sequence))
456    }
457
458    /// This stable node's complete active durable fault, including its terminal disposition.
459    ///
460    /// # Errors
461    /// Returns an error when shared authority is unavailable or malformed.
462    pub async fn read_local_recovery_fault_control(
463        &self,
464    ) -> Result<Option<RecoveryFault>, RecoveryControlError> {
465        Ok(self
466            .read_recovery_fault_inventory_control()
467            .await?
468            .faults
469            .into_iter()
470            .find(|fault| fault.reporter == self.instance_id))
471    }
472
473    /// This process's durable nonzero fault report with a display-stable error.
474    ///
475    /// # Errors
476    /// Returns an error when the point read fails or the local slot is malformed.
477    pub async fn read_local_fault_report(&self) -> Result<Option<u64>, String> {
478        self.read_local_fault_report_control()
479            .await
480            .map_err(|error| error.to_string())
481    }
482
483    /// Each active stable-node report and its globally monotonic authority sequence.
484    ///
485    /// # Errors
486    /// Returns an error when shared authority is unavailable or malformed.
487    pub async fn read_fault_reports(&self) -> Result<Vec<(NodeId, u64)>, String> {
488        Ok(self
489            .read_recovery_fault_inventory()
490            .await?
491            .faults
492            .into_iter()
493            .map(|fault| (fault.reporter, fault.sequence))
494            .collect())
495    }
496
497    pub(super) async fn audit_recovery_faults_control(
498        &self,
499        round: &RecoveryRound,
500    ) -> Result<(), RecoveryControlError> {
501        let inventory = self.read_recovery_fault_inventory_control().await?;
502        if inventory.revision == round.fault_revision && inventory.faults == round.faults {
503            Ok(())
504        } else {
505            Err(RecoveryControlError::Superseded(
506                "recovery fault set changed before Release commit".into(),
507            ))
508        }
509    }
510
511    pub(super) async fn audit_pending_release_faults_control(
512        &self,
513        release: &RecoveryAnnouncement,
514    ) -> Result<(), RecoveryControlError> {
515        let release_id =
516            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
517        let mut cached = self.pending_release_fault_audit.lock().await;
518        let now = tokio::time::Instant::now();
519        if cached
520            .as_ref()
521            .is_some_and(|(audited, valid_until)| audited == &release_id && now < *valid_until)
522        {
523            return Ok(());
524        }
525        self.audit_recovery_faults_control(&release.round).await?;
526        *cached = Some((
527            release_id,
528            tokio::time::Instant::now() + PENDING_RELEASE_FAULT_AUDIT_INTERVAL,
529        ));
530        Ok(())
531    }
532
533    /// Publish that this node restored the exact frozen recovery round.
534    ///
535    /// # Errors
536    /// Returns an error for an invalid round or when this node is outside its frozen quorum.
537    pub async fn announce_recovered(&self, start: &RecoveryAnnouncement) -> Result<(), String> {
538        start.validate()?;
539        if !matches!(start.phase, RecoverPhase::Start { .. }) {
540            return Err("restore acknowledgement must bind a Start target".into());
541        }
542        if !start.round.contains_owner(self.instance_id) {
543            return Err("node outside recovery quorum cannot acknowledge restore".into());
544        }
545        if start.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
546            return Err("restore acknowledgement has a stale local process incarnation".into());
547        }
548        if !self.recovery_incarnation_is_current().await? {
549            return Err("restore acknowledgement came from a superseded local process".into());
550        }
551        let encoded = serde_json::to_string(&RecoveryAnnouncementAck {
552            announcement: start.clone(),
553            incarnation: self.recovery_incarnation,
554        })
555        .map_err(|error| format!("could not encode recovery ack: {error}"))?;
556        self.write_recovery_value_exact("control:recovered", encoded)
557            .await
558    }
559
560    /// Each visible node's exact restored recovery round.
561    ///
562    /// # Errors
563    /// Fails closed when any visible acknowledgement is malformed.
564    pub async fn read_recovered(&self) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
565        self.read_recovery_announcement_map("control:recovered")
566            .await
567    }
568
569    /// Publish that this exact process is prepared for the pending release intent.
570    ///
571    /// Readiness is published only after local recovery state, shuffle loss accounting, and
572    /// assignment transport authority are installed while source intake remains closed.
573    ///
574    /// # Errors
575    /// Returns an error for a non-Release phase, changed local fault, or stale process.
576    pub async fn announce_release_ready(
577        &self,
578        release: &RecoveryAnnouncement,
579    ) -> Result<(), RecoveryControlError> {
580        let release_id =
581            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
582        if release.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
583            return Err(RecoveryControlError::Superseded(
584                "release readiness has a stale local process incarnation".into(),
585            ));
586        }
587        if !self
588            .recovery_incarnation_is_current()
589            .await
590            .map_err(RecoveryControlError::Uncertain)?
591        {
592            return Err(RecoveryControlError::Superseded(
593                "release readiness came from a superseded local process".into(),
594            ));
595        }
596        if self.read_local_fault_report_control().await?
597            != release.round.fault_sequence(self.instance_id)
598        {
599            return Err(RecoveryControlError::Superseded(
600                "local fault set changed before release readiness".into(),
601            ));
602        }
603        match self.observe_recover_control().await? {
604            Some(active) if active == *release => {}
605            _ => {
606                return Err(RecoveryControlError::Superseded(
607                    "release readiness no longer matches the active intent".into(),
608                ));
609            }
610        }
611        let participant = CheckpointParticipant {
612            node_id: self.instance_id.0,
613            boot_incarnation: self.recovery_incarnation,
614        };
615        let encoded = encode_release_ready_ack(&RecoveryReleaseReadyAck {
616            release: release_id,
617            participant,
618        })
619        .map_err(RecoveryControlError::Conflict)?;
620        self.write_recovery_value_exact(RELEASE_READY_ACK_KEY, encoded)
621            .await
622            .map_err(RecoveryControlError::Uncertain)
623    }
624
625    /// Point-read the exact frozen owner roster's compact readiness records.
626    ///
627    /// Unrelated visible nodes are never scanned. Older records count as missing; malformed,
628    /// same-generation divergent, or newer records are explicit conflicts.
629    ///
630    /// # Errors
631    /// Returns an error only when an exact point read is transport-uncertain.
632    pub(super) async fn read_release_ready(
633        &self,
634        release: &RecoveryAnnouncement,
635    ) -> Result<ReleaseReadyStatus, RecoveryControlError> {
636        let expected =
637            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
638        let reads = futures::stream::iter(
639            release
640                .round
641                .assignment_fence
642                .participants
643                .iter()
644                .copied()
645                .map(|participant| async move {
646                    let value = self
647                        .read_recovery_value(NodeId(participant.node_id), RELEASE_READY_ACK_KEY)
648                        .await;
649                    (participant, value)
650                }),
651        )
652        .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
653        .collect::<Vec<_>>()
654        .await;
655        let mut reads = reads;
656        reads.sort_unstable_by_key(|(participant, _)| participant.node_id);
657        let mut missing = Vec::new();
658        for (participant, value) in reads {
659            let raw = value.map_err(RecoveryControlError::Uncertain)?;
660            let Some(raw) = raw else {
661                missing.push(NodeId(participant.node_id));
662                continue;
663            };
664            let ack = parse_release_ready_ack(&raw, NodeId(participant.node_id))
665                .map_err(RecoveryControlError::Conflict)?;
666            if ack.participant != participant {
667                return Err(RecoveryControlError::Conflict(format!(
668                    "release readiness from {} does not match the frozen process",
669                    participant.node_id
670                )));
671            }
672            if ack.release != expected {
673                if ack.release.generation < expected.generation {
674                    missing.push(NodeId(participant.node_id));
675                    continue;
676                }
677                if ack.release.generation > expected.generation {
678                    return Err(RecoveryControlError::Superseded(format!(
679                        "release readiness from {} has newer generation {}",
680                        participant.node_id, ack.release.generation
681                    )));
682                }
683                return Err(RecoveryControlError::Conflict(format!(
684                    "release readiness from {} conflicts with generation {}",
685                    participant.node_id, expected.generation
686                )));
687            }
688        }
689        if !missing.is_empty() {
690            return Ok(ReleaseReadyStatus::Pending { missing });
691        }
692        Ok(ReleaseReadyStatus::Complete)
693    }
694}