1use 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;
18pub const MAX_TOPOLOGY_ROOT_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct TopologyPreservedObject {
25 pub name: String,
27 pub kind: CatalogObjectKind,
29 pub catalog_generation: u64,
31 pub compatibility_sha256: String,
33 pub state_operator_id: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct TopologySubscriptionRoot {
41 pub parent_certificate: OutputDistributionCertificate,
43 pub target_certificate: OutputDistributionCertificate,
47 pub frontiers: Vec<PartitionFrontier>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct TopologyMigrationRoot {
57 pub format_version: u16,
59 pub operation_id: TopologyOperationId,
61 pub plan: TopologyPlanRef,
63 pub compatibility: TopologyCompatibilityRef,
65 pub cut: TopologyCutCommit,
67 pub preserved_objects: Vec<TopologyPreservedObject>,
69 pub future_only_objects: Vec<String>,
71 pub subscriptions: Vec<TopologySubscriptionRoot>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub source_initializations: Vec<TopologySourceInitialization>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct TopologyMigrationRootRef {
83 pub sha256: String,
85 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct TopologyMigrationRootBinding {
111 pub root: TopologyMigrationRootRef,
113 pub authority_sequence: u64,
115}
116
117impl TopologyMigrationRoot {
118 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 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}