1use std::collections::BTreeMap;
4use std::sync::atomic::Ordering;
5use std::sync::Arc;
6
7use laminar_core::checkpoint::{CheckpointAttempt, CheckpointScope, ObjectStoreCheckpointStore};
8use laminar_core::cluster::control::{
9 RecoveryAnnouncement, TopologyError, TopologyMigrationRoot, TopologyOperationId,
10 TopologyRecoveryCut, TopologyRecoveryInput, TopologyRestoreInput, TopologyVersion,
11};
12
13use super::{DbError, LaminarDB};
14
15pub(super) enum TopologyRestorePurpose {
16 CutPreparation,
17 MigrationInstallation,
18 Recovery,
19 CoordinatedRecovery(Box<RecoveryAnnouncement>),
20}
21
22#[derive(Debug)]
24pub enum PreparedTopologySourcePosition {
25 Preserved {
27 attempt: CheckpointAttempt,
29 checkpoint: laminar_connectors::checkpoint::SourceCheckpoint,
31 },
32 Initialized {
35 checkpoint: laminar_connectors::checkpoint::SourceCheckpoint,
37 },
38}
39
40impl PreparedTopologySourcePosition {
41 #[must_use]
45 pub fn startup_position(&self) -> laminar_connectors::connector::SourcePosition {
46 match self {
47 Self::Preserved {
48 attempt,
49 checkpoint,
50 } => laminar_connectors::connector::SourcePosition::Resume {
51 attempt: *attempt,
52 checkpoint: checkpoint.clone(),
53 },
54 Self::Initialized { checkpoint } => {
55 laminar_connectors::connector::SourcePosition::Initialized {
56 checkpoint: checkpoint.clone(),
57 }
58 }
59 }
60 }
61}
62
63pub struct PreparedTopologyRestore {
69 pub(crate) candidate: LaminarDB,
70 pub(crate) graph: crate::operator_graph::OperatorGraph,
71 pub(crate) input: TopologyRestoreInput,
72 recovery: Option<Box<TopologyRecoveryInput>>,
74 pub(super) recovery_start: Option<RecoveryAnnouncement>,
75 recovered: crate::recovery_manager::RecoveredState,
76 sources: BTreeMap<String, PreparedTopologySourcePosition>,
77 restored_frames: usize,
78 pub(super) parent_retirement_observed: bool,
79 compiler: tokio::sync::OwnedMutexGuard<()>,
81}
82
83impl std::fmt::Debug for PreparedTopologyRestore {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("PreparedTopologyRestore")
86 .field("operation", &self.input.operation().operation_id)
87 .field("target_version", &self.target_version())
88 .field("restored_frames", &self.restored_frames)
89 .field(
90 "recovery_cut",
91 &self.recovery.as_ref().map(|input| input.cut()),
92 )
93 .field(
94 "parent_retirement_observed",
95 &self.parent_retirement_observed,
96 )
97 .field("managed_state_bytes", &self.managed_state_bytes())
98 .field(
99 "private_catalog_state",
100 &super::DbState::load(&self.candidate.state),
101 )
102 .finish_non_exhaustive()
103 }
104}
105
106impl PreparedTopologyRestore {
107 pub(crate) fn into_runtime(
108 self,
109 ) -> Result<
110 (
111 crate::operator_graph::OperatorGraph,
112 TopologyRuntimeMetadata,
113 ),
114 DbError,
115 > {
116 let Self {
117 candidate,
118 graph,
119 input,
120 recovery,
121 recovery_start,
122 recovered,
123 sources,
124 compiler,
125 ..
126 } = self;
127 let recovery = match (recovery, recovery_start) {
128 (Some(selection), Some(start)) => Some(TopologyRecoveryRuntime {
129 selection,
130 start,
131 released: false,
132 }),
133 (None, None) => None,
134 _ => return Err(TopologyError::Fenced.into()),
135 };
136 drop(candidate);
137 Ok((
138 graph,
139 TopologyRuntimeMetadata {
140 input,
141 recovery,
142 recovered,
143 sources,
144 _compiler: compiler,
145 },
146 ))
147 }
148
149 #[must_use]
152 pub const fn is_committed(&self) -> bool {
153 self.input.is_committed()
154 }
155 #[must_use]
159 pub const fn parent_retirement_observed(&self) -> bool {
160 self.parent_retirement_observed
161 }
162
163 pub(super) fn belongs_to(&self, db: &LaminarDB) -> bool {
164 self.recovery.is_none()
165 && self.recovery_start.is_none()
166 && Arc::ptr_eq(
167 tokio::sync::OwnedMutexGuard::mutex(&self.compiler),
168 &db.topology_validation_lock,
169 )
170 }
171
172 pub(crate) fn belongs_to_recovery(&self, db: &LaminarDB, start: &RecoveryAnnouncement) -> bool {
173 self.recovery_start.as_ref() == Some(start)
174 && self.recovery.is_some()
175 && Arc::ptr_eq(
176 tokio::sync::OwnedMutexGuard::mutex(&self.compiler),
177 &db.topology_validation_lock,
178 )
179 }
180
181 #[must_use]
183 pub fn target_version(&self) -> TopologyVersion {
184 self.input.descriptor().target_version
185 }
186 #[must_use]
188 pub fn root(&self) -> &TopologyMigrationRoot {
189 self.input.root()
190 }
191 #[must_use]
193 pub const fn restored_frame_count(&self) -> usize {
194 self.restored_frames
195 }
196 #[must_use]
198 pub fn managed_state_bytes(&self) -> usize {
199 self.graph.managed_state_accounted_bytes()
200 }
201 #[must_use]
203 pub const fn source_positions(&self) -> &BTreeMap<String, PreparedTopologySourcePosition> {
204 &self.sources
205 }
206 #[must_use]
209 pub const fn parent_checkpoint(&self) -> &laminar_core::checkpoint::CommittedCheckpointIndex {
210 self.input.checkpoint()
211 }
212
213 #[must_use]
216 pub fn recovery_input(&self) -> Option<&TopologyRecoveryInput> {
217 self.recovery.as_deref()
218 }
219
220 #[must_use]
222 pub const fn recovery_checkpoint(&self) -> &laminar_core::checkpoint::CommittedCheckpointIndex {
223 &self.recovered.committed
224 }
225}
226
227#[derive(Clone)]
229pub(crate) struct TopologyRecoveryRuntime {
230 pub(crate) selection: Box<TopologyRecoveryInput>,
231 pub(crate) start: RecoveryAnnouncement,
232 pub(crate) released: bool,
234}
235
236pub(crate) struct TopologyRuntimeMetadata {
237 pub(crate) input: TopologyRestoreInput,
238 pub(crate) recovery: Option<TopologyRecoveryRuntime>,
239 pub(crate) recovered: crate::recovery_manager::RecoveredState,
240 pub(crate) sources: BTreeMap<String, PreparedTopologySourcePosition>,
241 _compiler: tokio::sync::OwnedMutexGuard<()>,
242}
243
244impl LaminarDB {
245 pub async fn prepare_cluster_topology_restore(
259 &self,
260 operation_id: TopologyOperationId,
261 ) -> Result<PreparedTopologyRestore, DbError> {
262 self.prepare_topology_restore_image(operation_id, TopologyRestorePurpose::CutPreparation)
263 .await
264 }
265
266 pub async fn recover_committed_cluster_topology(
275 &self,
276 operation_id: TopologyOperationId,
277 ) -> Result<PreparedTopologyRestore, DbError> {
278 self.prepare_topology_restore_image(
279 operation_id,
280 TopologyRestorePurpose::MigrationInstallation,
281 )
282 .await
283 }
284
285 pub(super) async fn prepare_topology_restore_image(
286 &self,
287 operation_id: TopologyOperationId,
288 purpose: TopologyRestorePurpose,
289 ) -> Result<PreparedTopologyRestore, DbError> {
290 let committed = !matches!(purpose, TopologyRestorePurpose::CutPreparation);
291 let recovery_start = match &purpose {
292 TopologyRestorePurpose::CoordinatedRecovery(start) => Some((**start).clone()),
293 _ => None,
294 };
295 self.ensure_topology_restore_purpose(&purpose)?;
296 let compiler = Arc::clone(&self.topology_validation_lock)
297 .try_lock_owned()
298 .map_err(|_| TopologyError::PlanningBusy)?;
299 let (candidate, graph, input, recovery, recovered, sources, restored_frames) =
301 tokio::time::timeout(std::time::Duration::from_secs(45), async {
302 let controller = self.cluster_controller.lock().clone().ok_or_else(|| {
303 TopologyError::Protocol("restore requires the configured controller".into())
304 })?;
305 let recovery = if committed {
306 let selection = if let Some(start) = &recovery_start {
307 controller.topology_recovery_input(&start.round, super::recovery_runtime::start_epoch(start)?).await?
308 } else {
309 controller.committed_topology_recovery_input(operation_id).await?
310 };
311 if selection.migration().operation().operation_id != operation_id {
312 return Err(TopologyError::Fenced.into());
313 }
314 if matches!(purpose, TopologyRestorePurpose::MigrationInstallation)
315 && selection.cut() != TopologyRecoveryCut::MigrationRoot {
316 return Err(TopologyError::Conflict(
317 "target checkpoint has committed; reconstruction requires coordinated target recovery".into(),
318 ).into());
319 }
320 if super::DbState::load(&self.state) == super::DbState::ShuttingDown {
321 self.stop_pipeline_for_topology_retirement().await?;
322 }
323 Some(Box::new(selection))
324 } else {
325 None
326 };
327 let input = if let Some(selection) = &recovery {
328 selection.migration().clone()
329 } else {
330 controller.topology_restore_input(operation_id).await?
331 };
332 if !committed {
333 self.validate_bound_parent_pipeline(&input.descriptor().parent_pipeline).await?;
334 }
335 let local = self.catalog_manifest_inventory()?;
336 let catalog_matches = local == input.parent().entries
337 || (committed && (local.is_empty() || local == input.target().entries));
338 if !catalog_matches {
339 return Err(TopologyError::Conflict(
340 "restore requires the exact live parent inventory".into(),
341 ).into());
342 }
343 if !committed && self.topology_definition_identities().await?.pipeline != input.descriptor().parent_pipeline {
344 return Err(TopologyError::Conflict(
345 "live parent differs from the certified restore parent".into(),
346 ).into());
347 }
348 let candidate = self.isolated_topology_catalog()?;
349 for entry in &input.target().entries {
350 super::planning::replay_entry(&candidate, entry).await?;
351 }
352 if candidate.reconcile_catalog_manifest_inventory(input.target())? != input.target().entries {
353 return Err(TopologyError::Invalid(
354 "restore candidate changed its exact incarnations".into(),
355 ).into());
356 }
357 let identities = candidate.topology_definition_identities().await?;
358 if identities.pipeline != input.descriptor().target_pipeline
359 || identities.environment_sha256 != input.descriptor().environment_sha256
360 {
361 return Err(TopologyError::Conflict(
362 "restore candidate differs from the certified target environment".into(),
363 ).into());
364 }
365 let mut scope = crate::operator::sql_query::ClusterShuffleConfig {
366 registry: self.vnode_registry.lock().clone().ok_or(TopologyError::Fenced)?,
367 sender: self.shuffle_sender.lock().clone().ok_or(TopologyError::Fenced)?,
368 receiver: self.shuffle_receiver.lock().clone().ok_or(TopologyError::Fenced)?,
369 topology: None,
370 self_id: laminar_core::state::NodeId(input.process().participant.node_id),
371 };
372 scope.topology = scope.sender.topology_fence();
373 scope.ensure_topology_current()?;
374 let parent_topology = if input.plan().expected_parent == TopologyVersion::LEGACY_BASELINE {
375 None
376 } else {
377 Some(laminar_core::shuffle::ShuffleTopologyFence::from_manifest(
378 input.plan().expected_parent, &input.plan().parent_manifest,
379 ).map_err(|error| TopologyError::Invalid(error.to_string()))?)
380 };
381 let target_topology = laminar_core::shuffle::ShuffleTopologyFence::from_manifest(
382 input.descriptor().target_version, &input.plan().target_manifest,
383 ).map_err(|error| TopologyError::Invalid(error.to_string()))?;
384 if scope.receiver.topology_fence() != scope.topology
385 || (scope.topology != parent_topology
386 && !(committed && (scope.topology == Some(target_topology)
387 || (super::DbState::load(&self.state) == super::DbState::Created && scope.topology.is_none())))) {
388 return Err(TopologyError::Fenced.into());
389 }
390 let assignment = scope.registry.versioned_snapshot();
391 let owner_ids = assignment.owners().iter().map(|owner| owner.0).collect::<Vec<_>>();
392 if assignment.version() != input.assignment().assignment_version
393 || !input.assignment().matches_owner_map(&owner_ids)
394 {
395 return Err(TopologyError::Fenced.into());
396 }
397 let (description, graph) = candidate.compile_topology_restore_graph(&input, scope).await?;
398 let objects = super::planning::describe_catalog(
399 &candidate, input.target(), &identities, &description, input.parent(),
400 )?;
401 if objects.into_values().ne(input.descriptor().objects.iter().filter(|object| object.transition != super::ClusterTopologyObjectTransition::Remove).cloned()) {
402 return Err(TopologyError::Conflict(
403 "restore graph differs from its certified descriptor".into(),
404 ).into());
405 }
406 drop(description);
407 let budget = self.config.pipeline_max_managed_state_bytes.ok_or_else(|| {
408 TopologyError::Invalid("restore has no managed-state budget".into())
409 })?;
410 let objects = self.checkpoint_object_store()?.ok_or_else(|| {
411 TopologyError::Protocol("restore requires configured checkpoint storage".into())
412 })?;
413 let store = ObjectStoreCheckpointStore::new(objects, "")
414 .with_participant_id(input.process().participant.node_id)
415 .with_key_group_count(self.checkpoint_key_groups())
416 .with_max_node_data_bytes(
417 self.config.checkpoint.as_ref().and_then(|c| c.max_node_data_bytes)
418 .unwrap_or(laminar_core::checkpoint::checkpoint_store::DEFAULT_MAX_CHECKPOINT_NODE_DATA_BYTES),
419 )?;
420 let selected_pipeline = recovery.as_ref().map_or(
421 &input.descriptor().parent_pipeline, |selection| &selection.checkpoint().pipeline_identity,
422 );
423 let reader = crate::recovery_manager::RecoveryManager::new(
424 &store, selected_pipeline,
425 &input.descriptor().deployment_id, CheckpointScope::Cluster,
426 );
427 let mut recovered = if let Some(selection) = &recovery {
428 reader.recover_topology_selection(selection, budget).await?
429 } else {
430 reader.recover_topology_root(&input, budget).await?
431 };
432 let (graph, restored_frames) = graph.restore_topology_state_frames(&recovered, &input)?;
433 if recovery.as_ref().is_some_and(|selection| selection.cut() == TopologyRecoveryCut::TargetCheckpoint) {
434 super::recovery::validate_target_subscription_frontiers(&graph, &recovered, &input)?;
435 } else {
436 validate_subscription_frontiers(&graph, &input)?;
437 }
438 recovered.state_frames.clear();
440 let sources = prepare_source_positions_at_cut(&candidate, &input, recovery.as_deref()).await?;
441 let after = if let Some(selection) = &recovery {
442 let fresh = if let Some(start) = &recovery_start {
443 controller.topology_recovery_input(&start.round, super::recovery_runtime::start_epoch(start)?).await?
444 } else {
445 controller.committed_topology_recovery_input(operation_id).await?
446 };
447 if !fresh.same_restore_requirements(selection) {
448 return Err(TopologyError::Fenced.into());
449 }
450 fresh.migration().clone()
451 } else {
452 controller.topology_restore_input(operation_id).await?
453 };
454 if !after.same_restore_requirements(&input) {
455 return Err(TopologyError::Fenced.into());
456 }
457 self.ensure_topology_restore_purpose(&purpose)?;
458 if self.catalog_manifest_inventory()? != local { return Err(TopologyError::Fenced.into()); }
459 if !committed {
460 self.validate_bound_parent_pipeline(&input.descriptor().parent_pipeline).await?;
461 if self.topology_definition_identities().await?.pipeline != input.descriptor().parent_pipeline {
462 return Err(TopologyError::Fenced.into());
463 }
464 }
465 let recovery = matches!(purpose, TopologyRestorePurpose::Recovery | TopologyRestorePurpose::CoordinatedRecovery(_)).then_some(recovery).flatten();
466 Ok::<_, DbError>((candidate, graph, input, recovery, recovered, sources, restored_frames))
467 })
468 .await
469 .map_err(|_| TopologyError::Contended)??;
470 Ok(PreparedTopologyRestore {
471 candidate,
472 graph,
473 input,
474 recovery,
475 recovery_start,
476 recovered,
477 sources,
478 restored_frames,
479 parent_retirement_observed: false,
480 compiler,
481 })
482 }
483
484 fn ensure_topology_restore_purpose(
485 &self,
486 purpose: &TopologyRestorePurpose,
487 ) -> Result<(), DbError> {
488 if let TopologyRestorePurpose::CoordinatedRecovery(start) = purpose {
489 self.ensure_topology_recovery_stopped(start)
490 } else {
491 self.ensure_topology_restore_available(!matches!(
492 purpose,
493 TopologyRestorePurpose::CutPreparation
494 ))
495 }
496 }
497
498 pub(super) fn ensure_topology_restore_available(&self, committed: bool) -> Result<(), DbError> {
499 if !committed {
500 if super::DbState::load(&self.state) == super::DbState::ShuttingDown {
501 return self.ensure_topology_retirement_available();
504 }
505 return self.ensure_topology_root_available();
506 }
507 if self.is_closed() {
508 return Err(DbError::Shutdown);
509 }
510 let state = super::DbState::load(&self.state);
511 if !self.is_cluster_runtime()
512 || !matches!(
513 state,
514 super::DbState::Created | super::DbState::ShuttingDown
515 )
516 || (state == super::DbState::ShuttingDown
517 && (!self.topology_cut_hold.load(Ordering::Acquire)
518 || !self.source_gate.load(Ordering::Acquire)
519 || !self.runtime_shutdown.read().is_cancelled()))
520 || self.cluster_authority_revoked.load(Ordering::Acquire)
521 || self.durable_terminal_recovery_fence.load(Ordering::Acquire)
522 || self.terminal_pipeline_halt.load(Ordering::Acquire)
523 || self.coordinated_recovery_in_progress()
524 || self.pending_recovery_fault.load(Ordering::Acquire) != 0
525 || self.last_fault.lock().is_some()
526 {
527 return Err(TopologyError::Conflict("committed reconstruction requires a Created DB or its still-held retired parent without local faults".into()).into());
528 }
529 self.ensure_catalog_cleanup_unfenced("committed topology reconstruction")
530 }
531}
532
533pub(super) async fn prepare_source_positions(
534 candidate: &LaminarDB,
535 input: &TopologyRestoreInput,
536) -> Result<BTreeMap<String, PreparedTopologySourcePosition>, DbError> {
537 prepare_source_positions_at_cut(candidate, input, None).await
538}
539
540pub(super) async fn prepare_source_positions_at_cut(
541 candidate: &LaminarDB,
542 input: &TopologyRestoreInput,
543 recovery: Option<&TopologyRecoveryInput>,
544) -> Result<BTreeMap<String, PreparedTopologySourcePosition>, DbError> {
545 let cut = recovery.map_or_else(|| input.checkpoint(), TopologyRecoveryInput::checkpoint);
546 let registrations = candidate.connector_manager.lock().sources().clone();
547 let mut sources = BTreeMap::new();
548 for name in candidate.catalog.list_sources() {
549 let registration = registrations
550 .get(&name)
551 .ok_or_else(|| TopologyError::Invalid("restore source has no connector".into()))?;
552 let config = candidate.build_registered_source_config(&name, registration)?;
553 let mut connector = candidate.connector_registry.create_source(&config, None)?;
554 let target_cut = cut.pipeline_identity == input.descriptor().target_pipeline;
555 let preserved = input.root().preserved_objects.iter().any(|object| {
556 object.kind == laminar_core::cluster::control::CatalogObjectKind::Source
557 && object.name == name
558 });
559 let position = if let Some(checkpoint) = cut
560 .source_offsets
561 .get(&name)
562 .filter(|_| target_cut || preserved)
563 {
564 let scoped = connector.contract(&config)?.topology
565 == laminar_connectors::connector::SourceTopology::Splittable;
566 crate::pipeline_lifecycle::validate_source_recovery_assignment(
567 &name,
568 scoped,
569 Some(checkpoint),
570 cut.assignment_fence.as_ref().and_then(|assignment| {
571 std::num::NonZeroU64::new(assignment.assignment_version)
572 }),
573 )?;
574 PreparedTopologySourcePosition::Preserved {
575 attempt: CheckpointAttempt::canonical(cut.epoch),
576 checkpoint: crate::checkpoint_coordinator::connector_to_source_checkpoint(
577 checkpoint,
578 ),
579 }
580 } else {
581 if recovery
582 .is_some_and(|selection| selection.cut() == TopologyRecoveryCut::TargetCheckpoint)
583 {
584 return Err(TopologyError::Invalid(format!(
585 "target checkpoint has no committed progress for source '{name}'; root initialization cannot replace it",
586 )).into());
587 }
588 let initialization = input
589 .root()
590 .source_initializations
591 .iter()
592 .find(|source| source.name == name)
593 .ok_or_else(|| {
594 TopologyError::Invalid("new source has no sealed initialization".into())
595 })?;
596 let checkpoint = crate::checkpoint_coordinator::connector_to_source_checkpoint(
597 &initialization.checkpoint,
598 );
599 connector
600 .validate_initial_position(&config, &checkpoint)
601 .await
602 .map_err(|error| {
603 TopologyError::Unsupported(format!(
604 "source '{name}' sealed cursor validation: {error}"
605 ))
606 })?;
607 PreparedTopologySourcePosition::Initialized { checkpoint }
608 };
609 sources.insert(name, position);
610 }
611 Ok(sources)
612}
613
614impl LaminarDB {
615 pub(crate) fn bind_topology_subscriptions(
616 &self,
617 streams: &mut std::collections::HashMap<
618 String,
619 crate::connector_manager::StreamRegistration,
620 >,
621 input: &TopologyRestoreInput,
622 schemas: &std::collections::HashMap<String, arrow_schema::SchemaRef>,
623 ) -> Result<(), DbError> {
624 for subscription in &input.root().subscriptions {
625 let certificate = &subscription.target_certificate;
626 let stream = streams.get_mut(&certificate.stream_id).ok_or_else(|| {
627 TopologyError::Invalid("preserved subscription has no target stream".into())
628 })?;
629 if stream.subscription_output.is_none()
630 || stream.catalog_generation != certificate.catalog_generation
631 {
632 return Err(TopologyError::Invalid(
633 "preserved subscription has a different target incarnation".into(),
634 )
635 .into());
636 }
637 stream.subscription_certificate = Some(certificate.clone());
638 }
639 for stream in streams
640 .values_mut()
641 .filter(|stream| stream.subscription_certificate.is_none())
642 {
643 let Some(output) = &stream.subscription_output else {
644 continue;
645 };
646 let object = input
647 .descriptor()
648 .objects
649 .iter()
650 .find(|object| {
651 object.name == stream.name
652 && object.transition != super::ClusterTopologyObjectTransition::Remove
653 })
654 .ok_or_else(|| {
655 TopologyError::Invalid("new subscription has no descriptor".into())
656 })?;
657 if !object.initialization.starts_empty_at_cut()
658 || object.catalog_generation != stream.catalog_generation
659 || input
660 .root()
661 .future_only_objects
662 .binary_search(&stream.name)
663 .is_err()
664 {
665 return Err(TopologyError::Invalid(
666 "new subscription has no empty-state cut contract".into(),
667 )
668 .into());
669 }
670 let schema = schemas.get(&stream.name).ok_or_else(|| {
671 TopologyError::Invalid("new subscription has no resolved schema".into())
672 })?;
673 let schema_fingerprint =
674 crate::pipeline_identity::subscription_schema_fingerprint(schema)?;
675 if object.schema_sha256.as_deref() != Some(schema_fingerprint.to_hex().as_str()) {
676 return Err(TopologyError::Invalid(
677 "new subscription schema differs from its descriptor".into(),
678 )
679 .into());
680 }
681 stream.subscription_certificate = Some(
682 output.bind(
683 uuid::Uuid::parse_str(&input.descriptor().deployment_id)
684 .map_err(|error| TopologyError::Invalid(error.to_string()))?,
685 stream.catalog_generation,
686 &stream.name,
687 schema_fingerprint,
688 stream.subscription_retention_bytes,
689 input.descriptor().target_pipeline.clone(),
690 self.checkpoint_key_groups(),
691 )?,
692 );
693 }
694 self.connector_manager
695 .lock()
696 .install_stream_subscription_certificates(streams)
697 }
698}
699
700fn validate_subscription_frontiers(
701 graph: &crate::operator_graph::OperatorGraph,
702 input: &TopologyRestoreInput,
703) -> Result<(), DbError> {
704 let captures = graph.capture_subscription_frontiers()?;
705 let mut preserved = 0;
706 for capture in &captures {
707 let Some(mapping) =
708 input.root().subscriptions.iter().find(|mapping| {
709 mapping.target_certificate.stream_id == capture.certificate.stream_id
710 })
711 else {
712 let initialized = input.descriptor().objects.iter().any(|object| {
713 object.name == capture.certificate.stream_id
714 && object.initialization.starts_empty_at_cut()
715 && object.catalog_generation == capture.certificate.catalog_generation
716 && input
717 .root()
718 .future_only_objects
719 .binary_search(&object.name)
720 .is_ok()
721 });
722 if !initialized
723 || capture.certificate.pipeline_identity != input.descriptor().target_pipeline
724 || capture.frontiers.iter().any(|frontier| {
725 frontier.through_sequence != laminar_core::checkpoint::PartitionSequence::FIRST
726 })
727 {
728 return Err(TopologyError::Invalid(
729 "new subscription did not initialize at its first sequence".into(),
730 )
731 .into());
732 }
733 continue;
734 };
735 preserved += 1;
736 let expected = mapping
737 .frontiers
738 .iter()
739 .filter(|frontier| {
740 input
741 .owned_vnodes()
742 .binary_search(&u32::from(frontier.partition.get()))
743 .is_ok()
744 })
745 .collect::<Vec<_>>();
746 if capture.certificate.as_ref() != &mapping.target_certificate
747 || capture.frontiers.iter().collect::<Vec<_>>() != expected
748 {
749 return Err(TopologyError::Invalid(
750 "restored subscription identity or exclusive sequence differs from the root".into(),
751 )
752 .into());
753 }
754 }
755 if preserved != input.root().subscriptions.len() {
756 return Err(TopologyError::Invalid(
757 "restored subscription roster differs from the root".into(),
758 )
759 .into());
760 }
761 Ok(())
762}