Skip to main content

laminar_core/cluster/control/controller/
recovery_identity.rs

1//! Durable recovery values, generations, incarnations, and local authority evidence.
2
3use super::*;
4
5impl ClusterController {
6    pub(super) async fn write_recovery_value(
7        &self,
8        key: &str,
9        value: String,
10    ) -> Result<(), String> {
11        if !self.process_lease_is_live() {
12            return Err("stable node process lease is no longer live".into());
13        }
14        tokio::time::timeout(
15            RECOVERY_CONTROL_IO_TIMEOUT,
16            self.recovery_kv.write_checked(key, value),
17        )
18        .await
19        .map_err(|_| format!("recovery control write for {key} timed out"))?
20        .map_err(|error| format!("recovery control write for {key} failed: {error}"))
21    }
22
23    pub(super) async fn write_recovery_value_exact(
24        &self,
25        key: &str,
26        value: String,
27    ) -> Result<(), String> {
28        self.write_recovery_value(key, value.clone()).await?;
29        let observed = self
30            .read_recovery_value(self.instance_id, key)
31            .await?
32            .ok_or_else(|| format!("recovery control value for {key} vanished after write"))?;
33        if observed != value {
34            return Err(format!(
35                "recovery control read-back mismatch for {key}; write was not durable"
36            ));
37        }
38        Ok(())
39    }
40
41    pub(super) async fn read_recovery_value(
42        &self,
43        node: NodeId,
44        key: &str,
45    ) -> Result<Option<String>, String> {
46        tokio::time::timeout(
47            RECOVERY_CONTROL_IO_TIMEOUT,
48            self.recovery_kv.read_from_checked(node, key),
49        )
50        .await
51        .map_err(|_| format!("recovery control read for {key} from {node} timed out"))?
52        .map_err(|error| format!("recovery control read for {key} from {node} failed: {error}"))
53    }
54
55    pub(super) async fn scan_recovery_values(
56        &self,
57        key: &str,
58    ) -> Result<Vec<(NodeId, String)>, String> {
59        tokio::time::timeout(
60            RECOVERY_CONTROL_IO_TIMEOUT,
61            self.recovery_kv.scan_checked(key),
62        )
63        .await
64        .map_err(|_| format!("recovery control scan for {key} timed out"))?
65        .map_err(|error| format!("recovery control scan for {key} failed: {error}"))
66    }
67
68    /// Highest durable recovery generation replicated by any visible participant.
69    ///
70    /// # Errors
71    /// Fails closed when the durable scan is unavailable or contains malformed state.
72    pub async fn max_recovery_generation(&self) -> Result<u64, String> {
73        let mut maximum = 0;
74        for (node, raw) in self.scan_recovery_values("control:recovery-gen").await? {
75            let generation = raw.parse::<u64>().map_err(|error| {
76                format!("invalid replicated recovery generation from {node}: {error}")
77            })?;
78            maximum = maximum.max(generation);
79        }
80        Ok(maximum)
81    }
82
83    /// Monotonically persist this participant's recovery generation and read it back exactly.
84    ///
85    /// # Errors
86    /// Rejects zero, regression, unavailable durable storage, or a mismatched read-back.
87    pub async fn adopt_recovery_generation(&self, generation: u64) -> Result<(), String> {
88        if generation == 0 {
89            return Err("recovery generation must be nonzero".into());
90        }
91        let current = self
92            .read_recovery_value(self.instance_id, "control:recovery-gen")
93            .await?
94            .map(|raw| {
95                raw.parse::<u64>()
96                    .map_err(|error| format!("invalid local recovery generation: {error}"))
97            })
98            .transpose()?;
99        if let Some(current) = current {
100            if current > generation {
101                return Err(format!(
102                    "local recovery generation {current} is newer than proposed generation {generation}"
103                ));
104            }
105            if current == generation {
106                return Ok(());
107            }
108        }
109        self.write_recovery_value_exact("control:recovery-gen", generation.to_string())
110            .await
111    }
112
113    pub(super) async fn read_recovery_stopped_reports(
114        &self,
115        round: &RecoveryRound,
116        roster: &[CheckpointParticipant],
117    ) -> Result<Vec<RecoveryStoppedReport>, RecoveryControlError> {
118        round.validate().map_err(RecoveryControlError::Conflict)?;
119        if roster
120            .windows(2)
121            .any(|pair| pair[0].node_id >= pair[1].node_id)
122            || roster.iter().any(|participant| {
123                round.stopped_participant_incarnation(NodeId(participant.node_id))
124                    != Some(participant.boot_incarnation)
125            })
126        {
127            return Err(RecoveryControlError::Conflict(
128                "recovery stopped-report read roster is not a canonical round subset".into(),
129            ));
130        }
131        let reads = futures::stream::iter(roster.iter().copied().map(|participant| async move {
132            let value = self
133                .read_recovery_value(NodeId(participant.node_id), RECOVERY_STOPPED_REPORT_KEY)
134                .await;
135            (participant, value)
136        }))
137        .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
138        .collect::<Vec<_>>()
139        .await;
140        let mut reports = Vec::new();
141        for (participant, value) in reads {
142            let Some(raw) = value.map_err(RecoveryControlError::Uncertain)? else {
143                continue;
144            };
145            let publisher = NodeId(participant.node_id);
146            let report = parse_recovery_stopped_report_shape(&raw, publisher)
147                .map_err(RecoveryControlError::Conflict)?;
148            if report.round_id.generation < round.id.generation {
149                continue;
150            }
151            if report.round_id.generation > round.id.generation {
152                // A slot alone is not authority: corroborate that its exact publishing boot
153                // durably adopted at least this generation. A stale or partially written newer
154                // value remains pending for the old round instead of forcing abandon loops.
155                let adopted = self
156                    .read_recovery_value(publisher, "control:recovery-gen")
157                    .await
158                    .map_err(RecoveryControlError::Uncertain)?;
159                let incarnation = self
160                    .read_recovery_value(publisher, RECOVERY_INCARNATION_KEY)
161                    .await
162                    .map_err(RecoveryControlError::Uncertain)?;
163                let Some((adopted, incarnation)) = adopted.zip(incarnation) else {
164                    continue;
165                };
166                let adopted = adopted.parse::<u64>().map_err(|error| {
167                    RecoveryControlError::Conflict(format!(
168                        "invalid replicated recovery generation from {publisher}: {error}"
169                    ))
170                })?;
171                let incarnation = Uuid::parse_str(&incarnation).map_err(|error| {
172                    RecoveryControlError::Conflict(format!(
173                        "invalid recovery incarnation published by {publisher}: {error}"
174                    ))
175                })?;
176                if adopted >= report.round_id.generation
177                    && incarnation == report.publisher.boot_incarnation
178                {
179                    reports.push(report);
180                }
181                continue;
182            }
183            report.validate_semantics(round).map_err(|error| {
184                RecoveryControlError::Conflict(format!(
185                    "same-generation recovery stopped report from {publisher} conflicts with the exact frozen round: {error}"
186                ))
187            })?;
188            reports.push(report);
189        }
190        reports.sort_unstable_by_key(|report| report.publisher.node_id);
191        Ok(reports)
192    }
193
194    pub(super) async fn read_recovery_announcement_map(
195        &self,
196        key: &str,
197    ) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
198        let mut announcements = Vec::new();
199        for (node, raw) in self.scan_recovery_values(key).await? {
200            let announcement = parse_recovery_announcement_ack(&raw, node)?;
201            announcements.push((node, announcement));
202        }
203        Ok(announcements)
204    }
205
206    /// This process's boot-unique recovery identity.
207    #[must_use]
208    pub fn recovery_incarnation(&self) -> Uuid {
209        self.recovery_incarnation
210    }
211
212    /// Try to capture this process's live local identity without waiting or performing I/O.
213    ///
214    /// The process-authority transition lock is acquired with `try_lock`; checkpoint timing
215    /// instrumentation can therefore fail closed instead of extending the measured pause. The
216    /// lease is checked before and after the atomic identity load.
217    ///
218    /// # Errors
219    /// Returns an error when an authority transition is in progress, the local lease is not live,
220    /// or the installed process identity is non-canonical.
221    pub fn try_live_local_process_authority_identity(
222        &self,
223    ) -> Result<LocalProcessAuthorityIdentity, String> {
224        let _transition = self
225            .process_authority_transition
226            .try_lock()
227            .ok_or_else(|| "local process authority transition is in progress".to_string())?;
228        self.capture_live_local_process_authority_locked()
229    }
230
231    /// Read this process's exact local assignment evidence with one bounded checked-KV operation.
232    ///
233    /// Only the local stable-node slot is read; this method never scans shared assignment or
234    /// recovery records. The process identity and sampled lease are captured before the read and
235    /// revalidated immediately afterward. The retained report is then validated against the current
236    /// boot, and identity is revalidated again around the sampled assignment fence. A canonical
237    /// report from an older boot makes the view unavailable rather than being attributed to this
238    /// process. A returned adoption also matches the exact locally audited assignment fence sampled
239    /// before and after the final identity revalidation.
240    ///
241    /// # Errors
242    /// Fails closed when the process term is unpublished, the lease is not live, the bounded
243    /// durable read fails or times out, retained bytes are malformed or non-canonical, a
244    /// current-boot record contradicts the local identity, or identity changes during the read.
245    /// Checked-storage errors are unavailable even when a backend internally conflates a malformed
246    /// outer envelope with I/O. `Invalid` is reserved for logical values returned successfully to
247    /// this method that then fail payload bounds, canonicality, current-slot validation, or the
248    /// sampled same-version audited fence.
249    pub async fn read_local_process_authority_evidence(
250        &self,
251    ) -> Result<LocalProcessAuthorityEvidence, LocalProcessAuthorityEvidenceError> {
252        let before = self
253            .capture_live_local_process_authority()
254            .map_err(LocalProcessAuthorityEvidenceError::Unavailable)?;
255        let node = NodeId(before.participant.node_id);
256        let adopted_raw = self
257            .read_recovery_value(node, ADOPTED_ASSIGNMENT_KEY)
258            .await
259            .map_err(LocalProcessAuthorityEvidenceError::Unavailable)?;
260
261        let after_read = self
262            .capture_live_local_process_authority()
263            .map_err(LocalProcessAuthorityEvidenceError::Unavailable)?;
264        if after_read != before {
265            return Err(LocalProcessAuthorityEvidenceError::Unavailable(
266                "local process authority changed while reading retained evidence".into(),
267            ));
268        }
269
270        let adopted_raw = adopted_raw.ok_or_else(|| {
271            LocalProcessAuthorityEvidenceError::Unavailable(
272                "local process has no durable assignment adoption".into(),
273            )
274        })?;
275        let adoption = parse_local_adopted_assignment(&adopted_raw, before.participant)
276            .map_err(LocalProcessAuthorityEvidenceError::Invalid)?
277            .ok_or_else(|| {
278                LocalProcessAuthorityEvidenceError::Unavailable(
279                    "durable assignment adoption belongs to a prior local boot".into(),
280                )
281            })?;
282        let expected_fence = match self.checkpoint_assignment_fence(adoption.assignment_version) {
283            Some(fence) if adoption.matches_fence(&fence) => fence,
284            Some(_) => {
285                return Err(LocalProcessAuthorityEvidenceError::Invalid(
286                    "durable local adoption contradicts the same-version audited assignment fence"
287                        .into(),
288                ));
289            }
290            None => {
291                return Err(LocalProcessAuthorityEvidenceError::Unavailable(
292                    "matching locally audited assignment fence is unavailable".into(),
293                ));
294            }
295        };
296
297        let after = self
298            .capture_live_local_process_authority()
299            .map_err(LocalProcessAuthorityEvidenceError::Unavailable)?;
300        if after != before {
301            return Err(LocalProcessAuthorityEvidenceError::Unavailable(
302                "local process authority changed while reading retained evidence".into(),
303            ));
304        }
305        match self.checkpoint_assignment_fence(adoption.assignment_version) {
306            Some(fence) if fence == expected_fence && adoption.matches_fence(&fence) => {}
307            Some(_) => {
308                return Err(LocalProcessAuthorityEvidenceError::Invalid(
309                    "locally audited assignment fence changed or contradicted its durable adoption at the same version"
310                        .into(),
311                ));
312            }
313            None => {
314                return Err(LocalProcessAuthorityEvidenceError::Unavailable(
315                    "matching locally audited assignment fence changed during evidence capture"
316                        .into(),
317                ));
318            }
319        }
320
321        Ok(LocalProcessAuthorityEvidence {
322            participant: after.participant,
323            process_term: after.process_term,
324            adopted_assignment: adoption,
325        })
326    }
327
328    /// Publish and read back this process's incarnation metadata.
329    ///
330    /// This does not grant recovery-write authority. Cluster startup must use
331    /// [`Self::publish_leased_recovery_incarnation`] to bind the durable process term.
332    ///
333    /// # Errors
334    /// Returns an error when the control write/read is unavailable or the read-back differs.
335    pub async fn publish_recovery_incarnation(&self) -> Result<(), String> {
336        self.write_recovery_value(
337            RECOVERY_INCARNATION_KEY,
338            self.recovery_incarnation.to_string(),
339        )
340        .await?;
341        let observed = self
342            .read_recovery_value(self.instance_id, RECOVERY_INCARNATION_KEY)
343            .await?
344            .ok_or_else(|| "recovery incarnation was not readable after publication".to_string())?;
345        let observed = Uuid::parse_str(&observed)
346            .map_err(|error| format!("invalid recovery incarnation after publication: {error}"))?;
347        if observed != self.recovery_incarnation {
348            return Err("recovery incarnation read-back mismatch".into());
349        }
350        Ok(())
351    }
352
353    /// Publish the recovery identity only when it matches an acquired stable-node lease.
354    ///
355    /// # Errors
356    /// Rejects a lease for another node or boot identity, or a failed durable publication.
357    pub async fn publish_leased_recovery_incarnation(
358        &self,
359        lease: &super::super::ProcessLease,
360    ) -> Result<(), String> {
361        lease
362            .validate(self.instance_id)
363            .map_err(|error| error.to_string())?;
364        if lease.node != self.instance_id || lease.owner != self.recovery_incarnation {
365            return Err("process lease does not bind this recovery incarnation".into());
366        }
367        if !self.recovery_process_lease_is_live() {
368            return Err("live process lease deadline is not installed".into());
369        }
370        let publisher = RecoveryFaultPublisher {
371            participant: CheckpointParticipant {
372                node_id: lease.node.0,
373                boot_incarnation: lease.owner,
374            },
375            process_term: lease.term,
376        };
377        if !self
378            .recovery_fault_publisher_is_current(publisher)
379            .await
380            .map_err(|error| error.to_string())?
381        {
382            return Err("process lease is not the current durable stable-node term".into());
383        }
384        if !self.recovery_process_lease_is_live() {
385            return Err("process lease changed before publishing recovery identity".into());
386        }
387        self.barrier.install_local_process_lease(lease)?;
388        if let Err(error) = self.publish_recovery_incarnation().await {
389            self.fence_process_lease();
390            return Err(error);
391        }
392        if !self.recovery_process_lease_is_live() {
393            self.fence_process_lease();
394            return Err("process lease changed while publishing recovery identity".into());
395        }
396        match self.recovery_fault_publisher_is_current(publisher).await {
397            Ok(true) if self.recovery_process_lease_is_live() => {}
398            Ok(_) => {
399                self.fence_process_lease();
400                return Err("process lease changed while publishing recovery identity".into());
401            }
402            Err(error) => {
403                self.fence_process_lease();
404                return Err(format!(
405                    "process lease authority became uncertain after recovery publication: {error}"
406                ));
407            }
408        }
409        self.recovery_process_term
410            .store(lease.term, Ordering::Release);
411        Ok(())
412    }
413}