Skip to main content

laminar_core/cluster/control/topology/
compatibility.rs

1//! Deterministic candidate definitions shared by local compilation and durable preparation.
2
3use super::{TopologyError, TopologyVersion};
4use crate::checkpoint::PipelineIdentity;
5use crate::cluster::control::{CatalogManifest, CatalogManifestRef, CatalogObjectKind};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9pub(crate) const MAX_TOPOLOGY_COMPATIBILITY_BYTES: usize = 1024 * 1024;
10
11/// What a successful validation proves. Participant agreement and runtime authorization follow.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case", deny_unknown_fields)]
14pub enum TopologyValidationScope {
15    /// This binary compiled a compatible candidate without changing the active catalog or actors.
16    LocalCandidatePlan,
17}
18
19/// Supported catalog-object transitions at an exact checkpoint cut.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", deny_unknown_fields)]
22pub enum ClusterTopologyObjectTransition {
23    /// Same incarnation, schema and state contract; any replacement is compiler-certified.
24    Preserve,
25    /// New object, activated at an explicitly persisted future-only boundary.
26    AddFutureOnly,
27    /// Retire an existing source, stream or sink after its cut and actor termination are settled.
28    Remove,
29}
30
31/// Required initialization semantics. These are requirements, never concrete cut positions.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case", deny_unknown_fields)]
34pub enum TopologyInitialization {
35    /// Retain the exact reconciled cut's state, timers, watermarks and source/output progress.
36    PreserveExactCut,
37    /// Process only target-generation input after the cut, without historical replay or backfill.
38    FutureOnlyAtCut,
39    /// Initialize new managed state empty at the exact cut; consume only subsequent input.
40    /// This never authorizes resetting a preserved operator or a target checkpoint image.
41    EmptyManagedStateAtCut,
42    /// Resolve concrete latest source/partition positions once at the cut and persist before commit.
43    ResolveSourcePositionsOnce,
44    /// Settle old-generation state/progress/effects at the cut and omit the object from the target.
45    RetireAtCut,
46    /// Initialize a new materialized view's managed state empty at the exact cut, then apply
47    /// the rows its preserved upstream changelog holds at the cut before any subsequent input.
48    SeedFromUpstreamAtCut,
49}
50
51impl TopologyInitialization {
52    /// Whether the object's managed state starts empty at the cut.
53    #[must_use]
54    pub const fn starts_empty_at_cut(self) -> bool {
55        matches!(
56            self,
57            Self::EmptyManagedStateAtCut | Self::SeedFromUpstreamAtCut
58        )
59    }
60}
61
62/// Authorization still needed before this local plan can activate a target graph.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case", deny_unknown_fields)]
65pub enum TopologyActivationRequirement {
66    /// Every required exact owner/evidence process must validate the same plan and protocol.
67    ParticipantPlanAgreement,
68    /// Establish and reconcile one exact old-topology checkpoint cut.
69    ReconciledCheckpointCut,
70    /// Persist source/channel positions, state mapping and output/replay frontiers.
71    DurableInitializationAndProgress,
72    /// Observe superseded actors' terminal completion and fence staged target output.
73    ObservedActorRetirement,
74    /// Commit the target manifest, cut and mapping atomically in shared authority.
75    AtomicTargetCommit,
76    /// Install the committed graph and consume an owner-complete coordinated Release.
77    InstalledTargetRelease,
78}
79
80/// One catalog-bound entry of the local compatibility descriptor, sorted by canonical name.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct ClusterTopologyObjectPlan {
84    /// Stable catalog name, never an optimizer node index.
85    pub name: String,
86    /// Typed catalog namespace owner.
87    pub kind: CatalogObjectKind,
88    /// Incarnation retained from the parent or newly allocated for future-only activation.
89    pub catalog_generation: u64,
90    /// Supported local classification.
91    pub transition: ClusterTopologyObjectTransition,
92    /// Required activation semantics; no scalar offset is invented during validation.
93    pub initialization: TopologyInitialization,
94    /// Hash of the same resolved definition used by strict pipeline identity.
95    pub definition_sha256: String,
96    /// Hash binding identity, schema, ABI, capability, connector contract and dependency closure.
97    pub compatibility_sha256: String,
98    /// Sorted direct catalog dependencies, with their identities transitively bound by the hash.
99    pub dependencies: Vec<String>,
100    /// Resolved Arrow schema hash, absent for sinks.
101    pub schema_sha256: Option<String>,
102    /// Versioned codec name of a managed operator, absent for stateless/source/sink objects.
103    pub managed_state_contract: Option<String>,
104}
105
106impl ClusterTopologyObjectPlan {
107    /// The only initialization this object's transition and kind admit.
108    fn canonical_initialization(&self) -> Result<TopologyInitialization, TopologyError> {
109        Ok(match (self.transition, self.kind) {
110            (ClusterTopologyObjectTransition::Preserve, _) => {
111                TopologyInitialization::PreserveExactCut
112            }
113            (
114                ClusterTopologyObjectTransition::Remove,
115                CatalogObjectKind::Source
116                | CatalogObjectKind::Stream
117                | CatalogObjectKind::MaterializedView
118                | CatalogObjectKind::Sink,
119            ) => TopologyInitialization::RetireAtCut,
120            (ClusterTopologyObjectTransition::Remove, _) => {
121                return Err(TopologyError::Unsupported(
122                    "only sources, streams, materialized views and sinks have a certified removal contract".into(),
123                ))
124            }
125            (_, CatalogObjectKind::Source) => TopologyInitialization::ResolveSourcePositionsOnce,
126            (_, CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView)
127                if self.managed_state_contract.is_some() =>
128            {
129                TopologyInitialization::EmptyManagedStateAtCut
130            }
131            _ => TopologyInitialization::FutureOnlyAtCut,
132        })
133    }
134
135    /// Whether this new view seeds from an upstream the migration preserves, in place of its
136    /// canonical empty start.
137    pub(super) fn seeds_from_preserved_upstream(&self, objects: &[Self]) -> bool {
138        self.initialization == TopologyInitialization::SeedFromUpstreamAtCut
139            && self.transition == ClusterTopologyObjectTransition::AddFutureOnly
140            && self.kind == CatalogObjectKind::MaterializedView
141            && self.managed_state_contract.is_some()
142            && self.dependencies.iter().any(|name| {
143                objects.iter().any(|dependency| {
144                    &dependency.name == name
145                        && dependency.transition == ClusterTopologyObjectTransition::Preserve
146                        && matches!(
147                            dependency.kind,
148                            CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView
149                        )
150                })
151            })
152    }
153
154    /// Whether `operator_id` is a state frame of this object: a stream's operator, or a
155    /// materialized view's operator and the operator that owns its result slices, or a stage
156    /// split out of either's query.
157    #[must_use]
158    pub fn owns_state_frame(&self, operator_id: &str) -> bool {
159        let Some(operator) = operator_id.strip_prefix("graph:") else {
160            return false;
161        };
162        let operator = crate::catalog::stage_owner(operator).unwrap_or(operator);
163        match self.kind {
164            CatalogObjectKind::Stream => operator == self.name,
165            CatalogObjectKind::MaterializedView => {
166                operator == self.name
167                    || operator.strip_suffix(crate::mv::VIEW_RESULT_OPERATOR_SUFFIX)
168                        == Some(self.name.as_str())
169            }
170            _ => false,
171        }
172    }
173}
174
175/// Effect-free candidate validation. This is neither a durable admission nor an activation receipt.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct ClusterTopologyValidation {
179    /// Deterministic local descriptor format, independent of catalog and topology versions.
180    pub validation_format_version: u16,
181    /// Exact scope of the returned evidence.
182    pub scope: TopologyValidationScope,
183    /// Existing control/checkpoint deployment identity.
184    pub deployment_id: String,
185    /// Expected authoritative parent.
186    pub parent_version: TopologyVersion,
187    /// Proposed exact successor; not a committed version.
188    pub target_version: TopologyVersion,
189    /// Exact durable parent bytes and inventory.
190    pub parent_manifest: CatalogManifestRef,
191    /// Candidate inventory reference, computed without writing the blob or a catalog head.
192    pub target_manifest: CatalogManifestRef,
193    /// Unmodified strict recovery identity for the parent graph.
194    pub parent_pipeline: PipelineIdentity,
195    /// Full strict identity for the changed graph; ordinary parent checkpoints will not match it.
196    pub target_pipeline: PipelineIdentity,
197    /// Global state/routing/delivery ABI and config shared by every object descriptor.
198    pub environment_sha256: String,
199    /// Deterministic descriptor digest. Participants must agree on this before admission advances.
200    pub compatibility_sha256: String,
201    /// Exact submitted DDL, including removals absent from the target inventory. Binds retries
202    /// and participant compilation to the same ordered payload.
203    pub statements: Vec<String>,
204    /// Parent/target union sorted by name and generation. Explicit reset has two mappings:
205    /// retirement of the parent incarnation and future-only activation of its successor.
206    pub objects: Vec<ClusterTopologyObjectPlan>,
207    /// A processing pause is required for the implemented old-topology cut contract.
208    pub requires_processing_pause: bool,
209    /// Evidence this validation does not supply; the public mutation path remains guarded.
210    pub required_before_activation: Vec<TopologyActivationRequirement>,
211}
212
213/// Immutable canonical candidate report. The report is separate from authority renewals.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct TopologyCompatibilityRef {
217    /// SHA-256 of the complete canonical report, including the definition descriptor digest.
218    pub sha256: String,
219    /// Exact report length, bounded before reading or decoding.
220    pub encoded_len: u64,
221}
222
223pub(super) fn is_digest(value: &str) -> bool {
224    value.len() == 64
225        && value
226            .bytes()
227            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
228}
229
230impl TopologyCompatibilityRef {
231    pub(crate) fn validate(&self) -> Result<(), TopologyError> {
232        if !is_digest(&self.sha256)
233            || self.encoded_len == 0
234            || self.encoded_len > MAX_TOPOLOGY_COMPATIBILITY_BYTES as u64
235        {
236            return Err(TopologyError::Invalid(
237                "invalid compatibility reference".into(),
238            ));
239        }
240        Ok(())
241    }
242}
243
244impl ClusterTopologyValidation {
245    /// Recompute the existing descriptor digest from its explicit immutable fields.
246    ///
247    /// # Errors
248    /// Returns a typed encoding failure; this never writes authority.
249    pub fn descriptor_digest(&self) -> Result<String, TopologyError> {
250        let bytes = serde_json::to_vec(&(
251            "laminardb-topology-compatibility-v3",
252            self.validation_format_version,
253            &self.deployment_id,
254            self.parent_version,
255            self.target_version,
256            &self.parent_manifest,
257            &self.target_manifest,
258            &self.parent_pipeline,
259            &self.target_pipeline,
260            &self.environment_sha256,
261            &self.statements,
262            &self.objects,
263        ))
264        .map_err(|error| TopologyError::Invalid(error.to_string()))?;
265        Ok(format!("{:x}", Sha256::digest(bytes)))
266    }
267
268    /// Validate canonical structure and bind every object to the exact ordered catalog inventories.
269    /// Compilation/connector semantics are independently checked by each DB participant.
270    ///
271    /// # Errors
272    /// Rejects malformed, unsupported, divergent or oversized evidence.
273    pub fn validate_catalogs(
274        &self,
275        parent: &CatalogManifest,
276        target: &CatalogManifest,
277    ) -> Result<(), TopologyError> {
278        if parent.reference()? != self.parent_manifest
279            || target.reference()? != self.target_manifest
280        {
281            return Err(TopologyError::Invalid(
282                "compatibility descriptor differs from the exact catalog inventories".into(),
283            ));
284        }
285        self.encode_and_reference()?;
286        let mut expected = std::collections::BTreeMap::new();
287        let mut retained = Vec::new();
288        for entry in &parent.entries {
289            let transition = match target
290                .entries
291                .iter()
292                .find(|target| target.canonical_name == entry.canonical_name)
293            {
294                Some(target) if target.kind == entry.kind
295                    && target.catalog_generation == entry.catalog_generation => {
296                    retained.push(target.clone());
297                    ClusterTopologyObjectTransition::Preserve
298                }
299                replacement if matches!(
300                    entry.kind,
301                    CatalogObjectKind::Source | CatalogObjectKind::Stream | CatalogObjectKind::Sink
302                ) && replacement.is_none_or(|new| new.kind == entry.kind
303                    && Some(new.catalog_generation) == entry.catalog_generation.checked_add(1)) =>
304                {
305                    ClusterTopologyObjectTransition::Remove
306                }
307                // A cut keys a view's frontier and log by name, so a view has no successor in the
308                // migration that retires it.
309                None if entry.kind == CatalogObjectKind::MaterializedView => {
310                    ClusterTopologyObjectTransition::Remove
311                }
312                _ => return Err(TopologyError::Invalid(
313                    "target changes an incarnation without an explicit retirement/successor mapping"
314                        .into(),
315                )),
316            };
317            expected.insert(
318                (entry.canonical_name.as_str(), entry.catalog_generation),
319                (entry, transition),
320            );
321        }
322        if !target.entries.starts_with(&retained) {
323            return Err(TopologyError::Invalid(
324                "target reordered the retained parent inventory".into(),
325            ));
326        }
327        for entry in &target.entries {
328            expected
329                .entry((entry.canonical_name.as_str(), entry.catalog_generation))
330                .or_insert((entry, ClusterTopologyObjectTransition::AddFutureOnly));
331        }
332        if expected.len() != self.objects.len() {
333            return Err(TopologyError::Invalid(
334                "compatibility descriptor omitted or added a catalog mapping".into(),
335            ));
336        }
337        for object in &self.objects {
338            let (entry, transition) = expected
339                .get(&(object.name.as_str(), object.catalog_generation))
340                .ok_or_else(|| {
341                    TopologyError::Invalid(
342                        "compatibility object is absent from both catalogs".into(),
343                    )
344                })?;
345            if object.kind != entry.kind
346                || object.catalog_generation != entry.catalog_generation
347                || object.transition != *transition
348            {
349                return Err(TopologyError::Invalid(
350                    "compatibility object incarnation/classification differs from catalog".into(),
351                ));
352            }
353        }
354        Ok(())
355    }
356
357    /// Canonical bounded report encoding used by all admission and participant paths.
358    ///
359    /// # Errors
360    /// Rejects unsupported or noncanonical descriptor fields and size limits.
361    pub fn encode_and_reference(
362        &self,
363    ) -> Result<(Vec<u8>, TopologyCompatibilityRef), TopologyError> {
364        let deployment = uuid::Uuid::parse_str(&self.deployment_id)
365            .map_err(|_| TopologyError::Invalid("invalid descriptor deployment".into()))?;
366        self.parent_manifest.validate()?;
367        self.target_manifest.validate()?;
368        let sql_bytes = self
369            .statements
370            .iter()
371            .try_fold(0_usize, |total, sql| total.checked_add(sql.len()));
372        if self.validation_format_version != 3
373            || deployment.is_nil()
374            || deployment.to_string() != self.deployment_id
375            || self.parent_version.successor()? != self.target_version
376            || self.parent_manifest == self.target_manifest
377            || !self.parent_pipeline.is_canonical()
378            || !self.target_pipeline.is_canonical()
379            || self.parent_pipeline == self.target_pipeline
380            || !is_digest(&self.environment_sha256)
381            || self.statements.is_empty()
382            || self.statements.len() > 64
383            || self.statements.iter().any(|sql| sql.trim().is_empty())
384            || sql_bytes.is_none_or(|bytes| bytes > 256 * 1024)
385            || self.objects.is_empty()
386            || self.objects.len() > 256
387            || !self.objects.windows(2).all(|pair| {
388                (&pair[0].name, pair[0].catalog_generation)
389                    < (&pair[1].name, pair[1].catalog_generation)
390            })
391            || !self.requires_processing_pause
392            || self.required_before_activation
393                != [
394                    TopologyActivationRequirement::ParticipantPlanAgreement,
395                    TopologyActivationRequirement::ReconciledCheckpointCut,
396                    TopologyActivationRequirement::DurableInitializationAndProgress,
397                    TopologyActivationRequirement::ObservedActorRetirement,
398                    TopologyActivationRequirement::AtomicTargetCommit,
399                    TopologyActivationRequirement::InstalledTargetRelease,
400                ]
401        {
402            return Err(TopologyError::Invalid(
403                "unsupported or noncanonical candidate descriptor".into(),
404            ));
405        }
406        for object in &self.objects {
407            let initialization = object.canonical_initialization()?;
408            if object.name.is_empty()
409                || object.name.len() > 256
410                || object.catalog_generation == 0
411                || !is_digest(&object.definition_sha256)
412                || !is_digest(&object.compatibility_sha256)
413                || object
414                    .schema_sha256
415                    .as_ref()
416                    .is_some_and(|hash| !is_digest(hash))
417                || (object.initialization != initialization
418                    && !object.seeds_from_preserved_upstream(&self.objects))
419                || object.dependencies.len() > 256
420                || !object.dependencies.windows(2).all(|pair| pair[0] < pair[1])
421                || object.dependencies.iter().any(|name| {
422                    name == &object.name
423                        || !self.objects.iter().any(|dependency| {
424                            &dependency.name == name
425                                && if object.transition == ClusterTopologyObjectTransition::Remove {
426                                    dependency.transition
427                                        != ClusterTopologyObjectTransition::AddFutureOnly
428                                } else {
429                                    dependency.transition != ClusterTopologyObjectTransition::Remove
430                                }
431                        })
432                })
433                || object
434                    .managed_state_contract
435                    .as_ref()
436                    .is_some_and(|contract| {
437                        contract.is_empty()
438                            || contract.len() > 128
439                            || !matches!(
440                                object.kind,
441                                CatalogObjectKind::Stream | CatalogObjectKind::MaterializedView
442                            )
443                    })
444            {
445                return Err(TopologyError::Invalid(
446                    "invalid compatibility object mapping".into(),
447                ));
448            }
449        }
450        if self.descriptor_digest()? != self.compatibility_sha256 {
451            return Err(TopologyError::Invalid(
452                "candidate descriptor digest differs from its fields".into(),
453            ));
454        }
455        let bytes =
456            serde_json::to_vec(self).map_err(|error| TopologyError::Invalid(error.to_string()))?;
457        let reference = TopologyCompatibilityRef {
458            sha256: format!("{:x}", Sha256::digest(&bytes)),
459            encoded_len: bytes.len() as u64,
460        };
461        reference.validate()?;
462        Ok((bytes, reference))
463    }
464}