Skip to main content

laminar_core/cluster/control/leader_lease/
topology_recovery_round.rs

1//! Bind the existing stopped/restore/Release protocol to the irreversible catalog decision.
2
3use super::topology_admission::CONTROL_TIMEOUT;
4use super::{
5    ClusterCheckpointAuthorityError, DecisionError, LeaderAuthorityRecord, LeaderLeaseStore,
6};
7use crate::checkpoint::CheckpointScope;
8use crate::cluster::control::{
9    AssignmentSnapshotStore, CheckpointAssignmentFence, LocalProcessAuthorityIdentity,
10    ProcessLeaseAuthority, RecoveryAnnouncement, RecoveryRound, TopologyRecoveryBinding,
11};
12
13impl LeaderLeaseStore {
14    /// Check placement compatibility before fencing processes for an assignment recovery.
15    /// This is read-only preflight; the shared assignment append rechecks the same constraint.
16    /// Until a committed target is Active and has committed its own checkpoint, it supports only
17    /// replacement in the same node slots with its complete vnode map. Afterwards ordinary
18    /// membership changes and state redistribution apply.
19    ///
20    /// # Errors
21    /// Rejects uncommitted preparation, changed placement of a live migration, corrupt evidence
22    /// or a bounded read failure.
23    pub async fn validate_topology_assignment_proposal(
24        &self,
25        proposal: &CheckpointAssignmentFence,
26    ) -> Result<(), ClusterCheckpointAuthorityError> {
27        tokio::time::timeout(CONTROL_TIMEOUT, async {
28            let before = self
29                .load_record()
30                .await?
31                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
32            before.reject_uncommitted_topology_preparation()?;
33            self.validate_topology_assignment_proposal_from(&before, proposal)
34                .await?;
35            let after = self
36                .load_record()
37                .await?
38                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
39            after.reject_uncommitted_topology_preparation()?;
40            if before.lease.proof() != after.lease.proof()
41                || before.committed_topology_identity() != after.committed_topology_identity()
42            {
43                return Err(ClusterCheckpointAuthorityError::Fenced);
44            }
45            Ok(())
46        })
47        .await
48        .map_err(|_| DecisionError::Conflict("topology assignment preflight timed out".into()))?
49    }
50
51    pub(super) async fn validate_topology_assignment_proposal_from(
52        &self,
53        current: &LeaderAuthorityRecord,
54        proposal: &CheckpointAssignmentFence,
55    ) -> Result<(), ClusterCheckpointAuthorityError> {
56        if !proposal.is_canonical() {
57            return Err(ClusterCheckpointAuthorityError::Fenced);
58        }
59        let Some(operation) = current.live_topology_migration() else {
60            return Ok(());
61        };
62        self.audit_topology_operation(operation).await?;
63        let plan = self
64            .load_topology_plan(&operation.plan)
65            .await
66            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
67        if proposal.assignment_version < plan.assignment.assignment_version
68            || proposal.assignment_digest != plan.assignment.assignment_digest
69            || proposal.vnode_count != plan.assignment.vnode_count
70            || proposal.partitioning_abi_version != plan.assignment.partitioning_abi_version
71            || !proposal
72                .participants
73                .iter()
74                .map(|participant| participant.node_id)
75                .eq(plan
76                    .assignment
77                    .participants
78                    .iter()
79                    .map(|participant| participant.node_id))
80        {
81            return Err(DecisionError::Conflict(
82                "committed topology requires the unchanged complete owner map; replace failed processes in their original node slots".into()
83            ).into());
84        }
85        Ok(())
86    }
87
88    pub(crate) async fn recovery_topology_binding(
89        &self,
90        round: &RecoveryRound,
91        context: Option<(&AssignmentSnapshotStore, &ProcessLeaseAuthority)>,
92    ) -> Result<Option<TopologyRecoveryBinding>, ClusterCheckpointAuthorityError> {
93        tokio::time::timeout(CONTROL_TIMEOUT, async {
94            let before = self
95                .load_record()
96                .await?
97                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
98            let binding = if let Some(operation) = before.live_topology_migration() {
99                let (assignments, processes) = context.ok_or_else(|| {
100                    DecisionError::Conflict(
101                        "topology recovery requires configured assignment and process authority"
102                            .into(),
103                    )
104                })?;
105                self.audit_topology_operation(operation).await?;
106                let assignment = assignments
107                    .load()
108                    .await
109                    .map_err(|error| DecisionError::Conflict(error.to_string()))?
110                    .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
111                if assignment.draining
112                    || assignment.assignment_fence().ok().as_ref() != Some(&round.assignment_fence)
113                {
114                    return Err(ClusterCheckpointAuthorityError::Fenced);
115                }
116                let mut roster = Vec::with_capacity(round.assignment_fence.participants.len());
117                for participant in &round.assignment_fence.participants {
118                    let lease = processes
119                        .store_for(crate::cluster::discovery::NodeId(participant.node_id))
120                        .load()
121                        .await
122                        .map_err(|error| DecisionError::Conflict(error.to_string()))?
123                        .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
124                    let identity = LocalProcessAuthorityIdentity {
125                        participant: *participant,
126                        process_term: lease.term,
127                    };
128                    self.require_topology_process(processes, identity)
129                        .await
130                        .map_err(|error| DecisionError::Conflict(error.to_string()))?;
131                    roster.push(identity);
132                }
133                Some(
134                    TopologyRecoveryBinding::new(
135                        operation
136                            .commit
137                            .clone()
138                            .ok_or(ClusterCheckpointAuthorityError::Fenced)?,
139                        roster,
140                    )
141                    .map_err(|error| DecisionError::Conflict(error.to_string()))?,
142                )
143            } else {
144                None
145            };
146            let after = self
147                .load_record()
148                .await?
149                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
150            if before.lease.proof() != after.lease.proof()
151                || before.committed_topology_identity() != after.committed_topology_identity()
152            {
153                return Err(ClusterCheckpointAuthorityError::Fenced);
154            }
155            Ok(binding)
156        })
157        .await
158        .map_err(|_| DecisionError::Conflict("recovery topology binding read timed out".into()))?
159    }
160
161    pub(crate) async fn recovery_round_topology_is_current(
162        &self,
163        round: &RecoveryRound,
164    ) -> Result<bool, ClusterCheckpointAuthorityError> {
165        let head = self
166            .load_record()
167            .await?
168            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
169        Ok(Self::recovery_topology_matches(&head, round))
170    }
171
172    pub(super) fn recovery_topology_matches(
173        head: &LeaderAuthorityRecord,
174        round: &RecoveryRound,
175    ) -> bool {
176        match round
177            .topology_binding()
178            .map(TopologyRecoveryBinding::commit)
179        {
180            Some(commit) => {
181                head.committed_topology_operation()
182                    .and_then(|operation| operation.commit.as_ref())
183                    == Some(commit)
184            }
185            None => head.live_topology_migration().is_none(),
186        }
187    }
188
189    /// Audit the target and, when selecting/installing/committing Release, its exact greatest cut.
190    /// Later release-consumption audits use only the immutable target: a healthy released runtime
191    /// continues checkpointing and must never rewind to its recovery terminal's old epoch.
192    pub(crate) async fn audit_recovery_topology(
193        &self,
194        round: &RecoveryRound,
195        epoch: Option<u64>,
196        context: Option<(&AssignmentSnapshotStore, &ProcessLeaseAuthority)>,
197    ) -> Result<(), ClusterCheckpointAuthorityError> {
198        tokio::time::timeout(CONTROL_TIMEOUT, async {
199            let head = self
200                .load_record()
201                .await?
202                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
203            self.audit_recovery_topology_from(&head, round, epoch, context)
204                .await?;
205            let after = self
206                .load_record()
207                .await?
208                .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
209            if !Self::recovery_topology_matches(&after, round)
210                || head.commit_head != after.commit_head
211                || head.committed_topology_operation() != after.committed_topology_operation()
212                || (epoch.is_some()
213                    && (head.assignment_handoff_pin != after.assignment_handoff_pin
214                        || head.active_checkpoint_artifacts != after.active_checkpoint_artifacts
215                        || head.artifact_cleanup != after.artifact_cleanup
216                        || head.assignment_drain_reservation != after.assignment_drain_reservation))
217            {
218                return Err(ClusterCheckpointAuthorityError::Fenced);
219            }
220            Ok(())
221        })
222        .await
223        .map_err(|_| DecisionError::Conflict("recovery topology audit timed out".into()))?
224    }
225
226    pub(super) async fn audit_recovery_topology_from(
227        &self,
228        head: &LeaderAuthorityRecord,
229        round: &RecoveryRound,
230        epoch: Option<u64>,
231        context: Option<(&AssignmentSnapshotStore, &ProcessLeaseAuthority)>,
232    ) -> Result<(), ClusterCheckpointAuthorityError> {
233        if round.topology_binding().is_none() {
234            head.reject_unbound_topology_recovery(epoch)?;
235        }
236        if !Self::recovery_topology_matches(head, round) {
237            return Err(DecisionError::Conflict(
238                "recovery round does not bind the current topology Commit".into(),
239            )
240            .into());
241        }
242        let Some(binding) = head.audited_topology_binding(round, epoch) else {
243            return Ok(());
244        };
245        let (assignments, processes) = context.ok_or_else(|| {
246            DecisionError::Conflict(
247                "topology recovery requires configured assignment and process authority".into(),
248            )
249        })?;
250        binding
251            .validate()
252            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
253        let operation = head
254            .committed_topology_operation()
255            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
256        self.audit_topology_operation(operation).await?;
257        let plan = self
258            .load_topology_plan(&operation.plan)
259            .await
260            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
261        let descriptor = self
262            .audit_topology_compatibility(&plan)
263            .await
264            .map_err(|error| DecisionError::Conflict(error.to_string()))?
265            .ok_or_else(|| DecisionError::Conflict("recovery topology has no descriptor".into()))?;
266        let assignment = assignments
267            .load()
268            .await
269            .map_err(|error| DecisionError::Conflict(error.to_string()))?
270            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
271        let fence = assignment
272            .assignment_fence()
273            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
274        if assignment.draining
275            || fence != round.assignment_fence
276            || fence.assignment_version < plan.assignment.assignment_version
277            || fence.assignment_digest != plan.assignment.assignment_digest
278            || fence.vnode_count != plan.assignment.vnode_count
279            || fence.partitioning_abi_version != plan.assignment.partitioning_abi_version
280            || !fence
281                .participants
282                .iter()
283                .map(|participant| participant.node_id)
284                .eq(plan
285                    .assignment
286                    .participants
287                    .iter()
288                    .map(|participant| participant.node_id))
289        {
290            return Err(DecisionError::Conflict("recovery topology requires the exact current boots and unchanged complete owner map".into()).into());
291        }
292        for process in binding.processes() {
293            self.require_topology_process(processes, *process)
294                .await
295                .map_err(|error| DecisionError::Conflict(error.to_string()))?;
296        }
297        let Some(epoch) = epoch else {
298            return Ok(());
299        };
300        if head.active_checkpoint_artifacts.is_some()
301            || head.artifact_cleanup.is_some()
302            || head.assignment_drain_reservation.is_some()
303        {
304            return Err(DecisionError::Conflict("topology recovery Start/install/Release requires settled checkpoint, cleanup and assignment authority".into()).into());
305        }
306        let root_ref = &operation
307            .migration_root
308            .as_ref()
309            .ok_or_else(|| DecisionError::Conflict("recovery topology has no root".into()))?
310            .root;
311        let root = self
312            .load_topology_root(root_ref)
313            .await
314            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
315        let committed_head = head.commit_head.ok_or_else(|| {
316            DecisionError::Conflict("recovery topology has no checkpoint Commit".into())
317        })?;
318        if epoch != committed_head.epoch {
319            return Err(DecisionError::Conflict(
320                "recovery Start/Release does not select the greatest topology checkpoint cut"
321                    .into(),
322            )
323            .into());
324        }
325        let (outcome, checkpoint) = self
326            .cluster_outcome_with_committed_checkpoint(epoch)
327            .await?
328            .ok_or_else(|| {
329                DecisionError::Conflict("recovery topology checkpoint outcome is missing".into())
330            })?;
331        let checkpoint = checkpoint.ok_or_else(|| {
332            DecisionError::Conflict("recovery topology cut is not committed".into())
333        })?;
334        let root_cut = committed_head.sequence == root.cut.authority_sequence;
335        let checkpoint_assignment = checkpoint
336            .assignment_fence
337            .as_ref()
338            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
339        if !root_cut
340            && (checkpoint_assignment.assignment_version < plan.assignment.assignment_version
341                || checkpoint_assignment.assignment_version > fence.assignment_version
342                || checkpoint_assignment.assignment_digest != fence.assignment_digest
343                || checkpoint_assignment.vnode_count != fence.vnode_count
344                || checkpoint_assignment.partitioning_abi_version != fence.partitioning_abi_version
345                || !checkpoint_assignment
346                    .participants
347                    .iter()
348                    .map(|participant| participant.node_id)
349                    .eq(fence
350                        .participants
351                        .iter()
352                        .map(|participant| participant.node_id)))
353        {
354            return Err(DecisionError::Conflict(
355                "target recovery checkpoint differs from the frozen owner map".into(),
356            )
357            .into());
358        }
359        if !outcome.is_commit()
360            || outcome.checkpoint_id != committed_head.checkpoint_id
361            || checkpoint.scope != CheckpointScope::Cluster
362            || checkpoint.deployment_id != descriptor.deployment_id
363            || (root_cut
364                && (outcome.committed_checkpoint.as_ref() != Some(&root.cut.checkpoint)
365                    || checkpoint.pipeline_identity != descriptor.parent_pipeline
366                    || checkpoint.assignment_fence.as_ref() != Some(&plan.assignment)))
367            || (!root_cut
368                && (committed_head.sequence <= binding.commit().authority_sequence
369                    || checkpoint.pipeline_identity != descriptor.target_pipeline
370                    || checkpoint.epoch <= root.cut.checkpoint.epoch))
371        {
372            return Err(DecisionError::Conflict(
373                "recovery cut is neither the exact migration root nor target checkpoint progress"
374                    .into(),
375            )
376            .into());
377        }
378        // Recovery's handoff pin keeps this exact cut alive until the first checkpoint from
379        // the replacement assignment. Waiting for it to disappear would prevent that runtime
380        // from ever starting. Retain the pin and accept only its exact target and payload.
381        if head.assignment_handoff_pin.as_ref().is_some_and(|pin| {
382            pin.target != fence || outcome.committed_checkpoint.as_ref() != Some(&pin.checkpoint)
383        }) {
384            return Err(DecisionError::Conflict(
385                "topology recovery handoff pin differs from its exact assignment or selected checkpoint"
386                    .into(),
387            )
388            .into());
389        }
390        Ok(())
391    }
392
393    pub(super) async fn audit_topology_recovery_terminal(
394        &self,
395        head: &LeaderAuthorityRecord,
396        terminal: &RecoveryAnnouncement,
397        context: Option<(&AssignmentSnapshotStore, &ProcessLeaseAuthority)>,
398    ) -> Result<(), ClusterCheckpointAuthorityError> {
399        self.audit_recovery_topology_from(head, &terminal.round, None, context)
400            .await
401    }
402
403    pub(super) fn release_recovered_topology(
404        candidate: &mut LeaderAuthorityRecord,
405        round: &RecoveryRound,
406        sequence: u64,
407    ) -> Result<(), ClusterCheckpointAuthorityError> {
408        let binding = round
409            .topology_binding()
410            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
411        let operation = candidate
412            .topology_operations
413            .iter_mut()
414            .find(|operation| operation.commit.as_ref() == Some(binding.commit()))
415            .ok_or(ClusterCheckpointAuthorityError::Fenced)?;
416        if operation.phase == crate::cluster::control::TopologyAdmissionPhase::Active {
417            // Never change the original installed-runtime UUIDs or released roster.
418            return Ok(());
419        }
420        let activation = operation.activation.as_mut().ok_or_else(|| {
421            DecisionError::Conflict("recovered target has no held installation evidence".into())
422        })?;
423        if activation.recovery_round != Some(round.id)
424            || activation.leader != round.leader_proof
425            || activation.assignment != round.assignment_fence
426            || activation.processes != binding.processes()
427            || !activation.installation_complete()
428            || activation.release.is_some()
429        {
430            return Err(DecisionError::Conflict("first target Release requires every exact recovered runtime under this stopped/restore round".into()).into());
431        }
432        activation.release = Some(crate::cluster::control::TopologyRelease {
433            authority_sequence: sequence,
434        });
435        operation.phase = crate::cluster::control::TopologyAdmissionPhase::Active;
436        operation.status_sequence = sequence;
437        Ok(())
438    }
439}