Skip to main content

laminar_core/cluster/control/leader_lease/
topology_preparation.rs

1//! Candidate binding and exact-process preparation use the existing fenced authority append.
2
3use super::topology_admission::{
4    topology_assignment_error, CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS,
5};
6use super::{
7    read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome, CatalogManifest,
8    LeaderLeaseStore, LeaseError, OsPath,
9};
10use crate::cluster::control::topology::{
11    ClusterTopologyValidation, TopologyAdmissionPhase, TopologyAdmissionPlan,
12    TopologyAdmissionStatus, TopologyCompatibilityRef, TopologyError, TopologyOperationId,
13    TopologyParticipantCertificate, TopologyPlanRef, TOPOLOGY_PREPARATION_PROTOCOL_VERSION,
14    TOPOLOGY_SUBMISSION_PROTOCOL_VERSION,
15};
16use crate::cluster::control::{LocalProcessAuthorityIdentity, ProcessLeaseAuthority};
17
18fn compatibility_path(reference: &TopologyCompatibilityRef) -> OsPath {
19    OsPath::from(format!(
20        "control/topology-compatibility/v1/{}.json",
21        reference.sha256
22    ))
23}
24
25impl LeaderLeaseStore {
26    /// Stage the canonical local report without admitting a request or authorizing execution.
27    ///
28    /// # Errors
29    /// Rejects malformed/oversized evidence, divergent immutable bytes or a 15 second deadline.
30    pub async fn stage_topology_compatibility(
31        &self,
32        descriptor: &ClusterTopologyValidation,
33    ) -> Result<TopologyCompatibilityRef, TopologyError> {
34        let (bytes, reference) = descriptor.encode_and_reference()?;
35        tokio::time::timeout(
36            CONTROL_TIMEOUT,
37            self.stage_admission_blob(&compatibility_path(&reference), &bytes),
38        )
39        .await
40        .map_err(|_| TopologyError::Contended)??;
41        Ok(reference)
42    }
43
44    pub(super) async fn load_topology_compatibility(
45        &self,
46        reference: &TopologyCompatibilityRef,
47    ) -> Result<ClusterTopologyValidation, TopologyError> {
48        reference.validate()?;
49        let bytes = self
50            .read_admission_blob(&compatibility_path(reference), reference.encoded_len)
51            .await?;
52        let descriptor: ClusterTopologyValidation = serde_json::from_slice(&bytes)
53            .map_err(|error| TopologyError::Invalid(error.to_string()))?;
54        let (canonical, actual) = descriptor.encode_and_reference()?;
55        if actual != *reference || canonical.as_slice() != bytes.as_ref() {
56            return Err(TopologyError::Invalid(
57                "descriptor differs from its immutable canonical reference".into(),
58            ));
59        }
60        Ok(descriptor)
61    }
62
63    pub(super) async fn audit_topology_compatibility(
64        &self,
65        plan: &TopologyAdmissionPlan,
66    ) -> Result<Option<ClusterTopologyValidation>, TopologyError> {
67        let Some(reference) = &plan.compatibility else {
68            return Ok(None);
69        };
70        let descriptor = self.load_topology_compatibility(reference).await?;
71        if descriptor.parent_version != plan.expected_parent
72            || descriptor.parent_manifest != plan.parent_manifest
73            || descriptor.target_manifest != plan.target_manifest
74        {
75            return Err(TopologyError::Invalid(
76                "descriptor is not bound to the admitted catalog parent/target".into(),
77            ));
78        }
79        let parent = self.load_catalog_manifest(&plan.parent_manifest).await?;
80        let target = self.load_catalog_manifest(&plan.target_manifest).await?;
81        descriptor.validate_catalogs(&parent, &target)?;
82        Ok(Some(descriptor))
83    }
84
85    /// Read a complete immutable preparation input; participants independently compile its target.
86    ///
87    /// # Errors
88    /// Rejects unknown requests, legacy protocols, damaged bindings and a 15 second read deadline.
89    pub async fn topology_preparation_input(
90        &self,
91        operation_id: TopologyOperationId,
92    ) -> Result<
93        (
94            TopologyAdmissionStatus,
95            TopologyAdmissionPlan,
96            CatalogManifest,
97            ClusterTopologyValidation,
98        ),
99        TopologyError,
100    > {
101        tokio::time::timeout(CONTROL_TIMEOUT, async {
102            let current = self.load_record().await?.ok_or(TopologyError::Fenced)?;
103            let operation = current
104                .topology_operations
105                .iter()
106                .find(|entry| entry.operation_id == operation_id)
107                .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
108            self.audit_topology_operation(operation).await?;
109            let plan = self.load_topology_plan(&operation.plan).await?;
110            let descriptor = self
111                .audit_topology_compatibility(&plan)
112                .await?
113                .ok_or_else(|| {
114                    TopologyError::Protocol(
115                        "legacy plan has no participant compatibility binding".into(),
116                    )
117                })?;
118            let target = self.load_catalog_manifest(&plan.target_manifest).await?;
119            Ok((operation.clone(), plan, target, descriptor))
120        })
121        .await
122        .map_err(|_| TopologyError::ReadTimedOut)?
123    }
124
125    pub(super) async fn require_topology_process(
126        &self,
127        processes: &ProcessLeaseAuthority,
128        identity: LocalProcessAuthorityIdentity,
129    ) -> Result<(), TopologyError> {
130        if !identity.is_canonical() {
131            return Err(TopologyError::Fenced);
132        }
133        // The process namespace is configured separately from checkpoint/catalog storage.
134        // Use its existing exact-term fence verifier, never infer a store from this authority.
135        let current = processes
136            .verify_current_participant_term(
137                identity.participant,
138                identity.process_term,
139                tokio::time::Instant::now() + CONTROL_TIMEOUT,
140            )
141            .await
142            .map_err(|error| TopologyError::Authority(LeaseError::Io(error.to_string())))?;
143        if !current {
144            return Err(TopologyError::Fenced);
145        }
146        Ok(())
147    }
148
149    pub(super) async fn require_topology_prepared(
150        &self,
151        operation: &TopologyAdmissionStatus,
152        plan: &TopologyAdmissionPlan,
153        processes: &ProcessLeaseAuthority,
154    ) -> Result<ClusterTopologyValidation, TopologyError> {
155        let descriptor = self
156            .audit_topology_compatibility(plan)
157            .await?
158            .ok_or_else(|| {
159                TopologyError::Protocol(
160                    "new cuts require preparation protocol two and a durable candidate descriptor"
161                        .into(),
162                )
163            })?;
164        let preparation = operation.preparation.as_ref().ok_or_else(|| {
165            TopologyError::Protocol("operation has no durable preparation".into())
166        })?;
167        if preparation.complete_sequence.is_none()
168            || preparation
169                .certificates
170                .iter()
171                .map(|cert| cert.participant)
172                .collect::<Vec<_>>()
173                != plan.assignment.participants
174        {
175            return Err(TopologyError::Conflict(
176                "every frozen owner/evidence process must certify the candidate before the cut"
177                    .into(),
178            ));
179        }
180        for certificate in &preparation.certificates {
181            self.require_topology_process(
182                processes,
183                LocalProcessAuthorityIdentity {
184                    participant: certificate.participant,
185                    process_term: certificate.process_term,
186                },
187            )
188            .await?;
189        }
190        Ok(descriptor)
191    }
192
193    /// Persist one participant's independently compiled descriptor using the operation's leader
194    /// and exact frozen assignment. Call through the local controller after checking live process
195    /// authority around compilation. A certificate does not authorize restore or target output.
196    ///
197    /// # Errors
198    /// Rejects divergence, missing capability, stale process/assignment/leader, changed payload,
199    /// aborted requests, or 16 CAS attempts within 15 seconds. Retry identical evidence on ambiguity.
200    #[allow(clippy::too_many_arguments)] // Explicit independent control authorities and exact request evidence.
201    pub async fn certify_topology_participant(
202        &self,
203        assignments: &AssignmentSnapshotStore,
204        processes: &ProcessLeaseAuthority,
205        operation_id: TopologyOperationId,
206        expected_plan: &TopologyPlanRef,
207        identity: LocalProcessAuthorityIdentity,
208        protocol_version: u16,
209        compiled: &ClusterTopologyValidation,
210    ) -> Result<TopologyAdmissionStatus, TopologyError> {
211        if !matches!(
212            protocol_version,
213            TOPOLOGY_PREPARATION_PROTOCOL_VERSION | TOPOLOGY_SUBMISSION_PROTOCOL_VERSION
214        ) {
215            return Err(TopologyError::Protocol(
216                "participant lacks the exact preparation protocol".into(),
217            ));
218        }
219        let (_, compiled_ref) = compiled.encode_and_reference()?;
220        expected_plan.validate()?;
221        tokio::time::timeout(CONTROL_TIMEOUT, async {
222            for _ in 0..MAX_ADMISSION_ATTEMPTS {
223                let published = self
224                    .load_published_authority_head()
225                    .await?
226                    .ok_or(TopologyError::Fenced)?;
227                let current = &published.record;
228                let index = current
229                    .topology_operations
230                    .iter()
231                    .position(|entry| entry.operation_id == operation_id)
232                    .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
233                let operation = &current.topology_operations[index];
234                if operation.plan != *expected_plan {
235                    return Err(TopologyError::Conflict(
236                        "certificate request payload differs".into(),
237                    ));
238                }
239                if !current.lease.matches_proof(&operation.admitted_by) || !operation.is_preparing()
240                {
241                    return Err(TopologyError::Fenced);
242                }
243                let plan = self.load_topology_plan(expected_plan).await?;
244                if plan.compatibility.as_ref() != Some(&compiled_ref)
245                    || plan.protocol_version != protocol_version
246                {
247                    return Err(TopologyError::Conflict(
248                        "participant compiled a divergent candidate descriptor".into(),
249                    ));
250                }
251                if plan
252                    .assignment
253                    .participant_incarnation(identity.participant.node_id)
254                    != Some(identity.participant.boot_incarnation)
255                {
256                    return Err(TopologyError::Fenced);
257                }
258                self.audit_topology_operation(operation).await?;
259                self.require_topology_process(processes, identity).await?;
260                let assignment = assignments
261                    .load()
262                    .await
263                    .map_err(topology_assignment_error)?
264                    .ok_or(TopologyError::Fenced)?;
265                if assignment.draining
266                    || assignment
267                        .assignment_fence()
268                        .map_err(|error| TopologyError::Invalid(error.to_string()))?
269                        != plan.assignment
270                {
271                    return Err(TopologyError::Conflict(
272                        "participant assignment or frozen roster changed".into(),
273                    ));
274                }
275                self.reject_consumed_checkpoint_assignment(current, &plan.assignment)
276                    .await
277                    .map_err(super::topology_admission::topology_checkpoint_error)?;
278                let preparation = operation.preparation.as_ref().ok_or_else(|| {
279                    TopologyError::Protocol("operation has no preparation binding".into())
280                })?;
281                if let Some(existing) = preparation
282                    .certificates
283                    .iter()
284                    .find(|cert| cert.participant.node_id == identity.participant.node_id)
285                {
286                    if existing.participant != identity.participant
287                        || existing.process_term != identity.process_term
288                        || existing.protocol_version != protocol_version
289                    {
290                        return Err(TopologyError::Fenced);
291                    }
292                    return Ok(operation.clone());
293                }
294                if !matches!(
295                    operation.phase,
296                    TopologyAdmissionPhase::Planned | TopologyAdmissionPhase::Preparing
297                ) {
298                    return Err(TopologyError::Conflict(
299                        "cut cannot acquire new preparation certificates".into(),
300                    ));
301                }
302                let mut lease = current.lease.clone();
303                lease.seq = lease
304                    .seq
305                    .checked_add(1)
306                    .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
307                let sequence = lease.seq;
308                let mut next = current.preserve_with_lease(lease);
309                let operation = &mut next.topology_operations[index];
310                operation.phase = TopologyAdmissionPhase::Preparing;
311                operation.status_sequence = sequence;
312                let preparation = operation
313                    .preparation
314                    .as_mut()
315                    .ok_or_else(|| TopologyError::Invalid("preparation binding lost".into()))?;
316                preparation
317                    .certificates
318                    .push(TopologyParticipantCertificate {
319                        participant: identity.participant,
320                        process_term: identity.process_term,
321                        protocol_version,
322                        authority_sequence: sequence,
323                    });
324                preparation
325                    .certificates
326                    .sort_by_key(|cert| cert.participant.node_id);
327                if preparation.certificates.len() == plan.assignment.participants.len() {
328                    preparation.complete_sequence = Some(sequence);
329                }
330                let result = operation.clone();
331                match self
332                    .create_authority_record(Some(&published), &next)
333                    .await?
334                {
335                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
336                        self.require_topology_process(processes, identity).await?;
337                        return Ok(result);
338                    }
339                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
340                }
341            }
342            Err(TopologyError::Contended)
343        })
344        .await
345        .map_err(|_| TopologyError::Contended)?
346    }
347
348    pub(super) async fn audit_topology_preparation(
349        &self,
350        operation: &TopologyAdmissionStatus,
351        plan: &TopologyAdmissionPlan,
352    ) -> Result<Option<ClusterTopologyValidation>, TopologyError> {
353        match (&plan.compatibility, &operation.preparation) {
354            (None, None) => return Ok(None),
355            (Some(reference), Some(preparation)) if reference == &preparation.compatibility => {}
356            _ => {
357                return Err(TopologyError::Invalid(
358                    "operation changed its admitted descriptor binding".into(),
359                ))
360            }
361        }
362        let descriptor = self
363            .audit_topology_compatibility(plan)
364            .await?
365            .ok_or_else(|| TopologyError::Invalid("missing descriptor".into()))?;
366        let preparation = operation
367            .preparation
368            .as_ref()
369            .ok_or_else(|| TopologyError::Invalid("missing preparation".into()))?;
370        if preparation.certificates.iter().any(|cert| {
371            cert.protocol_version != plan.protocol_version
372                || plan
373                    .assignment
374                    .participant_incarnation(cert.participant.node_id)
375                    != Some(cert.participant.boot_incarnation)
376        }) || preparation.complete_sequence.is_some()
377            != (preparation.certificates.len() == plan.assignment.participants.len())
378            || operation.cut.as_ref().is_some_and(|cut| {
379                cut.inventory.pipeline_identity != descriptor.parent_pipeline
380                    || cut.inventory.deployment_id != descriptor.deployment_id
381            })
382        {
383            return Err(TopologyError::Invalid(
384                "preparation does not bind the frozen roster and parent pipeline".into(),
385            ));
386        }
387        for certificate in &preparation.certificates {
388            let record = read_authority_record(self.store.as_ref(), certificate.authority_sequence)
389                .await?
390                .ok_or_else(|| {
391                    TopologyError::Invalid("preparation authority anchor is missing".into())
392                })?;
393            if record
394                .topology_operations
395                .iter()
396                .find(|entry| entry.operation_id == operation.operation_id)
397                .is_none_or(|anchored| {
398                    anchored.plan != operation.plan
399                        || anchored.admitted_by != operation.admitted_by
400                        || anchored.admitted_sequence != operation.admitted_sequence
401                        || anchored.status_sequence != certificate.authority_sequence
402                        || anchored.phase != TopologyAdmissionPhase::Preparing
403                        || anchored.preparation.as_ref().is_none_or(|prepared| {
404                            prepared.compatibility != preparation.compatibility
405                                || !prepared.certificates.contains(certificate)
406                        })
407                })
408            {
409                return Err(TopologyError::Invalid(
410                    "certificate differs from its immutable authority append".into(),
411                ));
412            }
413        }
414        Ok(Some(descriptor))
415    }
416}