Skip to main content

laminar_core/cluster/control/leader_lease/
topology_commit.rs

1//! Catalog Commit shares the existing authority append with the exact retained migration root.
2
3use super::topology_admission::{CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS};
4use super::{
5    read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome,
6    ClusterCheckpointAuthorityError, LeaderAuthorityRecord, LeaderLeaseStore, LeaseError,
7    RecoveryFaultDisposition, TOPOLOGY_COMMIT_RECORD_VERSION,
8};
9use crate::checkpoint::LeaderProof;
10use crate::checkpoint_decision::DecisionError;
11use crate::cluster::control::{
12    CatalogManifestRef, LegacyTopologyBaseline, ProcessLeaseAuthority, TopologyAdmissionPhase,
13    TopologyAdmissionPlan, TopologyAdmissionStatus, TopologyCommit, TopologyError,
14    TopologyRestoreInput, TopologyVersion, TOPOLOGY_COMMIT_PROTOCOL_VERSION,
15};
16
17impl LeaderAuthorityRecord {
18    pub(super) fn committed_topology_operation(&self) -> Option<&TopologyAdmissionStatus> {
19        self.topology_operations
20            .iter()
21            .rev()
22            .find(|operation| operation.commit.is_some())
23    }
24
25    /// The newest committed migration while it still owns assignment and recovery authority.
26    /// Its scope ends once the target is Active and a later checkpoint has committed: that cut
27    /// carries the target pipeline identity, so ordinary reassignment and recovery apply. Only
28    /// the newest Commit can be live because the next cut itself settles its predecessor.
29    pub(super) fn live_topology_migration(&self) -> Option<&TopologyAdmissionStatus> {
30        self.committed_topology_operation().filter(|operation| {
31            operation.phase != TopologyAdmissionPhase::Active
32                || self
33                    .commit_head
34                    .as_ref()
35                    .zip(operation.commit.as_ref())
36                    .is_none_or(|(head, commit)| head.sequence <= commit.authority_sequence)
37        })
38    }
39
40    pub(super) fn committed_topology_identity(
41        &self,
42    ) -> Option<(TopologyVersion, &CatalogManifestRef)> {
43        self.committed_topology_operation()
44            .and_then(|operation| operation.commit.as_ref())
45            .map(|commit| (commit.topology_version, &commit.manifest))
46            .or_else(|| {
47                self.topology_baseline
48                    .as_ref()
49                    .map(|baseline| (baseline.topology_version, &baseline.manifest))
50            })
51    }
52
53    pub(super) fn validate_committed_topology_chain(
54        &self,
55        baseline: &LegacyTopologyBaseline,
56    ) -> Result<(), LeaseError> {
57        let mut version = baseline.topology_version;
58        let mut manifest = &baseline.manifest;
59        let mut sequence = baseline.authority_sequence;
60        for operation in &self.topology_operations {
61            let Some(commit) = &operation.commit else {
62                continue;
63            };
64            commit
65                .validate()
66                .map_err(|error| LeaseError::Invalid(error.to_string()))?;
67            if self.version < TOPOLOGY_COMMIT_RECORD_VERSION
68                || commit.parent_version != version
69                || &commit.parent_manifest != manifest
70                || commit.authority_sequence <= sequence
71                || commit.authority_sequence > self.lease.seq
72            {
73                return Err(LeaseError::Invalid(
74                    "committed topology chain differs from its exact predecessor".into(),
75                ));
76            }
77            version = commit.topology_version;
78            manifest = &commit.manifest;
79            sequence = commit.authority_sequence;
80        }
81        if self.lease.catalog_manifest.as_ref() != Some(manifest) {
82            return Err(LeaseError::Invalid(
83                "catalog head differs from its committed topology decision".into(),
84            ));
85        }
86        Ok(())
87    }
88
89    pub(super) fn validate_committed_topology_successor(
90        &self,
91        next: &Self,
92    ) -> Result<(), LeaseError> {
93        let prior = self
94            .committed_topology_operation()
95            .and_then(|operation| operation.commit.as_ref());
96        let after = next
97            .committed_topology_operation()
98            .and_then(|operation| operation.commit.as_ref());
99        if prior == after {
100            // Initial legacy seal is the only inventory change without a topology Commit.
101            if self.lease.catalog_manifest.is_some()
102                && self.lease.catalog_manifest != next.lease.catalog_manifest
103            {
104                return Err(LeaseError::Invalid(
105                    "catalog replacement requires an atomic topology Commit".into(),
106                ));
107            }
108            return Ok(());
109        }
110        let commit = after.ok_or_else(|| {
111            LeaseError::Invalid("authority cannot forget a topology Commit".into())
112        })?;
113        let operation = next
114            .committed_topology_operation()
115            .ok_or_else(|| LeaseError::Invalid("Commit operation is missing".into()))?;
116        if next.version < TOPOLOGY_COMMIT_RECORD_VERSION
117            || self.committed_topology_identity()
118                != Some((commit.parent_version, &commit.parent_manifest))
119            || next.lease.catalog_manifest.as_ref() != Some(&commit.manifest)
120            || commit.authority_sequence != next.lease.seq
121            || !next.lease.matches_proof(&operation.admitted_by)
122            || self
123                .topology_operations
124                .iter()
125                .find(|entry| entry.operation_id == operation.operation_id)
126                .is_none_or(|entry| {
127                    entry.phase != TopologyAdmissionPhase::CutPrepared || entry.commit.is_some()
128                })
129        {
130            return Err(LeaseError::Invalid(
131                "new catalog and target Commit must share their exact authority append".into(),
132            ));
133        }
134        Ok(())
135    }
136
137    pub(super) fn reject_pending_topology_commit(
138        &self,
139    ) -> Result<(), ClusterCheckpointAuthorityError> {
140        if self.topology_operations.iter().any(|operation| {
141            operation.has_target_commit()
142                && (operation.phase != TopologyAdmissionPhase::Active
143                    || self.commit_head.as_ref().is_none_or(|head| {
144                        operation
145                            .cut
146                            .as_ref()
147                            .and_then(|cut| cut.committed.as_ref())
148                            .is_none_or(|cut| head.sequence <= cut.authority_sequence)
149                    }))
150        }) {
151            return Err(DecisionError::Conflict("committed topology requires target installation and Release; parent recovery cannot release intake".into()).into());
152        }
153        Ok(())
154    }
155
156    /// The binding a recovery audit must check against the frozen migration owner map. A settled
157    /// target no longer pins that map or its process roster, so consuming its historical Release
158    /// (no selected epoch) must not fail after an ordinary reassignment.
159    pub(super) fn audited_topology_binding<'a>(
160        &self,
161        round: &'a crate::cluster::control::RecoveryRound,
162        epoch: Option<u64>,
163    ) -> Option<&'a crate::cluster::control::TopologyRecoveryBinding> {
164        round
165            .topology_binding()
166            .filter(|_| epoch.is_some() || self.live_topology_migration().is_some())
167    }
168
169    /// An unbound round requires a settled migration and restores target progress, never the
170    /// parent root or anything older; those cuts carry a pipeline the committed catalog replaced.
171    pub(super) fn reject_unbound_topology_recovery(
172        &self,
173        epoch: Option<u64>,
174    ) -> Result<(), ClusterCheckpointAuthorityError> {
175        self.reject_pending_topology_commit()?;
176        let root_epoch = self
177            .committed_topology_operation()
178            .and_then(|operation| operation.cut.as_ref())
179            .and_then(|cut| cut.committed.as_ref())
180            .map(|cut| cut.checkpoint.epoch);
181        if epoch
182            .zip(root_epoch)
183            .is_some_and(|(epoch, root)| epoch <= root)
184        {
185            return Err(DecisionError::Conflict(
186                "committed topology cannot release its parent or an older cut".into(),
187            )
188            .into());
189        }
190        Ok(())
191    }
192}
193
194impl LeaderLeaseStore {
195    /// Atomically change the catalog to the exact restored/retired candidate and pin its root.
196    /// Every frozen process must have protocol-four preparation evidence and still hold its
197    /// original process/assignment fence. This decision is irreversible; it never opens intake.
198    /// Call through the configured controller after runtime-owned terminal observation.
199    ///
200    /// # Errors
201    /// Rejects stale leader/process/assignment, incomplete capabilities, changed image/root,
202    /// unresolved checkpoint/recovery/cleanup or 16 CAS attempts within 15 seconds. A cancelled
203    /// or uncertain write may have committed: query the same operation, never roll back or retry
204    /// with a new identity. Explicit committed-root reconstruction remains available afterward.
205    pub async fn commit_topology_target(
206        &self,
207        proof: &LeaderProof,
208        assignments: &AssignmentSnapshotStore,
209        processes: &ProcessLeaseAuthority,
210        input: &TopologyRestoreInput,
211    ) -> Result<TopologyAdmissionStatus, TopologyError> {
212        tokio::time::timeout(CONTROL_TIMEOUT, async {
213            for _ in 0..MAX_ADMISSION_ATTEMPTS {
214                let published = self.load_published_authority_head().await?.ok_or(TopologyError::Fenced)?;
215                let current = &published.record;
216                if !proof.is_canonical() || !current.lease.matches_proof(proof)
217                    || proof.owner.node_id != input.process().participant.node_id
218                    || proof.owner.boot_id != input.process().participant.boot_incarnation {
219                    return Err(TopologyError::Fenced);
220                }
221                let index = current.topology_operations.iter().position(|entry| entry.operation_id == input.operation().operation_id)
222                    .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
223                let operation = &current.topology_operations[index];
224                if operation.has_target_commit() {
225                    let fresh = self.committed_topology_restore_input(assignments, processes, operation.operation_id, input.process()).await?;
226                    if !fresh.is_committed_successor_of(input) {
227                        return Err(TopologyError::Fenced);
228                    }
229                    return Ok(fresh.operation().clone());
230                }
231                if operation.phase != TopologyAdmissionPhase::CutPrepared || operation.admitted_by != *proof
232                    || !operation.same_restore_binding(input.operation()) {
233                    return Err(TopologyError::Fenced);
234                }
235                if !operation.target_preparation_complete() || operation.target_preparations.iter().any(|receipt| receipt.protocol_version != TOPOLOGY_COMMIT_PROTOCOL_VERSION) {
236                    return Err(TopologyError::Protocol("Commit requires protocol four from every frozen owner/evidence process; protocol-three observations cannot be upgraded in place".into()));
237                }
238                if current.committed_topology_identity() != Some((input.plan().expected_parent, &input.plan().parent_manifest))
239                    || current.active_checkpoint_artifacts.is_some() || current.artifact_cleanup.is_some()
240                    || current.assignment_drain_reservation.is_some() || current.assignment_handoff_pin.is_some()
241                    || current.recovery_fault_slots.iter().any(|slot| slot.active || slot.disposition == RecoveryFaultDisposition::Terminal)
242                    || current.commit_head.as_ref().is_none_or(|head| head.sequence != input.root().cut.authority_sequence) {
243                    return Err(TopologyError::Conflict("Commit requires the exact held parent and settled cut without competing authority".into()));
244                }
245                let fresh = self.topology_restore_input(assignments, processes, operation.operation_id, input.process()).await?;
246                if !fresh.same_restore_requirements(input) { return Err(TopologyError::Fenced); }
247                let mut lease = current.lease.clone();
248                lease.seq = lease.seq.checked_add(1).ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
249                lease.catalog_manifest = Some(input.plan().target_manifest.clone());
250                let sequence = lease.seq;
251                let mut next = current.preserve_with_lease(lease);
252                next.version = next.version.max(TOPOLOGY_COMMIT_RECORD_VERSION);
253                let operation = &mut next.topology_operations[index];
254                operation.phase = TopologyAdmissionPhase::Committed;
255                operation.status_sequence = sequence;
256                operation.commit = Some(TopologyCommit {
257                    protocol_version: TOPOLOGY_COMMIT_PROTOCOL_VERSION,
258                    operation_id: operation.operation_id,
259                    parent_version: input.plan().expected_parent,
260                    parent_manifest: input.plan().parent_manifest.clone(),
261                    topology_version: input.descriptor().target_version,
262                    manifest: input.plan().target_manifest.clone(),
263                    authority_sequence: sequence,
264                });
265                match self.create_authority_record(Some(&published), &next).await? {
266                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
267                        let fresh = self.committed_topology_restore_input(assignments, processes, input.operation().operation_id, input.process()).await?;
268                        if !fresh.is_committed_successor_of(input) { return Err(TopologyError::Fenced); }
269                        return Ok(fresh.operation().clone());
270                    }
271                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
272                }
273            }
274            Err(TopologyError::Contended)
275        }).await.map_err(|_| TopologyError::Contended)?
276    }
277
278    pub(super) async fn audit_topology_commit(
279        &self,
280        operation: &TopologyAdmissionStatus,
281        plan: &TopologyAdmissionPlan,
282    ) -> Result<(), LeaseError> {
283        let Some(commit) = &operation.commit else {
284            return Ok(());
285        };
286        let record = read_authority_record(self.store.as_ref(), commit.authority_sequence)
287            .await?
288            .ok_or_else(|| {
289                LeaseError::Invalid("topology Commit authority anchor is missing".into())
290            })?;
291        if commit.parent_version != plan.expected_parent
292            || commit.parent_manifest != plan.parent_manifest
293            || commit.manifest != plan.target_manifest
294            || record.version < TOPOLOGY_COMMIT_RECORD_VERSION
295            || record.lease.catalog_manifest.as_ref() != Some(&commit.manifest)
296            || !record.lease.matches_proof(&operation.admitted_by)
297            || record
298                .topology_operations
299                .iter()
300                .find(|entry| entry.operation_id == operation.operation_id)
301                .is_none_or(|anchored| {
302                    anchored.phase != TopologyAdmissionPhase::Committed
303                        || anchored.commit != operation.commit
304                        || !anchored.same_migration_binding(operation)
305                        || anchored.target_preparations != operation.target_preparations
306                        || anchored.activation.is_some()
307                        || anchored.status_sequence != commit.authority_sequence
308                })
309        {
310            return Err(LeaseError::Invalid(
311                "topology Commit differs from its atomic catalog/root append".into(),
312            ));
313        }
314        Ok(())
315    }
316}