Skip to main content

laminar_db/db/topology/
restore.rs

1//! Private target images reuse the isolated compiler and exact-cut recovery loader.
2
3use 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/// A source's preparation boundary. A sealed new-source cursor is never processing history.
23#[derive(Debug)]
24pub enum PreparedTopologySourcePosition {
25    /// Existing committed progress, retaining the exact parent attempt and assignment metadata.
26    Preserved {
27        /// Definitive old checkpoint attempt.
28        attempt: CheckpointAttempt,
29        /// Existing connector replay cursor.
30        checkpoint: laminar_connectors::checkpoint::SourceCheckpoint,
31    },
32    /// New global unowned position. Installation must validate/filter its current assignment;
33    /// no checkpoint attempt or acknowledgement is invented here.
34    Initialized {
35        /// Concrete cursor sealed in the immutable root.
36        checkpoint: laminar_connectors::checkpoint::SourceCheckpoint,
37    },
38}
39
40impl PreparedTopologySourcePosition {
41    /// Build the atomic startup position without inventing checkpoint history for a new source.
42    /// The installer must still revalidate Commit, current ownership and sealed availability,
43    /// keep source intake held, and wait for participant-complete target Release.
44    #[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
63/// One unstarted target state image. It owns the existing local compiler slot until dropped.
64/// It has no source/sink actors or installation/output authority. Existing fenced parent transport
65/// handles provide channel decoding context; no receiver or sender is started by preparation.
66/// Even a successful preparation requires observed predecessor retirement, fresh authority checks, Commit and
67/// coordinated target Release before it can become a runtime graph.
68pub struct PreparedTopologyRestore {
69    pub(crate) candidate: LaminarDB,
70    pub(crate) graph: crate::operator_graph::OperatorGraph,
71    pub(crate) input: TopologyRestoreInput,
72    // A selected recovery image cannot use the original migration installation/Release path.
73    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    // Declared last: the private graph/catalog are dropped before another compiler can run.
80    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    /// Whether this image was authorized from an irreversible target Commit. It remains private;
150    /// receivers, state installation, sinks and participant-complete Release are still required.
151    #[must_use]
152    pub const fn is_committed(&self) -> bool {
153        self.input.is_committed()
154    }
155    /// Whether this image's parent actors were observed terminal after exact authority checks.
156    /// This is a local observation, not a durable readiness receipt or target output permit.
157    /// A future installer must revalidate current authority and the retired runtime boundary.
158    #[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    /// Target version of this private image; Commit does not activate it.
182    #[must_use]
183    pub fn target_version(&self) -> TopologyVersion {
184        self.input.descriptor().target_version
185    }
186    /// Requirements consumed by this image; they grant no target output permit.
187    #[must_use]
188    pub fn root(&self) -> &TopologyMigrationRoot {
189        self.input.root()
190    }
191    /// Number of verified local frames successfully restored through existing operator codecs.
192    #[must_use]
193    pub const fn restored_frame_count(&self) -> usize {
194        self.restored_frames
195    }
196    /// Accounted managed state in the prepared graph, bounded by the configured state budget.
197    #[must_use]
198    pub fn managed_state_bytes(&self) -> usize {
199        self.graph.managed_state_accounted_bytes()
200    }
201    /// Preserved/new cursor origins. Sources have not started or acknowledged input.
202    #[must_use]
203    pub const fn source_positions(&self) -> &BTreeMap<String, PreparedTopologySourcePosition> {
204        &self.sources
205    }
206    /// Original historical checkpoint metadata and identity. Its state buffers are released after
207    /// decoding; no historical checkpoint is relabelled as target processing history.
208    #[must_use]
209    pub const fn parent_checkpoint(&self) -> &laminar_core::checkpoint::CommittedCheckpointIndex {
210        self.input.checkpoint()
211    }
212
213    /// Selected private recovery authority, absent for original migration installation images.
214    /// This never authorizes reuse of the original runtime's installation receipt or Release.
215    #[must_use]
216    pub fn recovery_input(&self) -> Option<&TopologyRecoveryInput> {
217        self.recovery.as_deref()
218    }
219
220    /// Exact cut supplying this image, with its original parent or target pipeline identity.
221    #[must_use]
222    pub const fn recovery_checkpoint(&self) -> &laminar_core::checkpoint::CommittedCheckpointIndex {
223        &self.recovered.committed
224    }
225}
226
227/// Control-path ownership of the exact root until the restored graph reaches runtime readiness.
228#[derive(Clone)]
229pub(crate) struct TopologyRecoveryRuntime {
230    pub(crate) selection: Box<TopologyRecoveryInput>,
231    pub(crate) start: RecoveryAnnouncement,
232    // Set only while consuming the exact durable coordinated Release against these live actors.
233    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    /// Restore one private target from the exact held and authority-bound old cut.
246    /// Replays the immutable candidate, checks its complete certified descriptor, loads verified
247    /// local state through the strict parent recovery path, and preserves subscription incarnations
248    /// and exclusive sequences. Sealed source cursors are validated without resolving them again.
249    /// This neither mutates the live catalog/coordinator nor installs actors or opens intake.
250    ///
251    /// One image owns the existing compiler slot for its lifetime. The 45 second request budget,
252    /// aggregate 16 MiB manifest limit, configured node-read and managed-state budgets bound restore.
253    /// Cancellation/error drops the partial image and releases the slot, retaining the parent hold.
254    ///
255    /// # Errors
256    /// Rejects absent holds, stale full-roster/process/assignment authority, divergent compilation,
257    /// damaged state/output artifacts, missing state or subscription frontiers and exceeded bounds.
258    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    /// Reconstruct the current committed target from its exact migration root before its first
267    /// checkpoint. Reuses the same strict historical-parent loader and actual target codecs.
268    /// A Created DB or still-held retired parent can reconstruct; the private image never starts
269    /// actors, changes the local catalog/coordinator, acknowledges input or opens intake.
270    ///
271    /// # Errors
272    /// Rejects uncommitted/obsolete targets, live or faulted local actors, changed owner maps,
273    /// stale current process/adoption, missing/corrupt artifacts or the existing 45 second budget.
274    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        // Keep the permit outside the timed future so cancellation drops partial state first.
300        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                // Release verified encoded buffers immediately after decoding, before broker I/O.
439                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                // A monitor restart may drop its private image after observed parent retirement.
502                // The exact held parent/root still permit private reconstruction, never intake.
503                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}