Skip to main content

laminar_core/cluster/control/topology/
migration_root.rs

1//! Exact-cut restore and retirement requirements. Staging grants no target authority.
2
3use super::{
4    ClusterTopologyObjectTransition, ClusterTopologyValidation, TopologyAdmissionPhase,
5    TopologyAdmissionPlan, TopologyAdmissionStatus, TopologyCompatibilityRef, TopologyCutCommit,
6    TopologyError, TopologyOperationId, TopologyPlanRef, TopologySourceInitialization,
7};
8use crate::checkpoint::{
9    canonical_json_bytes, checkpoint_manifest_bytes, merge_node_subscription_manifests,
10    CheckpointManifest, CommittedCheckpointIndex, OutputDistributionCertificate, PartitionFrontier,
11    StateFrameKey,
12};
13use crate::cluster::control::CatalogObjectKind;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17pub(crate) const MAX_TOPOLOGY_ROOT_BYTES: u64 = 1024 * 1024;
18/// Aggregate manifest metadata admitted while staging a root; state/output payloads are not read.
19pub const MAX_TOPOLOGY_ROOT_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
20
21/// Same catalog incarnation and ABI mapped to the same checkpoint state slot.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct TopologyPreservedObject {
25    /// Canonical catalog identity, never a traversal index.
26    pub name: String,
27    /// Source, stream or sink namespace.
28    pub kind: CatalogObjectKind,
29    /// Exact unchanged incarnation.
30    pub catalog_generation: u64,
31    /// Independently certified definition/schema/codec/dependency contract.
32    pub compatibility_sha256: String,
33    /// Exact existing graph frame identifier for streams; sources/sinks use the cut's inventories.
34    pub state_operator_id: Option<String>,
35}
36
37/// Unchanged subscription identity and exclusive sequence vector at the old cut.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct TopologySubscriptionRoot {
41    /// Original certificate and stream generation. Historical segments retain this certificate.
42    pub parent_certificate: OutputDistributionCertificate,
43    /// Required target certificate, differing only in the complete pipeline identity.
44    /// Target installation must explicitly bind this preserved generation instead of deriving
45    /// a new one from the changed whole-graph hash. This requirement is not an output permit.
46    pub target_certificate: OutputDistributionCertificate,
47    /// Exact next sequence in every output partition; never reset to the first sequence.
48    pub frontiers: Vec<PartitionFrontier>,
49}
50
51/// Immutable target restore/initialization requirements, retained by one authority append.
52/// All state ranges, source/snapshot/channel progress, sink decisions and replay segment references
53/// remain in the exact old checkpoint. No historical manifest or state payload is rewritten.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct TopologyMigrationRoot {
57    /// Root encoding, separate from logical topology, catalog and checkpoint versions.
58    pub format_version: u16,
59    /// Exact admitted request.
60    pub operation_id: TopologyOperationId,
61    /// Immutable admission payload.
62    pub plan: TopologyPlanRef,
63    /// Complete participant-certified compatibility descriptor.
64    pub compatibility: TopologyCompatibilityRef,
65    /// Definitive old-topology checkpoint and its shared Commit append.
66    pub cut: TopologyCutCommit,
67    /// Sorted exact identity mappings for every unchanged object.
68    pub preserved_objects: Vec<TopologyPreservedObject>,
69    /// Sorted new objects. Streams/sinks receive only target input; sources use their sealed cursors.
70    pub future_only_objects: Vec<String>,
71    /// Sorted preserved subscription incarnations and publication frontiers.
72    pub subscriptions: Vec<TopologySubscriptionRoot>,
73    /// Sorted new-source positions, sealed once for this exact operation and cut.
74    /// Empty format-1 roots retain their original canonical bytes.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub source_initializations: Vec<TopologySourceInitialization>,
77}
78
79/// Small content reference carried by authority renewals.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct TopologyMigrationRootRef {
83    /// SHA-256 of the exact canonical root body.
84    pub sha256: String,
85    /// Exact length, bounded before reading or decoding.
86    pub encoded_len: u64,
87}
88
89impl TopologyMigrationRootRef {
90    pub(crate) fn validate(&self) -> Result<(), TopologyError> {
91        if self.sha256.len() != 64
92            || !self
93                .sha256
94                .bytes()
95                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
96            || self.encoded_len == 0
97            || self.encoded_len > MAX_TOPOLOGY_ROOT_BYTES
98        {
99            return Err(TopologyError::Invalid(
100                "invalid migration root reference".into(),
101            ));
102        }
103        Ok(())
104    }
105}
106
107/// Durable staging evidence. Neither this binding nor `CutPrepared` commits the target catalog.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct TopologyMigrationRootBinding {
111    /// Immutable requirements derived from the certified descriptor and exact cut.
112    pub root: TopologyMigrationRootRef,
113    /// Shared append that first pinned the root.
114    pub authority_sequence: u64,
115}
116
117impl TopologyMigrationRoot {
118    /// Rebuild the exact requirements from checksummed historical manifests before target restore.
119    /// This does not rewrite their parent identity or grant installation/output authority.
120    ///
121    /// # Errors
122    /// Rejects missing, divergent or reordered state/progress/subscription mappings.
123    pub fn validate_restore_cut(
124        &self,
125        operation: &TopologyAdmissionStatus,
126        descriptor: &ClusterTopologyValidation,
127        index: &CommittedCheckpointIndex,
128        manifests: &[CheckpointManifest],
129    ) -> Result<(), TopologyError> {
130        let rebuilt = Self::build_with_sources(
131            operation,
132            descriptor,
133            index,
134            manifests,
135            self.source_initializations.clone(),
136        )?;
137        if rebuilt != *self {
138            return Err(TopologyError::Invalid(
139                "restore metadata differs from the sealed migration root".into(),
140            ));
141        }
142        Ok(())
143    }
144
145    pub(crate) fn object_mappings(
146        descriptor: &ClusterTopologyValidation,
147    ) -> Result<(Vec<TopologyPreservedObject>, Vec<String>), TopologyError> {
148        let mut preserved = Vec::new();
149        let mut future = Vec::new();
150        for object in &descriptor.objects {
151            match object.transition {
152                ClusterTopologyObjectTransition::Preserve => {
153                    preserved.push(TopologyPreservedObject {
154                        name: object.name.clone(),
155                        kind: object.kind,
156                        catalog_generation: object.catalog_generation,
157                        compatibility_sha256: object.compatibility_sha256.clone(),
158                        state_operator_id: matches!(
159                            object.kind,
160                            CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView
161                        )
162                        .then(|| format!("graph:{}", object.name)),
163                    });
164                }
165                ClusterTopologyObjectTransition::AddFutureOnly => {
166                    if object.managed_state_contract.is_some()
167                        && !object.initialization.starts_empty_at_cut()
168                    {
169                        return Err(TopologyError::Unsupported(
170                            "new managed state requires an explicit empty-state cut contract"
171                                .into(),
172                        ));
173                    }
174                    future.push(object.name.clone());
175                }
176                ClusterTopologyObjectTransition::Remove => {
177                    if !matches!(
178                        object.kind,
179                        CatalogObjectKind::Source
180                            | CatalogObjectKind::Stream
181                            | CatalogObjectKind::MaterializedView
182                            | CatalogObjectKind::Sink
183                    ) || object.initialization != super::TopologyInitialization::RetireAtCut
184                    {
185                        return Err(TopologyError::Unsupported(
186                            "removed objects must retire at the checkpoint cut".into(),
187                        ));
188                    }
189                }
190            }
191        }
192        Ok((preserved, future))
193    }
194
195    pub(crate) fn build(
196        operation: &TopologyAdmissionStatus,
197        descriptor: &ClusterTopologyValidation,
198        index: &CommittedCheckpointIndex,
199        manifests: &[CheckpointManifest],
200    ) -> Result<Self, TopologyError> {
201        Self::build_with_sources(operation, descriptor, index, manifests, Vec::new())
202    }
203
204    pub(crate) fn build_with_sources(
205        operation: &TopologyAdmissionStatus,
206        descriptor: &ClusterTopologyValidation,
207        index: &CommittedCheckpointIndex,
208        manifests: &[CheckpointManifest],
209        source_initializations: Vec<TopologySourceInitialization>,
210    ) -> Result<Self, TopologyError> {
211        let cut = operation
212            .cut
213            .as_ref()
214            .and_then(|cut| cut.committed.as_ref())
215            .ok_or_else(|| {
216                TopologyError::Conflict("root staging requires a definitive old cut".into())
217            })?;
218        if !matches!(
219            operation.phase,
220            TopologyAdmissionPhase::CutPrepared
221                | TopologyAdmissionPhase::Committed
222                | TopologyAdmissionPhase::Activating
223                | TopologyAdmissionPhase::Active
224        ) || index
225            .encode_and_reference()
226            .map_err(TopologyError::Invalid)?
227            .1
228            != cut.checkpoint
229            || index.pipeline_identity != descriptor.parent_pipeline
230            || index.deployment_id != descriptor.deployment_id
231        {
232            return Err(TopologyError::Conflict(
233                "root does not bind the certified prepared cut".into(),
234            ));
235        }
236        let encoded = manifests
237            .iter()
238            .map(checkpoint_manifest_bytes)
239            .collect::<Result<Vec<_>, _>>()
240            .map_err(|e| TopologyError::Invalid(e.to_string()))?;
241        let views = manifests
242            .iter()
243            .zip(&encoded)
244            .map(|(m, b)| (m, b.as_slice()))
245            .collect::<Vec<_>>();
246        index
247            .validate_participant_manifests(&views)
248            .map_err(TopologyError::Invalid)?;
249        let (preserved_objects, future_only_objects) = Self::object_mappings(descriptor)?;
250        Self::validate_parent_inventory(index, manifests, descriptor)?;
251        Self::validate_parent_state(manifests, descriptor)?;
252        let subscriptions = Self::subscription_mappings(index, manifests, descriptor)?;
253        let root = Self {
254            format_version: if source_initializations.is_empty() {
255                1
256            } else {
257                2
258            },
259            operation_id: operation.operation_id,
260            plan: operation.plan.clone(),
261            compatibility: operation
262                .preparation
263                .as_ref()
264                .ok_or_else(|| TopologyError::Protocol("root has no preparation".into()))?
265                .compatibility
266                .clone(),
267            cut: cut.clone(),
268            preserved_objects,
269            future_only_objects,
270            subscriptions,
271            source_initializations,
272        };
273        root.validate_source_mappings(descriptor)?;
274        root.encode_and_reference()?;
275        Ok(root)
276    }
277
278    fn validate_parent_state(
279        manifests: &[CheckpointManifest],
280        descriptor: &ClusterTopologyValidation,
281    ) -> Result<(), TopologyError> {
282        for manifest in manifests {
283            for frame in &manifest.state_frames {
284                let (operator_id, vnode) = match &frame.key {
285                    StateFrameKey::OperatorWhole { operator_id } => (operator_id, false),
286                    StateFrameKey::Vnode { operator_id, .. } => (operator_id, true),
287                };
288                let object = descriptor
289                    .objects
290                    .iter()
291                    .find(|o| {
292                        o.transition != ClusterTopologyObjectTransition::AddFutureOnly
293                            && o.owns_state_frame(operator_id)
294                    })
295                    .ok_or_else(|| {
296                        TopologyError::Unsupported(format!(
297                            "cut frame '{operator_id}' has no certified parent state mapping"
298                        ))
299                    })?;
300                if vnode && object.managed_state_contract.is_none() {
301                    return Err(TopologyError::Invalid(format!(
302                        "unmanaged stream '{}' has vnode state",
303                        object.name
304                    )));
305                }
306            }
307        }
308        for object in descriptor.objects.iter().filter(|o| {
309            o.transition != ClusterTopologyObjectTransition::AddFutureOnly
310                && o.managed_state_contract.is_some()
311        }) {
312            if !manifests.iter().any(|m| {
313                m.state_frames.iter().any(|frame| {
314                    matches!(&frame.key, StateFrameKey::Vnode { operator_id, .. }
315                    if object.owns_state_frame(operator_id))
316                })
317            }) {
318                return Err(TopologyError::Invalid(format!(
319                    "managed stream '{}' has no parent vnode state; a complete cut is required",
320                    object.name
321                )));
322            }
323        }
324        Ok(())
325    }
326
327    fn subscription_mappings(
328        index: &CommittedCheckpointIndex,
329        manifests: &[CheckpointManifest],
330        descriptor: &ClusterTopologyValidation,
331    ) -> Result<Vec<TopologySubscriptionRoot>, TopologyError> {
332        let subscription_views = manifests
333            .iter()
334            .filter_map(|m| {
335                m.subscription_output
336                    .as_ref()
337                    .map(|s| (s, m.owned_vnodes.as_slice()))
338            })
339            .collect::<Vec<_>>();
340        let merged = if subscription_views.is_empty() {
341            Vec::new()
342        } else {
343            merge_node_subscription_manifests(
344                index.epoch,
345                index.checkpoint_id,
346                index
347                    .assignment_fence
348                    .as_ref()
349                    .ok_or_else(|| TopologyError::Invalid("root has no assignment".into()))?,
350                &subscription_views,
351            )
352            .map_err(|e| TopologyError::Invalid(e.to_string()))?
353        };
354        let mut subscriptions = Vec::with_capacity(merged.len());
355        for stream in merged {
356            let parent_certificate = stream.manifest.distribution_certificate;
357            let object = descriptor
358                .objects
359                .iter()
360                .find(|o| {
361                    o.name == parent_certificate.stream_id
362                        && matches!(
363                            o.kind,
364                            CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView
365                        )
366                        && o.transition != ClusterTopologyObjectTransition::AddFutureOnly
367                })
368                .ok_or_else(|| {
369                    TopologyError::Invalid("subscription has no parent stream mapping".into())
370                })?;
371            if object.catalog_generation != parent_certificate.catalog_generation
372                || object.schema_sha256.as_deref()
373                    != Some(parent_certificate.schema_fingerprint.to_hex().as_str())
374                || parent_certificate.final_operator_id != format!("stream:{}", object.name)
375            {
376                return Err(TopologyError::Invalid(
377                    "subscription incarnation/schema/operator differs from the descriptor".into(),
378                ));
379            }
380            if object.transition == ClusterTopologyObjectTransition::Remove {
381                continue;
382            }
383            let mut target_certificate = parent_certificate.clone();
384            target_certificate.pipeline_identity = descriptor.target_pipeline.clone();
385            subscriptions.push(TopologySubscriptionRoot {
386                parent_certificate,
387                target_certificate,
388                frontiers: stream.manifest.frontiers,
389            });
390        }
391        Ok(subscriptions)
392    }
393
394    fn validate_parent_inventory(
395        index: &CommittedCheckpointIndex,
396        manifests: &[CheckpointManifest],
397        descriptor: &ClusterTopologyValidation,
398    ) -> Result<(), TopologyError> {
399        let names = |kind| {
400            descriptor
401                .objects
402                .iter()
403                .filter(|o| {
404                    o.kind == kind && o.transition != ClusterTopologyObjectTransition::AddFutureOnly
405                })
406                .map(|o| o.name.clone())
407                .collect::<Vec<_>>()
408        };
409        if index.source_names != names(CatalogObjectKind::Source)
410            || manifests
411                .iter()
412                .any(|m| m.sink_names != names(CatalogObjectKind::Sink))
413        {
414            return Err(TopologyError::Invalid(
415                "cut source/sink inventory differs from the complete parent catalog".into(),
416            ));
417        }
418        Ok(())
419    }
420
421    pub(crate) fn validate_binding(
422        &self,
423        operation: &TopologyAdmissionStatus,
424        plan: &TopologyAdmissionPlan,
425        descriptor: &ClusterTopologyValidation,
426    ) -> Result<(), TopologyError> {
427        self.validate_source_mappings(descriptor)?;
428        let (preserved, future) = Self::object_mappings(descriptor)?;
429        if self.operation_id != operation.operation_id
430            || self.plan != operation.plan
431            || plan.compatibility.as_ref() != Some(&self.compatibility)
432            || operation
433                .cut
434                .as_ref()
435                .and_then(|cut| cut.committed.as_ref())
436                != Some(&self.cut)
437            || self.preserved_objects != preserved
438            || self.future_only_objects != future
439            || self.subscriptions.iter().any(|s| {
440                s.parent_certificate.pipeline_identity != descriptor.parent_pipeline
441                    || s.target_certificate.pipeline_identity != descriptor.target_pipeline
442            })
443        {
444            return Err(TopologyError::Invalid(
445                "migration root differs from its immutable plan and cut".into(),
446            ));
447        }
448        let key_groups = crate::state::KeyGroupCount::try_from(plan.assignment.vnode_count)
449            .map_err(|e| TopologyError::Invalid(e.to_string()))?;
450        for subscription in &self.subscriptions {
451            let certificate = &subscription.parent_certificate;
452            certificate
453                .validate(key_groups)
454                .map_err(|e| TopologyError::Invalid(e.to_string()))?;
455            subscription
456                .target_certificate
457                .validate(key_groups)
458                .map_err(|e| TopologyError::Invalid(e.to_string()))?;
459            let object = descriptor
460                .objects
461                .iter()
462                .find(|o| {
463                    o.name == certificate.stream_id
464                        && matches!(
465                            o.kind,
466                            CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView
467                        )
468                        && o.transition == ClusterTopologyObjectTransition::Preserve
469                })
470                .ok_or_else(|| {
471                    TopologyError::Invalid("root subscription has no preserved object".into())
472                })?;
473            if certificate.catalog_generation != object.catalog_generation
474                || object.schema_sha256.as_deref()
475                    != Some(certificate.schema_fingerprint.to_hex().as_str())
476                || certificate.final_operator_id != format!("stream:{}", object.name)
477                || subscription.frontiers.len()
478                    != usize::from(certificate.distribution.partition_count())
479                || subscription
480                    .frontiers
481                    .iter()
482                    .any(|f| !certificate.distribution.contains(f.partition))
483            {
484                return Err(TopologyError::Invalid("root subscription does not preserve its certified incarnation/schema/partition vector".into()));
485            }
486        }
487        Ok(())
488    }
489
490    fn validate_source_mappings(
491        &self,
492        descriptor: &ClusterTopologyValidation,
493    ) -> Result<(), TopologyError> {
494        let sources = descriptor
495            .objects
496            .iter()
497            .filter(|object| {
498                object.kind == CatalogObjectKind::Source
499                    && object.transition == ClusterTopologyObjectTransition::AddFutureOnly
500            })
501            .collect::<Vec<_>>();
502        if sources.len() != self.source_initializations.len() {
503            return Err(TopologyError::Unsupported(
504                "every new source requires concrete connector positions sealed once at the cut"
505                    .into(),
506            ));
507        }
508        for (object, initialization) in sources.iter().zip(&self.source_initializations) {
509            initialization.validate()?;
510            if initialization.name != object.name
511                || initialization.catalog_generation != object.catalog_generation
512                || initialization.compatibility_sha256 != object.compatibility_sha256
513            {
514                return Err(TopologyError::Invalid(
515                    "source initialization differs from its certified catalog incarnation".into(),
516                ));
517            }
518        }
519        Ok(())
520    }
521
522    /// Canonical bounded body for durable storage. This encodes requirements, never authority.
523    ///
524    /// # Errors
525    /// Rejects malformed identities, unordered mappings or a body above 1 MiB.
526    pub fn encode_and_reference(
527        &self,
528    ) -> Result<(Vec<u8>, TopologyMigrationRootRef), TopologyError> {
529        self.plan.validate()?;
530        self.compatibility.validate()?;
531        self.cut
532            .checkpoint
533            .validate()
534            .map_err(TopologyError::Invalid)?;
535        if !matches!(
536            (self.format_version, self.source_initializations.is_empty()),
537            (1, true) | (2, false)
538        ) || self.cut.authority_sequence == 0
539            || self.preserved_objects.len() + self.future_only_objects.len() > 256
540            || !self
541                .preserved_objects
542                .windows(2)
543                .all(|p| p[0].name < p[1].name)
544            || !self.future_only_objects.windows(2).all(|p| p[0] < p[1])
545            || self.subscriptions.len() > self.preserved_objects.len()
546            || self.source_initializations.len() > self.future_only_objects.len()
547            || !self
548                .source_initializations
549                .windows(2)
550                .all(|p| p[0].name < p[1].name)
551            || !self
552                .subscriptions
553                .windows(2)
554                .all(|p| p[0].parent_certificate.stream_id < p[1].parent_certificate.stream_id)
555        {
556            return Err(TopologyError::Invalid(
557                "invalid migration root shape".into(),
558            ));
559        }
560        for initialization in &self.source_initializations {
561            initialization.validate()?;
562            if self
563                .future_only_objects
564                .binary_search(&initialization.name)
565                .is_err()
566            {
567                return Err(TopologyError::Invalid(
568                    "source initialization has no future object".into(),
569                ));
570            }
571        }
572        for subscription in &self.subscriptions {
573            let mut expected = subscription.parent_certificate.clone();
574            expected.pipeline_identity = subscription.target_certificate.pipeline_identity.clone();
575            if expected != subscription.target_certificate
576                || subscription.frontiers.is_empty()
577                || !subscription
578                    .frontiers
579                    .windows(2)
580                    .all(|p| p[0].partition < p[1].partition)
581            {
582                return Err(TopologyError::Invalid(
583                    "root cannot change subscription identity or rewind its sequence vector".into(),
584                ));
585            }
586        }
587        let bytes =
588            canonical_json_bytes(self).map_err(|e| TopologyError::Invalid(e.to_string()))?;
589        let reference = TopologyMigrationRootRef {
590            sha256: format!("{:x}", Sha256::digest(&bytes)),
591            encoded_len: bytes.len() as u64,
592        };
593        reference.validate()?;
594        Ok((bytes, reference))
595    }
596}