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            .fetch_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 authorized = self
417            .checkpoint_authority()
418            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
419            .authorize_recovery_release(publisher, terminal)
420            .await
421            .map_err(RecoveryControlError::from_authority)?;
422        if !authorized {
423            return Ok(None);
424        }
425        if !self.recovery_fault_publisher_is_current(publisher).await? {
426            return Err(RecoveryControlError::Superseded(
427                "stable node process lease changed while authorizing recovery Release".into(),
428            ));
429        }
430        Ok(Some(RecoveryReleaseGuard {
431            _write_guard: guard,
432        }))
433    }
434
435    /// This stable node's active durable fault sequence, when present.
436    ///
437    /// # Errors
438    /// Returns an error when shared authority is unavailable or malformed.
439    pub async fn read_local_fault_report_control(
440        &self,
441    ) -> Result<Option<u64>, RecoveryControlError> {
442        Ok(self
443            .read_local_recovery_fault_control()
444            .await?
445            .map(|fault| fault.sequence))
446    }
447
448    /// This stable node's complete active durable fault, including its terminal disposition.
449    ///
450    /// # Errors
451    /// Returns an error when shared authority is unavailable or malformed.
452    pub async fn read_local_recovery_fault_control(
453        &self,
454    ) -> Result<Option<RecoveryFault>, RecoveryControlError> {
455        Ok(self
456            .read_recovery_fault_inventory_control()
457            .await?
458            .faults
459            .into_iter()
460            .find(|fault| fault.reporter == self.instance_id))
461    }
462
463    /// This process's durable nonzero fault report with a display-stable error.
464    ///
465    /// # Errors
466    /// Returns an error when the point read fails or the local slot is malformed.
467    pub async fn read_local_fault_report(&self) -> Result<Option<u64>, String> {
468        self.read_local_fault_report_control()
469            .await
470            .map_err(|error| error.to_string())
471    }
472
473    /// Each active stable-node report and its globally monotonic authority sequence.
474    ///
475    /// # Errors
476    /// Returns an error when shared authority is unavailable or malformed.
477    pub async fn read_fault_reports(&self) -> Result<Vec<(NodeId, u64)>, String> {
478        Ok(self
479            .read_recovery_fault_inventory()
480            .await?
481            .faults
482            .into_iter()
483            .map(|fault| (fault.reporter, fault.sequence))
484            .collect())
485    }
486
487    pub(super) async fn audit_recovery_faults_control(
488        &self,
489        round: &RecoveryRound,
490    ) -> Result<(), RecoveryControlError> {
491        let inventory = self.read_recovery_fault_inventory_control().await?;
492        if inventory.revision == round.fault_revision && inventory.faults == round.faults {
493            Ok(())
494        } else {
495            Err(RecoveryControlError::Superseded(
496                "recovery fault set changed before Release commit".into(),
497            ))
498        }
499    }
500
501    pub(super) async fn audit_pending_release_faults_control(
502        &self,
503        release: &RecoveryAnnouncement,
504    ) -> Result<(), RecoveryControlError> {
505        let release_id =
506            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
507        let mut cached = self.pending_release_fault_audit.lock().await;
508        let now = tokio::time::Instant::now();
509        if cached
510            .as_ref()
511            .is_some_and(|(audited, valid_until)| audited == &release_id && now < *valid_until)
512        {
513            return Ok(());
514        }
515        self.audit_recovery_faults_control(&release.round).await?;
516        *cached = Some((
517            release_id,
518            tokio::time::Instant::now() + PENDING_RELEASE_FAULT_AUDIT_INTERVAL,
519        ));
520        Ok(())
521    }
522
523    /// Publish that this node restored the exact frozen recovery round.
524    ///
525    /// # Errors
526    /// Returns an error for an invalid round or when this node is outside its frozen quorum.
527    pub async fn announce_recovered(&self, start: &RecoveryAnnouncement) -> Result<(), String> {
528        start.validate()?;
529        if !matches!(start.phase, RecoverPhase::Start { .. }) {
530            return Err("restore acknowledgement must bind a Start target".into());
531        }
532        if !start.round.contains_owner(self.instance_id) {
533            return Err("node outside recovery quorum cannot acknowledge restore".into());
534        }
535        if start.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
536            return Err("restore acknowledgement has a stale local process incarnation".into());
537        }
538        if !self.recovery_incarnation_is_current().await? {
539            return Err("restore acknowledgement came from a superseded local process".into());
540        }
541        let encoded = serde_json::to_string(&RecoveryAnnouncementAck {
542            announcement: start.clone(),
543            incarnation: self.recovery_incarnation,
544        })
545        .map_err(|error| format!("could not encode recovery ack: {error}"))?;
546        self.write_recovery_value_exact("control:recovered", encoded)
547            .await
548    }
549
550    /// Each visible node's exact restored recovery round.
551    ///
552    /// # Errors
553    /// Fails closed when any visible acknowledgement is malformed.
554    pub async fn read_recovered(&self) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
555        self.read_recovery_announcement_map("control:recovered")
556            .await
557    }
558
559    /// Publish that this exact process is prepared for the pending release intent.
560    ///
561    /// Readiness is published only after local recovery state, shuffle loss accounting, and
562    /// assignment transport authority are installed while source intake remains closed.
563    ///
564    /// # Errors
565    /// Returns an error for a non-Release phase, changed local fault, or stale process.
566    pub async fn announce_release_ready(
567        &self,
568        release: &RecoveryAnnouncement,
569    ) -> Result<(), RecoveryControlError> {
570        let release_id =
571            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
572        if release.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
573            return Err(RecoveryControlError::Superseded(
574                "release readiness has a stale local process incarnation".into(),
575            ));
576        }
577        if !self
578            .recovery_incarnation_is_current()
579            .await
580            .map_err(RecoveryControlError::Uncertain)?
581        {
582            return Err(RecoveryControlError::Superseded(
583                "release readiness came from a superseded local process".into(),
584            ));
585        }
586        if self.read_local_fault_report_control().await?
587            != release.round.fault_sequence(self.instance_id)
588        {
589            return Err(RecoveryControlError::Superseded(
590                "local fault set changed before release readiness".into(),
591            ));
592        }
593        match self.observe_recover_control().await? {
594            Some(active) if active == *release => {}
595            _ => {
596                return Err(RecoveryControlError::Superseded(
597                    "release readiness no longer matches the active intent".into(),
598                ));
599            }
600        }
601        let participant = CheckpointParticipant {
602            node_id: self.instance_id.0,
603            boot_incarnation: self.recovery_incarnation,
604        };
605        let encoded = encode_release_ready_ack(&RecoveryReleaseReadyAck {
606            release: release_id,
607            participant,
608        })
609        .map_err(RecoveryControlError::Conflict)?;
610        self.write_recovery_value_exact(RELEASE_READY_ACK_KEY, encoded)
611            .await
612            .map_err(RecoveryControlError::Uncertain)
613    }
614
615    /// Point-read the exact frozen owner roster's compact readiness records.
616    ///
617    /// Unrelated visible nodes are never scanned. Older records count as missing; malformed,
618    /// same-generation divergent, or newer records are explicit conflicts.
619    ///
620    /// # Errors
621    /// Returns an error only when an exact point read is transport-uncertain.
622    pub(super) async fn read_release_ready(
623        &self,
624        release: &RecoveryAnnouncement,
625    ) -> Result<ReleaseReadyStatus, RecoveryControlError> {
626        let expected =
627            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
628        let reads = futures::stream::iter(
629            release
630                .round
631                .assignment_fence
632                .participants
633                .iter()
634                .copied()
635                .map(|participant| async move {
636                    let value = self
637                        .read_recovery_value(NodeId(participant.node_id), RELEASE_READY_ACK_KEY)
638                        .await;
639                    (participant, value)
640                }),
641        )
642        .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
643        .collect::<Vec<_>>()
644        .await;
645        let mut reads = reads;
646        reads.sort_unstable_by_key(|(participant, _)| participant.node_id);
647        let mut missing = Vec::new();
648        for (participant, value) in reads {
649            let raw = value.map_err(RecoveryControlError::Uncertain)?;
650            let Some(raw) = raw else {
651                missing.push(NodeId(participant.node_id));
652                continue;
653            };
654            let ack = parse_release_ready_ack(&raw, NodeId(participant.node_id))
655                .map_err(RecoveryControlError::Conflict)?;
656            if ack.participant != participant {
657                return Err(RecoveryControlError::Conflict(format!(
658                    "release readiness from {} does not match the frozen process",
659                    participant.node_id
660                )));
661            }
662            if ack.release != expected {
663                if ack.release.generation < expected.generation {
664                    missing.push(NodeId(participant.node_id));
665                    continue;
666                }
667                if ack.release.generation > expected.generation {
668                    return Err(RecoveryControlError::Superseded(format!(
669                        "release readiness from {} has newer generation {}",
670                        participant.node_id, ack.release.generation
671                    )));
672                }
673                return Err(RecoveryControlError::Conflict(format!(
674                    "release readiness from {} conflicts with generation {}",
675                    participant.node_id, expected.generation
676                )));
677            }
678        }
679        if !missing.is_empty() {
680            return Ok(ReleaseReadyStatus::Pending { missing });
681        }
682        Ok(ReleaseReadyStatus::Complete)
683    }
684}