1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case", deny_unknown_fields)]
14pub enum TopologyValidationScope {
15 LocalCandidatePlan,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", deny_unknown_fields)]
22pub enum ClusterTopologyObjectTransition {
23 Preserve,
25 AddFutureOnly,
27 Remove,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case", deny_unknown_fields)]
34pub enum TopologyInitialization {
35 PreserveExactCut,
37 FutureOnlyAtCut,
39 EmptyManagedStateAtCut,
42 ResolveSourcePositionsOnce,
44 RetireAtCut,
46 SeedFromUpstreamAtCut,
49}
50
51impl TopologyInitialization {
52 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case", deny_unknown_fields)]
65pub enum TopologyActivationRequirement {
66 ParticipantPlanAgreement,
68 ReconciledCheckpointCut,
70 DurableInitializationAndProgress,
72 ObservedActorRetirement,
74 AtomicTargetCommit,
76 InstalledTargetRelease,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct ClusterTopologyObjectPlan {
84 pub name: String,
86 pub kind: CatalogObjectKind,
88 pub catalog_generation: u64,
90 pub transition: ClusterTopologyObjectTransition,
92 pub initialization: TopologyInitialization,
94 pub definition_sha256: String,
96 pub compatibility_sha256: String,
98 pub dependencies: Vec<String>,
100 pub schema_sha256: Option<String>,
102 pub managed_state_contract: Option<String>,
104}
105
106impl ClusterTopologyObjectPlan {
107 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct ClusterTopologyValidation {
179 pub validation_format_version: u16,
181 pub scope: TopologyValidationScope,
183 pub deployment_id: String,
185 pub parent_version: TopologyVersion,
187 pub target_version: TopologyVersion,
189 pub parent_manifest: CatalogManifestRef,
191 pub target_manifest: CatalogManifestRef,
193 pub parent_pipeline: PipelineIdentity,
195 pub target_pipeline: PipelineIdentity,
197 pub environment_sha256: String,
199 pub compatibility_sha256: String,
201 pub statements: Vec<String>,
204 pub objects: Vec<ClusterTopologyObjectPlan>,
207 pub requires_processing_pause: bool,
209 pub required_before_activation: Vec<TopologyActivationRequirement>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct TopologyCompatibilityRef {
217 pub sha256: String,
219 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 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 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 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 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}