Skip to main content

laminar_core/cluster/control/leader_lease/
topology_cut.rs

1//! Exact old-graph cut admission and participant-complete sink settlement.
2
3use super::topology_admission::{
4    topology_assignment_error, topology_checkpoint_error, CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS,
5};
6use super::*;
7use crate::checkpoint::{CheckpointAttempt, CheckpointAttemptRelation, CheckpointParticipant};
8use crate::cluster::control::topology::{
9    TopologyAbortReason, TopologyAdmissionPhase, TopologyAdmissionStatus, TopologyCheckpointCut,
10    TopologyCutCommit, TopologyError, TopologyOperationId, TopologyPlanRef,
11};
12
13impl LeaderAuthorityRecord {
14    pub(super) fn validate_topology_checkpoint_inventory(
15        &self,
16        inventory: &CheckpointArtifactInventory,
17    ) -> Result<(), ClusterCheckpointAuthorityError> {
18        let Some(operation) = self
19            .topology_operations
20            .iter()
21            .find(|entry| entry.blocks_admission())
22        else {
23            return Ok(());
24        };
25        if operation.phase == TopologyAdmissionPhase::Quiescing
26            && operation
27                .cut
28                .as_ref()
29                .is_some_and(|cut| cut.inventory == *inventory && cut.committed.is_none())
30        {
31            return Ok(());
32        }
33        Err(DecisionError::Conflict("checkpoint is not the exact bound topology cut".into()).into())
34    }
35
36    pub(super) fn record_topology_cut_outcome(
37        &mut self,
38        outcome: &CheckpointOutcome,
39    ) -> Result<(), LeaseError> {
40        let Some(operation) = self
41            .topology_operations
42            .iter_mut()
43            .find(|entry| entry.is_preparing() && entry.cut.is_some())
44        else {
45            return Ok(());
46        };
47        let cut = operation
48            .cut
49            .as_mut()
50            .ok_or_else(|| LeaseError::Invalid("topology cut binding is missing".into()))?;
51        if cut.inventory.attempt != CheckpointAttempt::new(outcome.epoch, outcome.checkpoint_id)
52            || cut.inventory.assignment_fence != outcome.assignment_fence
53            || cut.inventory.deployment_id != outcome.deployment_id
54        {
55            return Err(LeaseError::Invalid(
56                "terminal checkpoint does not settle the exact topology cut".into(),
57            ));
58        }
59        if outcome.is_commit() {
60            cut.committed = Some(TopologyCutCommit {
61                checkpoint: outcome.committed_checkpoint.clone().ok_or_else(|| {
62                    LeaseError::Invalid("cut Commit has no checkpoint reference".into())
63                })?,
64                authority_sequence: self.lease.seq,
65            });
66        } else {
67            operation.phase = TopologyAdmissionPhase::Aborted {
68                reason: TopologyAbortReason::CheckpointAborted,
69            };
70        }
71        operation.status_sequence = self.lease.seq;
72        Ok(())
73    }
74
75    pub(super) fn topology_cut_blocks_cleanup(&self, _protected: &CommittedCheckpointRef) -> bool {
76        self.topology_operations.iter().any(|operation| {
77            // Before Release, no target checkpoint can replace the root's restore obligation.
78            // Active roots are retained by the cleanup stop boundary and live-state inventory.
79            operation.blocks_admission()
80        })
81    }
82}
83
84impl LeaderLeaseStore {
85    pub(super) async fn validate_committed_topology_checkpoint_inventory(
86        &self,
87        current: &LeaderAuthorityRecord,
88        inventory: &CheckpointArtifactInventory,
89    ) -> Result<(), ClusterCheckpointAuthorityError> {
90        let Some(operation) = current.committed_topology_operation() else {
91            return Ok(());
92        };
93        let plan = self
94            .load_topology_plan(&operation.plan)
95            .await
96            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
97        let descriptor = self
98            .audit_topology_compatibility(&plan)
99            .await
100            .map_err(|error| DecisionError::Conflict(error.to_string()))?
101            .ok_or_else(|| {
102                DecisionError::Conflict("committed topology has no state descriptor".into())
103            })?;
104        if inventory.pipeline_identity != descriptor.target_pipeline
105            || inventory.deployment_id != descriptor.deployment_id
106        {
107            return Err(DecisionError::Conflict(
108                "checkpoint does not belong to the committed target topology".into(),
109            )
110            .into());
111        }
112        Ok(())
113    }
114
115    /// Read the reserved old-graph cut owner for checkpoint control, under the exact leader proof.
116    /// This never validates candidate compatibility or grants target execution.
117    ///
118    /// # Errors
119    /// Rejects changed authority, assignment evidence, damaged plans, or a bounded read timeout.
120    pub async fn topology_checkpoint_operation(
121        &self,
122        proof: &LeaderProof,
123        assignment: &CheckpointAssignmentFence,
124    ) -> Result<Option<TopologyAdmissionStatus>, TopologyError> {
125        tokio::time::timeout(CONTROL_TIMEOUT, async {
126            let current = self.load_record().await?.ok_or(TopologyError::Fenced)?;
127            if !proof.is_canonical() || !current.lease.matches_proof(proof) {
128                return Err(TopologyError::Fenced);
129            }
130            let Some(operation) = current
131                .topology_operations
132                .iter()
133                .find(|entry| entry.is_preparing())
134            else {
135                return Ok(None);
136            };
137            let plan = self.load_topology_plan(&operation.plan).await?;
138            if plan.assignment != *assignment || operation.admitted_by != *proof {
139                return Err(TopologyError::Conflict(
140                    "topology cut assignment or leader changed".into(),
141                ));
142            }
143            self.audit_topology_operation(operation).await?;
144            Ok(Some(operation.clone()))
145        })
146        .await
147        .map_err(|_| TopologyError::ReadTimedOut)?
148    }
149
150    /// Atomically bind a reserved operation to its old-topology attempt and artifact inventory.
151    /// Sources remain open until this exact barrier reaches capture. No target actor is admitted.
152    /// The assignment store must be the controller's configured, namespace-verified store.
153    ///
154    /// # Errors
155    /// Rejects a stale proof, payload/attempt rebind, changed assignment, unresolved authority,
156    /// or more than 16 append attempts within 15 seconds. Retry the same binding after ambiguity.
157    pub async fn begin_topology_checkpoint_cut(
158        &self,
159        proof: &LeaderProof,
160        assignments: &AssignmentSnapshotStore,
161        processes: &crate::cluster::control::ProcessLeaseAuthority,
162        operation_id: TopologyOperationId,
163        expected_plan: &TopologyPlanRef,
164        inventory: CheckpointArtifactInventory,
165    ) -> Result<TopologyAdmissionStatus, TopologyError> {
166        inventory.validate().map_err(TopologyError::Invalid)?;
167        expected_plan.validate()?;
168        tokio::time::timeout(CONTROL_TIMEOUT, async {
169            for _ in 0..MAX_ADMISSION_ATTEMPTS {
170                let published = self.load_published_authority_head().await?.ok_or(TopologyError::Fenced)?;
171                let current = &published.record;
172                if !proof.is_canonical() || !current.lease.matches_proof(proof) {
173                    return Err(TopologyError::Fenced);
174                }
175                let index = current.topology_operations.iter().position(|entry| entry.operation_id == operation_id)
176                    .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
177                let operation = &current.topology_operations[index];
178                if operation.plan != *expected_plan {
179                    return Err(TopologyError::Conflict("operation payload differs".into()));
180                }
181                self.audit_topology_operation(operation).await?;
182                if let Some(cut) = &operation.cut {
183                    if cut.inventory != inventory {
184                        return Err(TopologyError::Conflict("topology operation is already bound to another exact checkpoint inventory".into()));
185                    }
186                    return Ok(operation.clone());
187                }
188                if !matches!(operation.phase, TopologyAdmissionPhase::Planned | TopologyAdmissionPhase::Preparing) || operation.admitted_by != *proof {
189                    return Err(TopologyError::Conflict("topology operation cannot start a cut in its current disposition".into()));
190                }
191                let plan = self.load_topology_plan(&operation.plan).await?;
192                let descriptor = self.require_topology_prepared(operation, &plan, processes).await?;
193                if inventory.pipeline_identity != descriptor.parent_pipeline {
194                    return Err(TopologyError::Conflict("cut pipeline differs from the certified parent".into()));
195                }
196                let baseline = current.topology_baseline.as_ref().ok_or_else(|| TopologyError::Invalid("cut has no adopted parent".into()))?;
197                if inventory.deployment_id != baseline.deployment_id || inventory.assignment_fence.as_ref() != Some(&plan.assignment)
198                    || current.committed_topology_identity() != Some((plan.expected_parent, &plan.parent_manifest))
199                {
200                    return Err(TopologyError::Conflict("cut inventory does not bind the admitted parent and assignment".into()));
201                }
202                if current.active_checkpoint_artifacts.is_some() || current.artifact_cleanup.is_some()
203                    || current.assignment_drain_reservation.is_some() || current.assignment_handoff_pin.is_some()
204                    || current.recovery_fault_slots.iter().any(|slot| slot.active || slot.disposition == RecoveryFaultDisposition::Terminal)
205                {
206                    return Err(TopologyError::Conflict("cut overlaps unresolved checkpoint, assignment, cleanup or recovery authority".into()));
207                }
208                let assignment = assignments.load().await.map_err(topology_assignment_error)?
209                    .ok_or_else(|| TopologyError::Conflict("cut assignment is missing".into()))?;
210                if assignment.draining || assignment.assignment_fence().map_err(|error| TopologyError::Invalid(error.to_string()))? != plan.assignment {
211                    return Err(TopologyError::Conflict("cut assignment or process roster changed".into()));
212                }
213                self.reject_consumed_checkpoint_assignment(current, &plan.assignment).await.map_err(topology_checkpoint_error)?;
214                if current.outcome_head.is_some_and(|head| inventory.attempt.relation_to(CheckpointAttempt::new(head.epoch, head.checkpoint_id)) != CheckpointAttemptRelation::Newer) {
215                    return Err(TopologyError::Conflict("topology cut does not advance the terminal checkpoint".into()));
216                }
217                let mut lease = current.lease.clone();
218                lease.seq = lease.seq.checked_add(1).ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
219                let sequence = lease.seq;
220                let mut next = current.preserve_with_lease(lease);
221                next.version = next.version.max(TOPOLOGY_CUT_RECORD_VERSION);
222                next.active_checkpoint_artifacts = Some(inventory.clone());
223                next.active_checkpoint_artifact_leader_proof = Some(proof.clone());
224                let operation = &mut next.topology_operations[index];
225                operation.phase = TopologyAdmissionPhase::Quiescing;
226                operation.status_sequence = sequence;
227                operation.cut = Some(TopologyCheckpointCut { inventory: inventory.clone(), bound_sequence: sequence, committed: None, completed_participants: Vec::new() });
228                let result = operation.clone();
229                match self.create_authority_record(Some(&published), &next).await? {
230                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => return Ok(result),
231                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
232                }
233            }
234            Err(TopologyError::Contended)
235        }).await.map_err(|_| TopologyError::Contended)?
236    }
237
238    /// Validate a reversible barrier against the same authority that owns its cut binding.
239    #[cfg(feature = "cluster")]
240    pub(in crate::cluster::control) async fn validate_topology_checkpoint_barrier(
241        &self,
242        proof: &LeaderProof,
243        attempt: CheckpointAttempt,
244        assignment: Option<&CheckpointAssignmentFence>,
245        flags: u64,
246    ) -> Result<(), LeaseError> {
247        let current = self
248            .load_record()
249            .await?
250            .ok_or_else(|| LeaseError::Invalid("no durable leader lease exists".into()))?;
251        if !current.lease.matches_proof(proof) {
252            return Err(LeaseError::Invalid(
253                "checkpoint does not match the latest durable leader lease".into(),
254            ));
255        }
256        let operation = current
257            .topology_operations
258            .iter()
259            .find(|entry| entry.blocks_admission());
260        if flags & crate::checkpoint::flags::TOPOLOGY_CUT == 0 {
261            return if operation.is_none() {
262                Ok(())
263            } else {
264                Err(LeaseError::Invalid(
265                    "ordinary barrier cannot cross a topology reservation".into(),
266                ))
267            };
268        }
269        let operation = operation.ok_or_else(|| {
270            LeaseError::Invalid("topology barrier has no reserved operation".into())
271        })?;
272        let cut = operation.cut.as_ref().ok_or_else(|| {
273            LeaseError::Invalid("topology barrier has no bound checkpoint".into())
274        })?;
275        if flags != crate::checkpoint::flags::TOPOLOGY_CUT
276            || operation.phase != TopologyAdmissionPhase::Quiescing
277            || operation.admitted_by != *proof
278            || cut.inventory.attempt != attempt
279            || assignment.is_none()
280            || cut.inventory.assignment_fence.as_ref() != assignment
281            || cut.committed.is_some()
282        {
283            return Err(LeaseError::Invalid(
284                "topology barrier differs from its exact live cut".into(),
285            ));
286        }
287        Ok(())
288    }
289
290    /// Record a process's completed old-cut tail through shared authority.
291    /// The leader receipt follows globally aggregated external sink settlement; followers report
292    /// local checkpoint application. The complete frozen roster includes that leader receipt.
293    /// The runtime caller must hold intake and sink succession and own a live process lease.
294    /// Receipts prove cut application, not actor retirement, state compatibility or target install.
295    ///
296    /// # Errors
297    /// Rejects stale authority, an uncommitted/different cut, changed process identity, recovery,
298    /// or bounded contention. A missing receipt or timeout leaves Quiescing visible.
299    pub async fn complete_topology_checkpoint_cut(
300        &self,
301        proof: &LeaderProof,
302        attempt: CheckpointAttempt,
303        participant: CheckpointParticipant,
304    ) -> Result<TopologyAdmissionStatus, TopologyError> {
305        tokio::time::timeout(CONTROL_TIMEOUT, async {
306            for _ in 0..MAX_ADMISSION_ATTEMPTS {
307                let published = self
308                    .load_published_authority_head()
309                    .await?
310                    .ok_or(TopologyError::Fenced)?;
311                let current = &published.record;
312                if !proof.is_canonical() || !current.lease.matches_proof(proof) {
313                    return Err(TopologyError::Fenced);
314                }
315                let index = current
316                    .topology_operations
317                    .iter()
318                    .position(|entry| {
319                        entry
320                            .cut
321                            .as_ref()
322                            .is_some_and(|cut| cut.inventory.attempt == attempt)
323                    })
324                    .ok_or_else(|| {
325                        TopologyError::Conflict("checkpoint is not a bound topology cut".into())
326                    })?;
327                let operation = &current.topology_operations[index];
328                let cut = operation
329                    .cut
330                    .as_ref()
331                    .ok_or_else(|| TopologyError::Invalid("cut binding is missing".into()))?;
332                if !operation.is_preparing()
333                    || operation.admitted_by != *proof
334                    || cut.inventory.assignment_fence.as_ref().is_none_or(|fence| {
335                        fence.participant_incarnation(participant.node_id)
336                            != Some(participant.boot_incarnation)
337                    })
338                    || current.recovery_fault_slots.iter().any(|slot| {
339                        slot.active || slot.disposition == RecoveryFaultDisposition::Terminal
340                    })
341                {
342                    return Err(TopologyError::Fenced);
343                }
344                self.audit_topology_operation(operation).await?;
345                if cut.committed.is_none() {
346                    return Err(TopologyError::Conflict(
347                        "cut has no definitive Commit; sink completion cannot certify it".into(),
348                    ));
349                }
350                if cut.completed_participants.contains(&participant) {
351                    return Ok(operation.clone());
352                }
353                let mut lease = current.lease.clone();
354                lease.seq = lease
355                    .seq
356                    .checked_add(1)
357                    .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
358                let sequence = lease.seq;
359                let mut next = current.preserve_with_lease(lease);
360                let operation = &mut next.topology_operations[index];
361                let cut = operation
362                    .cut
363                    .as_mut()
364                    .ok_or_else(|| TopologyError::Invalid("cut binding is missing".into()))?;
365                cut.completed_participants.push(participant);
366                cut.completed_participants
367                    .sort_unstable_by_key(|entry| entry.node_id);
368                if cut
369                    .inventory
370                    .assignment_fence
371                    .as_ref()
372                    .is_some_and(|fence| cut.completed_participants == fence.participants)
373                {
374                    operation.phase = TopologyAdmissionPhase::CutPrepared;
375                }
376                operation.status_sequence = sequence;
377                let result = operation.clone();
378                match self
379                    .create_authority_record(Some(&published), &next)
380                    .await?
381                {
382                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
383                        return Ok(result)
384                    }
385                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
386                }
387            }
388            Err(TopologyError::Contended)
389        })
390        .await
391        .map_err(|_| TopologyError::Contended)?
392    }
393
394    pub(super) async fn audit_topology_cut(
395        &self,
396        operation: &TopologyAdmissionStatus,
397    ) -> Result<(), LeaseError> {
398        let Some(cut) = &operation.cut else {
399            return Ok(());
400        };
401        let bound = read_authority_record(self.store.as_ref(), cut.bound_sequence)
402            .await?
403            .ok_or_else(|| {
404                LeaseError::Invalid("topology cut admission anchor is missing".into())
405            })?;
406        let anchored = bound
407            .topology_operations
408            .iter()
409            .find(|entry| entry.operation_id == operation.operation_id)
410            .ok_or_else(|| {
411                LeaseError::Invalid("topology cut is absent from its admission anchor".into())
412            })?;
413        if anchored.phase != TopologyAdmissionPhase::Quiescing
414            || anchored.plan != operation.plan
415            || anchored.admitted_by != operation.admitted_by
416            || anchored.admitted_sequence != operation.admitted_sequence
417            || anchored.status_sequence != cut.bound_sequence
418            || anchored.cut.as_ref().is_none_or(|entry| {
419                entry.inventory != cut.inventory
420                    || entry.bound_sequence != cut.bound_sequence
421                    || entry.committed.is_some()
422                    || !entry.completed_participants.is_empty()
423            })
424            || bound.active_checkpoint_artifacts.as_ref() != Some(&cut.inventory)
425            || bound.active_checkpoint_artifact_leader_proof.as_ref()
426                != Some(&operation.admitted_by)
427        {
428            return Err(LeaseError::Invalid(
429                "topology cut differs from its admitted checkpoint inventory".into(),
430            ));
431        }
432        if let Some(commit) = &cut.committed {
433            let record = read_authority_record(self.store.as_ref(), commit.authority_sequence)
434                .await?
435                .ok_or_else(|| {
436                    LeaseError::Invalid("topology cut Commit anchor is missing".into())
437                })?;
438            let outcome = record.checkpoint_outcome.as_ref().ok_or_else(|| {
439                LeaseError::Invalid("topology cut anchor has no terminal outcome".into())
440            })?;
441            if !outcome.is_commit()
442                || outcome.committed_checkpoint.as_ref() != Some(&commit.checkpoint)
443                || outcome.assignment_fence != cut.inventory.assignment_fence
444                || outcome.deployment_id != cut.inventory.deployment_id
445                || outcome.leader_proof.as_ref() != Some(&operation.admitted_by)
446                || record
447                    .topology_operations
448                    .iter()
449                    .find(|entry| entry.operation_id == operation.operation_id)
450                    .is_none_or(|entry| {
451                        entry.phase != TopologyAdmissionPhase::Quiescing
452                            || entry.status_sequence != commit.authority_sequence
453                            || entry.cut.as_ref().is_none_or(|anchored| {
454                                anchored.inventory != cut.inventory
455                                    || anchored.committed.as_ref() != Some(commit)
456                                    || !anchored.completed_participants.is_empty()
457                            })
458                    })
459            {
460                return Err(LeaseError::Invalid(
461                    "topology cut does not bind its exact definitive Commit".into(),
462                ));
463            }
464            if operation.blocks_admission() {
465                let index = CheckpointDecisionStore::new(self.store.clone())
466                    .load_committed_checkpoint(&commit.checkpoint)
467                    .await
468                    .map_err(|error| match error {
469                        DecisionError::Io(reason) => LeaseError::Io(reason),
470                        error => LeaseError::Invalid(error.to_string()),
471                    })?;
472                if index.pipeline_identity != cut.inventory.pipeline_identity
473                    || index.assignment_fence != cut.inventory.assignment_fence
474                    || index.deployment_id != cut.inventory.deployment_id
475                {
476                    return Err(LeaseError::Invalid(
477                        "topology cut index differs from its old pipeline identity".into(),
478                    ));
479                }
480            }
481        }
482        Ok(())
483    }
484}