Skip to main content

laminar_core/cluster/control/leader_lease/
topology_migration_root.rs

1//! Bounded exact-cut metadata staging through the existing shared authority append.
2
3use super::topology_admission::{
4    topology_assignment_error, topology_checkpoint_error, CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS,
5};
6use super::{
7    read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome, LeaderLeaseStore,
8    OsPath, TOPOLOGY_MIGRATION_ROOT_RECORD_VERSION, TOPOLOGY_SOURCE_ROOT_RECORD_VERSION,
9};
10use crate::checkpoint::{CheckpointStore, CommittedCheckpointIndex, LeaderProof};
11use crate::checkpoint_decision::CheckpointDecisionStore;
12use crate::cluster::control::{
13    CatalogManifest, ProcessLeaseAuthority, TopologyAdmissionPhase, TopologyAdmissionStatus,
14    TopologyError, TopologyMigrationRoot, TopologyMigrationRootBinding, TopologyMigrationRootRef,
15    TopologyOperationId, TopologyPlanRef, TopologySourceInitialization,
16    MAX_TOPOLOGY_ROOT_MANIFEST_BYTES,
17};
18use object_store::{ObjectStoreExt, PutMode, PutOptions, PutPayload};
19use std::future::Future;
20
21fn root_path(reference: &TopologyMigrationRootRef) -> OsPath {
22    OsPath::from(format!(
23        "control/topology-migration-roots/v1/{}.json",
24        reference.sha256
25    ))
26}
27
28// Create-only staging, not a second authority head. The first successfully sealed vector survives
29// a disconnect before the shared append. A replacement leader aborts this pre-commit operation;
30// it cannot use the slot to commit or run a target. Existing artifact cleanup never sweeps this
31// control prefix. Root publication retains the content-addressed body independently of the slot.
32fn source_root_slot(operation: &TopologyAdmissionStatus) -> OsPath {
33    OsPath::from(format!(
34        "control/topology-source-root-staging/v1/{}/{}.json",
35        operation.operation_id.get(),
36        operation.plan.sha256,
37    ))
38}
39
40impl LeaderLeaseStore {
41    async fn build_topology_root(
42        &self,
43        checkpoint_store: &dyn CheckpointStore,
44        operation: &TopologyAdmissionStatus,
45        descriptor: &crate::cluster::control::ClusterTopologyValidation,
46        sources: Vec<TopologySourceInitialization>,
47    ) -> Result<TopologyMigrationRoot, TopologyError> {
48        TopologyMigrationRoot::object_mappings(descriptor)?;
49        let reference = &operation
50            .cut
51            .as_ref()
52            .and_then(|cut| cut.committed.as_ref())
53            .ok_or_else(|| TopologyError::Conflict("root requires a committed parent cut".into()))?
54            .checkpoint;
55        let index = CheckpointDecisionStore::new(self.store.clone())
56            .load_committed_checkpoint(reference)
57            .await
58            .map_err(|error| TopologyError::Invalid(error.to_string()))?;
59        validate_manifest_budget(&index)?;
60        if checkpoint_store.key_group_count().get() != index.vnode_count {
61            return Err(TopologyError::Invalid(
62                "root reader has a different vnode domain".into(),
63            ));
64        }
65        // Sequential reads keep the aggregate 16 MiB metadata budget explicit. No state, sink
66        // payload or Arrow output segment is loaded, cloned, restored or rewritten here.
67        let mut manifests = Vec::with_capacity(index.participants.len());
68        for participant in &index.participants {
69            let manifest = checkpoint_store
70                .load_manifest_verified(
71                    participant.participant_id,
72                    index.checkpoint_id,
73                    participant.manifest_len,
74                    &participant.manifest_sha256,
75                )
76                .await
77                .map_err(|e| TopologyError::Invalid(e.to_string()))?
78                .ok_or_else(|| {
79                    TopologyError::Invalid("migration root participant manifest is missing".into())
80                })?;
81            manifests.push(manifest);
82        }
83        if sources.is_empty() {
84            TopologyMigrationRoot::build(operation, descriptor, &index, &manifests)
85        } else {
86            TopologyMigrationRoot::build_with_sources(
87                operation, descriptor, &index, &manifests, sources,
88            )
89        }
90    }
91
92    /// Pin exact-cut restore requirements for a certified topology candidate.
93    /// Call through the live controller with its configured authorities and checkpoint reader.
94    /// No candidate actor, source, output, target catalog Commit or Release is authorized.
95    /// Identical retries return the retained append, including after a lost response.
96    ///
97    /// # Errors
98    /// Requires the full certified/current roster, unchanged assignment, current admitting leader
99    /// and `CutPrepared`. Rejects unresolved new sources, damaged metadata, aggregate manifests over
100    /// 16 MiB, roots over 1 MiB or 16 CAS attempts within 15 seconds. Cancellation can leave a
101    /// successful immutable append; query status or retry the same request identity.
102    #[allow(clippy::too_many_arguments)] // Existing independently configured authority domains.
103    pub async fn stage_topology_migration_root(
104        &self,
105        proof: &LeaderProof,
106        assignments: &AssignmentSnapshotStore,
107        processes: &ProcessLeaseAuthority,
108        checkpoint_store: &dyn CheckpointStore,
109        operation_id: TopologyOperationId,
110        expected_plan: &TopologyPlanRef,
111    ) -> Result<TopologyAdmissionStatus, TopologyError> {
112        self.stage_topology_migration_root_with_initialization(
113            proof,
114            assignments,
115            processes,
116            checkpoint_store,
117            operation_id,
118            expected_plan,
119            |_, _| async {
120                Err(TopologyError::Unsupported(
121                    "new sources require the configured connector initialization path".into(),
122                ))
123            },
124        )
125        .await
126    }
127
128    /// Stage a root with connector-owned new-source cursors resolved by the DB control path.
129    /// The callback runs only on the admitting leader after full preparation/assignment checks,
130    /// only if no sealed source root exists, and at most once in this call. It must not start or
131    /// consume sources or create sink effects. The first create-only sealed vector wins concurrent
132    /// attempts; all callers use that vector. Reads before a successful seal grant no boundary.
133    /// Cancellation after sealing, lost responses and CAS retries never reevaluate that vector.
134    /// No target Commit, restore or output permit is granted by either the slot or the shared append.
135    ///
136    /// # Errors
137    /// Requires the same exact certified held cut as downstream-only staging. Rejects damaged or
138    /// divergent slots, unsupported connector positions, stale authority and exceeded size/deadline
139    /// bounds. Query the same operation after an ambiguous outcome; never invent another identity.
140    #[allow(clippy::too_many_arguments)] // Existing authorities and one connector control callback.
141    pub async fn stage_topology_migration_root_with_initialization<F, Fut>(
142        &self,
143        proof: &LeaderProof,
144        assignments: &AssignmentSnapshotStore,
145        processes: &ProcessLeaseAuthority,
146        checkpoint_store: &dyn CheckpointStore,
147        operation_id: TopologyOperationId,
148        expected_plan: &TopologyPlanRef,
149        initialize: F,
150    ) -> Result<TopologyAdmissionStatus, TopologyError>
151    where
152        F: FnOnce(CatalogManifest, crate::cluster::control::ClusterTopologyValidation) -> Fut,
153        Fut: Future<Output = Result<Vec<TopologySourceInitialization>, TopologyError>>,
154    {
155        expected_plan.validate()?;
156        tokio::time::timeout(CONTROL_TIMEOUT, async {
157            let mut staged = None;
158            let mut initialize = Some(initialize);
159            for _ in 0..MAX_ADMISSION_ATTEMPTS {
160                let published = self
161                    .load_published_authority_head()
162                    .await?
163                    .ok_or(TopologyError::Fenced)?;
164                let current = &published.record;
165                let index = current
166                    .topology_operations
167                    .iter()
168                    .position(|entry| entry.operation_id == operation_id)
169                    .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
170                let operation = &current.topology_operations[index];
171                if !current.lease.matches_proof(proof) || operation.admitted_by != *proof {
172                    return Err(TopologyError::Fenced);
173                }
174                if operation.plan != *expected_plan
175                    || operation.phase != TopologyAdmissionPhase::CutPrepared
176                {
177                    return Err(TopologyError::Conflict(
178                        "root staging requires the exact prepared request payload".into(),
179                    ));
180                }
181                self.audit_topology_operation(operation).await?;
182                let plan = self.load_topology_plan(expected_plan).await?;
183                let descriptor = self
184                    .require_topology_prepared(operation, &plan, processes)
185                    .await?;
186                let assignment = assignments
187                    .load()
188                    .await
189                    .map_err(topology_assignment_error)?
190                    .ok_or(TopologyError::Fenced)?;
191                if assignment.draining
192                    || assignment
193                        .assignment_fence()
194                        .map_err(|e| TopologyError::Invalid(e.to_string()))?
195                        != plan.assignment
196                {
197                    return Err(TopologyError::Fenced);
198                }
199                self.reject_consumed_checkpoint_assignment(current, &plan.assignment)
200                    .await
201                    .map_err(topology_checkpoint_error)?;
202                if operation.migration_root.is_some() {
203                    return Ok(operation.clone());
204                }
205                if staged.is_none() {
206                    let has_sources = descriptor.objects.iter().any(|object| {
207                        object.kind == crate::cluster::control::CatalogObjectKind::Source
208                            && object.transition == crate::cluster::control::topology::ClusterTopologyObjectTransition::AddFutureOnly
209                    });
210                    let sealed = if has_sources {
211                        self.load_source_root_slot(operation, &plan, &descriptor).await?
212                    } else { None };
213                    let sources = if let Some(root) = &sealed {
214                        root.source_initializations.clone()
215                    } else if has_sources {
216                        let target = self.load_catalog_manifest(&plan.target_manifest).await
217                            .map_err(TopologyError::from)?;
218                        initialize.take().ok_or_else(|| TopologyError::Invalid(
219                            "source initialization callback was already consumed".into(),
220                        ))?(target, descriptor.clone()).await?
221                    } else { Vec::new() };
222                    let root = self.build_topology_root(checkpoint_store, operation, &descriptor, sources).await?;
223                    root.validate_binding(operation, &plan, &descriptor)?;
224                    let root = if let Some(sealed) = sealed {
225                        if root != sealed {
226                            return Err(TopologyError::Invalid("sealed source root differs from the exact cut metadata".into()));
227                        }
228                        sealed
229                    } else if has_sources {
230                        self.seal_source_root_slot(operation, &plan, &descriptor, &root).await?
231                    } else { root };
232                    let (bytes, reference) = root.encode_and_reference()?;
233                    self.stage_admission_blob(&root_path(&reference), &bytes)
234                        .await?;
235                    let record_version = if root.format_version == 2 {
236                        TOPOLOGY_SOURCE_ROOT_RECORD_VERSION
237                    } else { TOPOLOGY_MIGRATION_ROOT_RECORD_VERSION };
238                    staged = Some((reference, record_version));
239                }
240                let mut lease = current.lease.clone();
241                lease.seq = lease
242                    .seq
243                    .checked_add(1)
244                    .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
245                let sequence = lease.seq;
246                let mut next = current.preserve_with_lease(lease);
247                next.version = next.version.max(staged.as_ref().ok_or_else(|| {
248                    TopologyError::Invalid("root staging lost its content reference".into())
249                })?.1);
250                let operation = &mut next.topology_operations[index];
251                operation.status_sequence = sequence;
252                operation.migration_root = Some(TopologyMigrationRootBinding {
253                    root: staged
254                        .as_ref()
255                        .ok_or_else(|| {
256                            TopologyError::Invalid("root staging lost its content reference".into())
257                        })?
258                        .0.clone(),
259                    authority_sequence: sequence,
260                });
261                let result = operation.clone();
262                match self
263                    .create_authority_record(Some(&published), &next)
264                    .await?
265                {
266                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
267                        self.require_topology_prepared(&result, &plan, processes)
268                            .await?;
269                        return Ok(result);
270                    }
271                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
272                }
273            }
274            Err(TopologyError::Contended)
275        })
276        .await
277        .map_err(|_| TopologyError::Contended)?
278    }
279
280    async fn load_source_root_slot(
281        &self,
282        operation: &TopologyAdmissionStatus,
283        plan: &crate::cluster::control::TopologyAdmissionPlan,
284        descriptor: &crate::cluster::control::ClusterTopologyValidation,
285    ) -> Result<Option<TopologyMigrationRoot>, TopologyError> {
286        let result = match self.store.get(&source_root_slot(operation)).await {
287            Ok(result) => result,
288            Err(object_store::Error::NotFound { .. }) => return Ok(None),
289            Err(error) => {
290                return Err(TopologyError::Authority(super::LeaseError::Io(
291                    error.to_string(),
292                )))
293            }
294        };
295        let expected_len = result.meta.size;
296        if expected_len == 0
297            || expected_len > crate::cluster::control::topology::MAX_TOPOLOGY_ROOT_BYTES
298        {
299            return Err(TopologyError::Invalid(
300                "sealed source root exceeds its 1 MiB bound".into(),
301            ));
302        }
303        let bytes = result
304            .bytes()
305            .await
306            .map_err(|e| super::LeaseError::Io(e.to_string()))?;
307        let root: TopologyMigrationRoot =
308            serde_json::from_slice(&bytes).map_err(|e| TopologyError::Invalid(e.to_string()))?;
309        let (canonical, _) = root.encode_and_reference()?;
310        if root.format_version != 2
311            || canonical.as_slice() != bytes.as_ref()
312            || bytes.len() as u64 != expected_len
313        {
314            return Err(TopologyError::Invalid(
315                "sealed source root has a noncanonical body".into(),
316            ));
317        }
318        root.validate_binding(operation, plan, descriptor)?;
319        Ok(Some(root))
320    }
321
322    async fn seal_source_root_slot(
323        &self,
324        operation: &TopologyAdmissionStatus,
325        plan: &crate::cluster::control::TopologyAdmissionPlan,
326        descriptor: &crate::cluster::control::ClusterTopologyValidation,
327        proposed: &TopologyMigrationRoot,
328    ) -> Result<TopologyMigrationRoot, TopologyError> {
329        let (bytes, _) = proposed.encode_and_reference()?;
330        let write = self
331            .store
332            .put_opts(
333                &source_root_slot(operation),
334                PutPayload::from(bytes),
335                PutOptions {
336                    mode: PutMode::Create,
337                    ..PutOptions::default()
338                },
339            )
340            .await;
341        // An unsuccessful response is not proof of failure. An existing valid slot is the winning
342        // boundary, including when a concurrent caller resolved a later broker high watermark.
343        let winner = self
344            .load_source_root_slot(operation, plan, descriptor)
345            .await?
346            .ok_or_else(|| match write {
347                Err(error) => TopologyError::Authority(super::LeaseError::Io(error.to_string())),
348                Ok(_) => {
349                    TopologyError::Invalid("sealed source root disappeared after creation".into())
350                }
351            })?;
352        let mut expected = proposed.clone();
353        expected
354            .source_initializations
355            .clone_from(&winner.source_initializations);
356        if winner != expected {
357            return Err(TopologyError::Invalid(
358                "source root slot changed preserved cut requirements".into(),
359            ));
360        }
361        Ok(winner)
362    }
363
364    pub(super) async fn load_topology_root(
365        &self,
366        reference: &TopologyMigrationRootRef,
367    ) -> Result<TopologyMigrationRoot, TopologyError> {
368        reference.validate()?;
369        let bytes = self
370            .read_admission_blob(&root_path(reference), reference.encoded_len)
371            .await?;
372        let root: TopologyMigrationRoot =
373            serde_json::from_slice(&bytes).map_err(|e| TopologyError::Invalid(e.to_string()))?;
374        let (canonical, actual) = root.encode_and_reference()?;
375        if actual != *reference || canonical.as_slice() != bytes.as_ref() {
376            return Err(TopologyError::Invalid(
377                "migration root differs from its canonical immutable reference".into(),
378            ));
379        }
380        Ok(root)
381    }
382
383    pub(super) async fn audit_topology_migration_root(
384        &self,
385        operation: &TopologyAdmissionStatus,
386        plan: &crate::cluster::control::TopologyAdmissionPlan,
387        descriptor: Option<&crate::cluster::control::ClusterTopologyValidation>,
388    ) -> Result<(), TopologyError> {
389        let Some(binding) = &operation.migration_root else {
390            return Ok(());
391        };
392        let root = self.load_topology_root(&binding.root).await?;
393        let descriptor =
394            descriptor.ok_or_else(|| TopologyError::Protocol("root has no descriptor".into()))?;
395        root.validate_binding(operation, plan, descriptor)?;
396        let record = read_authority_record(self.store.as_ref(), binding.authority_sequence)
397            .await?
398            .ok_or_else(|| {
399                TopologyError::Invalid("migration root authority anchor is missing".into())
400            })?;
401        if root.format_version == 2 && record.version < TOPOLOGY_SOURCE_ROOT_RECORD_VERSION {
402            return Err(TopologyError::Protocol(
403                "source initialization roots require authority format 18".into(),
404            ));
405        }
406        if record
407            .topology_operations
408            .iter()
409            .find(|entry| entry.operation_id == operation.operation_id)
410            .is_none_or(|anchored| {
411                anchored.plan != operation.plan
412                    || anchored.admitted_by != operation.admitted_by
413                    || anchored.admitted_sequence != operation.admitted_sequence
414                    || anchored.status_sequence != binding.authority_sequence
415                    || anchored.phase != TopologyAdmissionPhase::CutPrepared
416                    || anchored.cut != operation.cut
417                    || anchored.preparation != operation.preparation
418                    || anchored.migration_root.as_ref() != Some(binding)
419            })
420        {
421            return Err(TopologyError::Invalid(
422                "migration root differs from its first immutable authority append".into(),
423            ));
424        }
425        Ok(())
426    }
427
428    /// Read immutable staged requirements for inspection. They grant no restore/output authority.
429    ///
430    /// # Errors
431    /// Rejects damaged/missing evidence or a read exceeding 15 seconds.
432    pub async fn topology_migration_root(
433        &self,
434        operation_id: TopologyOperationId,
435    ) -> Result<Option<TopologyMigrationRoot>, TopologyError> {
436        tokio::time::timeout(CONTROL_TIMEOUT, async {
437            let Some(current) = self.load_record().await? else {
438                return Ok(None);
439            };
440            let Some(operation) = current
441                .topology_operations
442                .iter()
443                .find(|e| e.operation_id == operation_id)
444            else {
445                return Ok(None);
446            };
447            self.audit_topology_operation(operation).await?;
448            match &operation.migration_root {
449                Some(binding) => self.load_topology_root(&binding.root).await.map(Some),
450                None => Ok(None),
451            }
452        })
453        .await
454        .map_err(|_| TopologyError::ReadTimedOut)?
455    }
456}
457
458pub(super) fn validate_manifest_budget(
459    index: &CommittedCheckpointIndex,
460) -> Result<(), TopologyError> {
461    index
462        .participants
463        .iter()
464        .try_fold(0_u64, |total, p| total.checked_add(p.manifest_len))
465        .filter(|total| *total <= MAX_TOPOLOGY_ROOT_MANIFEST_BYTES)
466        .ok_or_else(|| {
467            TopologyError::Unsupported(
468                "migration root manifest metadata exceeds the aggregate 16 MiB budget".into(),
469            )
470        })?;
471    Ok(())
472}