laminar_core/cluster/control/leader_lease/
topology_recovery_round.rs1use 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 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 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 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 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}