Skip to main content

laminar_db/pipeline_lifecycle/
cluster_startup.rs

1#[cfg(feature = "cluster")]
2use super::{Arc, ClusterStartupDisposition, StartupCheckpointArtifactAudit};
3use super::{DbError, LaminarDB, StartupAttempt};
4
5impl LaminarDB {
6    pub(super) async fn join_runtime_watcher_until(
7        &self,
8        deadline: tokio::time::Instant,
9    ) -> Result<(), DbError> {
10        let mut owned = tokio::time::timeout_at(deadline, self.runtime_handle.lock())
11            .await
12            .map_err(|_| {
13                DbError::Pipeline(
14                    "runtime watcher ownership deadline expired; generation remains fenced".into(),
15                )
16            })?;
17        if let Some(watcher) = owned.as_mut() {
18            let result = tokio::time::timeout_at(deadline, watcher)
19                .await
20                .map_err(|_| {
21                    DbError::Pipeline(
22                        "runtime watcher is still draining; generation remains fenced".into(),
23                    )
24                })?;
25            owned.take();
26            result.map_err(|error| {
27                DbError::Pipeline(format!(
28                    "runtime watcher failed during startup cleanup: {error}"
29                ))
30            })?;
31        }
32        Ok(())
33    }
34
35    #[cfg(feature = "cluster")]
36    pub(super) fn should_defer_initial_sink_epoch_for_artifact_recovery(
37        &self,
38    ) -> Result<bool, DbError> {
39        if !self.is_cluster_runtime() {
40            return Ok(false);
41        }
42        let Some(StartupCheckpointArtifactAudit::Artifacts(audit_process)) =
43            *self.startup_checkpoint_artifact_audit.lock()
44        else {
45            return Ok(false);
46        };
47        let controller = self.cluster_controller.lock().clone().ok_or_else(|| {
48            DbError::Checkpoint("cluster artifact recovery has no process authority".into())
49        })?;
50        let current_process = controller
51            .try_live_local_process_authority_identity()
52            .map_err(|error| {
53                DbError::Checkpoint(format!(
54                    "cluster artifact recovery lost process authority: {error}"
55                ))
56            })?;
57        if current_process != audit_process || !self.cluster_intake_fenced() {
58            return Err(DbError::Checkpoint(
59                "cluster artifact recovery cannot defer sink admission without its exact fenced startup authority"
60                    .into(),
61            ));
62        }
63        Ok(true)
64    }
65
66    /// Finish clustered startup after the exact assignment fence is available.
67    ///
68    /// Fresh nodes open intake only when no durable recovery round exists. A process that restored
69    /// local state, or one that observes an active/stale round, remains fenced and requests a full
70    /// coordinated rewind.
71    ///
72    /// # Errors
73    ///
74    /// Returns a checkpoint error when the local assignment has not been certified. Intake remains
75    /// closed when recovery authority is unavailable so the monitor can retry without admitting
76    /// records.
77    #[cfg(feature = "cluster")]
78    pub async fn finish_cluster_startup(
79        &self,
80        deadline: tokio::time::Instant,
81    ) -> Result<ClusterStartupDisposition, DbError> {
82        let authority_revision = self
83            .assignment_authority_revision
84            .load(std::sync::atomic::Ordering::Acquire);
85        let controller = self.cluster_controller.lock().clone().ok_or_else(|| {
86            DbError::Checkpoint("cluster startup has no recovery controller".into())
87        })?;
88        if self.coordinated_recovery_in_progress()
89            && self
90                .topology_cut_hold
91                .load(std::sync::atomic::Ordering::Acquire)
92        {
93            return Ok(ClusterStartupDisposition::RecoveryFenced);
94        }
95        let registry =
96            self.vnode_registry.lock().clone().ok_or_else(|| {
97                DbError::Checkpoint("cluster startup has no vnode assignment".into())
98            })?;
99        let assignment = registry.versioned_snapshot();
100        let assignment_version = assignment.version();
101        let owners: Vec<u64> = assignment.owners().iter().map(|owner| owner.0).collect();
102        let assignment_fence = controller
103            .checkpoint_assignment_fence(assignment_version)
104            .filter(|fence| fence.matches_owner_map(&owners))
105            .ok_or_else(|| {
106                DbError::Checkpoint(format!(
107                    "cluster assignment {assignment_version} is not certified for source intake"
108                ))
109            })?;
110        let local_id = controller.instance_id().0;
111        let local_incarnation = assignment_fence.participant_incarnation(local_id);
112        let idle = match local_incarnation {
113            Some(incarnation) if incarnation == controller.recovery_incarnation() => false,
114            Some(_) => {
115                return Err(DbError::Checkpoint(format!(
116                    "cluster assignment {assignment_version} certifies another incarnation of process {local_id}"
117                )));
118            }
119            None if owners.contains(&local_id) => {
120                return Err(DbError::Checkpoint(format!(
121                    "cluster assignment {assignment_version} gives process {local_id} ownership without checkpoint authority"
122                )));
123            }
124            None => true,
125        };
126        if idle {
127            // This process is absent from the exact owner-complete checkpoint roster. A global
128            // artifact inventory can therefore belong only to the active owners; treating it as
129            // local crash residue would let an idle join interrupt their live checkpoint. Install
130            // topology metadata while retaining the local data-plane fence, without publishing a
131            // recovery fault or attempting to settle another process's artifacts.
132            controller.set_recovering(false);
133            let drain_transition = controller
134                .checkpoint_drain_transition()
135                .filter(|transition| transition.predecessor == assignment_fence);
136            let activation = self
137                .activate_assignment_authority(
138                    &assignment_fence,
139                    drain_transition,
140                    authority_revision,
141                    deadline,
142                )
143                .await?;
144            if !activation.installed {
145                self.set_source_gate(true);
146                return Ok(ClusterStartupDisposition::RecoveryFenced);
147            }
148            return Ok(ClusterStartupDisposition::Idle);
149        }
150        // A clean pre-start audit linearizes before this process can publish graph readiness. Once
151        // the owner-complete assignment fence exists, every participant has crossed that same
152        // boundary, so a later inventory belongs to live checkpoint work and is not crash residue.
153        // Evidence from any other process generation is unusable and falls back to the durable
154        // read below.
155        let audited_artifacts = self
156            .startup_checkpoint_artifact_audit
157            .lock()
158            .as_ref()
159            .copied()
160            .filter(|audit| {
161                controller
162                    .try_live_local_process_authority_identity()
163                    .is_ok_and(|process| process == audit.process())
164            });
165        let unresolved_artifacts = match audited_artifacts {
166            Some(StartupCheckpointArtifactAudit::Clean(_)) => false,
167            Some(StartupCheckpointArtifactAudit::Artifacts(_)) => true,
168            None => {
169                let checkpoint_authority = controller.checkpoint_authority().map_err(|error| {
170                    DbError::Checkpoint(format!(
171                        "cluster startup checkpoint authority is unavailable: {error}"
172                    ))
173                })?;
174                tokio::time::timeout_at(
175                    deadline,
176                    checkpoint_authority.cluster_checkpoint_artifacts(),
177                )
178                .await
179                .map_err(|_| {
180                    DbError::Checkpoint("cluster startup artifact inventory read timed out".into())
181                })?
182                .map_err(|error| {
183                    DbError::Checkpoint(format!(
184                        "cluster startup artifact inventory read failed: {error}"
185                    ))
186                })?
187                .is_some()
188            }
189        };
190        if unresolved_artifacts {
191            controller.set_recovering(true);
192            tokio::time::timeout_at(
193                deadline,
194                crate::coordinated_recovery::request_local_fault(
195                    &controller,
196                    &self.pending_recovery_fault,
197                ),
198            )
199            .await
200            .map_err(|_| {
201                DbError::Checkpoint("startup artifact recovery fault publication timed out".into())
202            })?
203            .map_err(DbError::Checkpoint)?;
204            return Ok(ClusterStartupDisposition::RecoveryFenced);
205        }
206        let fault_inventory =
207            match tokio::time::timeout_at(deadline, controller.read_recovery_fault_inventory())
208                .await
209            {
210                Err(_) => {
211                    controller.set_recovering(true);
212                    return Err(DbError::Checkpoint(
213                        "cluster startup recovery fault audit timed out".into(),
214                    ));
215                }
216                Ok(Err(error)) => {
217                    controller.set_recovering(true);
218                    return Err(DbError::Checkpoint(format!(
219                        "cluster startup recovery fault audit failed: {error}"
220                    )));
221                }
222                Ok(Ok(inventory)) => inventory,
223            };
224        if fault_inventory.has_terminal_fault() {
225            self.latch_durable_terminal_recovery_fence();
226            controller.set_recovering(true);
227            let reason = "cluster startup is fenced by a durable terminal pipeline fault";
228            self.last_fault.lock().get_or_insert(reason.into());
229            return Err(DbError::PipelineTerminal(reason.into()));
230        }
231        let pending_fault = !fault_inventory.faults().is_empty();
232        let Ok(active) = tokio::time::timeout_at(deadline, controller.observe_recover()).await
233        else {
234            return Err(DbError::Checkpoint(
235                "cluster startup recovery authority read timed out".into(),
236            ));
237        };
238        let active = match active {
239            Ok(active) => active,
240            Err(error) => {
241                controller.set_recovering(true);
242                tracing::error!(%error, "startup recovery authority is not currently valid");
243                tokio::time::timeout_at(
244                    deadline,
245                    crate::coordinated_recovery::request_local_fault(
246                        &controller,
247                        &self.pending_recovery_fault,
248                    ),
249                )
250                    .await
251                    .map_err(|_| {
252                        DbError::Checkpoint(
253                            "startup recovery fault publication timed out".into(),
254                        )
255                    })?
256                    .map_err(|report_error| {
257                        DbError::Checkpoint(format!(
258                            "startup recovery authority failed ({error}); fault publication failed: {report_error}"
259                        ))
260                    })?;
261                return Ok(ClusterStartupDisposition::RecoveryFenced);
262            }
263        };
264        let open_intake = if pending_fault {
265            controller.set_recovering(true);
266            false
267        } else if let Some(active) = active {
268            controller.set_recovering(true);
269            if !controller.recovery_driver_is_current(&active.round)
270                || !controller.recovery_round_contains_current_process(&active.round)
271            {
272                tokio::time::timeout_at(
273                    deadline,
274                    crate::coordinated_recovery::request_local_fault(
275                        &controller,
276                        &self.pending_recovery_fault,
277                    ),
278                )
279                .await
280                .map_err(|_| {
281                    DbError::Checkpoint("startup recovery fault publication timed out".into())
282                })?
283                .map_err(DbError::Checkpoint)?;
284            }
285            false
286        } else if self.last_recovery_epoch.lock().is_some() {
287            controller.set_recovering(true);
288            tokio::time::timeout_at(
289                deadline,
290                crate::coordinated_recovery::request_local_fault(
291                    &controller,
292                    &self.pending_recovery_fault,
293                ),
294            )
295            .await
296            .map_err(|_| {
297                DbError::Checkpoint("startup recovery fault publication timed out".into())
298            })?
299            .map_err(DbError::Checkpoint)?;
300            false
301        } else {
302            controller.set_recovering(false);
303            true
304        };
305
306        let drain_transition = controller
307            .checkpoint_drain_transition()
308            .filter(|transition| transition.predecessor == assignment_fence);
309        let activation = self
310            .activate_assignment_authority(
311                &assignment_fence,
312                drain_transition,
313                authority_revision,
314                deadline,
315            )
316            .await?;
317        if !activation.installed {
318            controller.set_recovering(true);
319            self.set_source_gate(true);
320            return Ok(ClusterStartupDisposition::RecoveryFenced);
321        }
322        Ok(if open_intake && activation.intake_open {
323            ClusterStartupDisposition::Serving
324        } else {
325            ClusterStartupDisposition::RecoveryFenced
326        })
327    }
328
329    /// Advance both shuffle directions to the coordinated recovery generation. Old streams are
330    /// rejected so pre-rewind frames cannot be folded and then replayed.
331    #[cfg(feature = "cluster")]
332    pub(crate) fn set_shuffle_recovery_gen(&self, gen: u64) {
333        // Fence inbound old-generation streams before outbound streams can emit the new one.
334        if let Some(receiver) = self.shuffle_receiver.lock().as_ref() {
335            receiver.set_recovery_gen(gen);
336        }
337        if let Some(sender) = self.shuffle_sender.lock().as_ref() {
338            sender.set_recovery_gen(gen);
339        }
340    }
341
342    /// Import the last recovery generation that reached an irrevocable cluster Release before a
343    /// fresh pipeline starts. The allocation high-watermark is deliberately not used: a driver
344    /// can reserve a generation and fail before any data plane rewinds to it.
345    ///
346    /// # Errors
347    /// Returns an error when durable recovery authority is unavailable, the process lease is
348    /// lost, or an already-active transport conflicts with the committed terminal.
349    #[cfg(feature = "cluster")]
350    pub async fn prepare_cluster_startup_recovery_generation(
351        &self,
352        deadline: tokio::time::Instant,
353    ) -> Result<(), DbError> {
354        // Never reuse evidence from an earlier or failed bootstrap attempt. Only the complete
355        // authority-checked read below publishes a replacement latch.
356        *self.startup_checkpoint_artifact_audit.lock() = None;
357        let controller = self.cluster_controller.lock().clone().ok_or_else(|| {
358            DbError::Checkpoint("cluster recovery generation bootstrap has no controller".into())
359        })?;
360        if !controller.process_lease_is_live() {
361            return Err(DbError::Checkpoint(
362                "cluster recovery generation bootstrap lost its process lease".into(),
363            ));
364        }
365        let audit_process = controller
366            .try_live_local_process_authority_identity()
367            .map_err(|error| {
368                DbError::Checkpoint(format!(
369                    "cluster startup artifact audit has no live process authority: {error}"
370                ))
371            })?;
372        let terminal =
373            tokio::time::timeout_at(deadline, controller.latest_committed_recover_release())
374                .await
375                .map_err(|_| {
376                    DbError::Checkpoint(
377                        "committed recovery Release lookup exceeded its deadline".into(),
378                    )
379                })?
380                .map_err(|error| {
381                    DbError::Checkpoint(format!(
382                        "committed recovery Release authority is unavailable: {error}"
383                    ))
384                })?;
385        let committed = terminal
386            .as_ref()
387            .map_or(0, |release| release.round.id.generation);
388        let checkpoint_authority = controller.checkpoint_authority().map_err(|error| {
389            DbError::Checkpoint(format!(
390                "cluster startup checkpoint authority is unavailable: {error}"
391            ))
392        })?;
393        let unresolved_artifacts = tokio::time::timeout_at(
394            deadline,
395            checkpoint_authority.cluster_checkpoint_artifacts(),
396        )
397        .await
398        .map_err(|_| {
399            DbError::Checkpoint("cluster startup artifact inventory read timed out".into())
400        })?
401        .map_err(|error| {
402            DbError::Checkpoint(format!(
403                "cluster startup artifact inventory read failed: {error}"
404            ))
405        })?
406        .is_some();
407        let final_process = controller
408            .try_live_local_process_authority_identity()
409            .map_err(|error| {
410                DbError::Checkpoint(format!(
411                    "cluster startup artifact audit lost process authority: {error}"
412                ))
413            })?;
414        if final_process != audit_process {
415            return Err(DbError::Checkpoint(
416                "cluster startup artifact audit changed process authority during its read".into(),
417            ));
418        }
419        *self.startup_checkpoint_artifact_audit.lock() = Some(if unresolved_artifacts {
420            StartupCheckpointArtifactAudit::Artifacts(audit_process)
421        } else {
422            StartupCheckpointArtifactAudit::Clean(audit_process)
423        });
424        let current = self.shuffle_recovery_generation()?.unwrap_or(0);
425        if current == committed {
426            return Ok(());
427        }
428        let assignment_active = self
429            .shuffle_receiver
430            .lock()
431            .as_ref()
432            .is_some_and(|receiver| receiver.assignment_version() != 0)
433            || self
434                .shuffle_sender
435                .lock()
436                .as_ref()
437                .is_some_and(|sender| sender.assignment_version() != 0);
438        let exact_terminal_participant = terminal.as_ref().is_some_and(|release| {
439            controller.recovery_round_requires_current_process_stop(&release.round)
440        });
441        if current != 0 || assignment_active || exact_terminal_participant {
442            return Err(DbError::Checkpoint(format!(
443                "startup shuffle recovery generation {current} conflicts with committed generation {committed}"
444            )));
445        }
446        self.set_shuffle_recovery_gen(committed);
447        let installed = self.shuffle_recovery_generation()?.unwrap_or(committed);
448        if installed != committed {
449            return Err(DbError::Checkpoint(format!(
450                "startup shuffle recovery generation {installed} does not match committed generation {committed}"
451            )));
452        }
453        if !controller.process_lease_is_live() {
454            return Err(DbError::Checkpoint(
455                "cluster recovery generation bootstrap lost its process lease".into(),
456            ));
457        }
458        if committed != 0 {
459            let epoch = terminal.as_ref().and_then(|release| match release.phase {
460                laminar_core::cluster::control::RecoverPhase::ReleaseCommitted { epoch } => {
461                    Some(epoch)
462                }
463                _ => None,
464            });
465            tracing::info!(
466                generation = committed,
467                ?epoch,
468                "restored committed shuffle recovery generation"
469            );
470        }
471        Ok(())
472    }
473
474    #[cfg(feature = "cluster")]
475    pub(crate) fn shuffle_recovery_generation(&self) -> Result<Option<u64>, DbError> {
476        let receiver = self.shuffle_receiver.lock().clone();
477        let sender = self.shuffle_sender.lock().clone();
478        let receiver_generation = receiver.as_ref().map(|receiver| receiver.recovery_gen());
479        let sender_generation = sender.as_ref().map(|sender| sender.recovery_gen());
480        if let (Some(receiver), Some(sender)) = (receiver_generation, sender_generation) {
481            if receiver != sender {
482                return Err(DbError::Checkpoint(format!(
483                    "shuffle endpoints disagree on recovery generation: receiver {receiver}, sender {sender}"
484                )));
485            }
486        }
487        Ok(receiver_generation.or(sender_generation))
488    }
489
490    /// Resolve only the cumulative shuffle-loss cutoff captured when this exact generation
491    /// started. This must succeed before publishing local `Release` readiness.
492    #[cfg(feature = "cluster")]
493    pub(crate) fn complete_shuffle_recovery(&self, gen: u64) -> bool {
494        self.shuffle_receiver
495            .lock()
496            .as_ref()
497            .is_none_or(|receiver| receiver.complete_recovery(gen))
498    }
499
500    /// Start the database-owned per-node recovery supervisor once. Coordinated recovery is the only cluster
501    /// fault path — a local-only restart rewinds one node while peers advance, an
502    /// inconsistent cut.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the database-owned control runtime cannot be initialized.
507    #[cfg(feature = "cluster")]
508    pub fn enable_coordinated_recovery(self: &Arc<Self>) -> Result<(), DbError> {
509        if !self.is_cluster_runtime() || self.cluster_controller.lock().is_none() {
510            return Err(DbError::Config(
511                "coordinated recovery requires a cluster runtime and controller".into(),
512            ));
513        }
514        let runtime = self.control_runtime.handle()?;
515        let mut owned = self.recovery_monitor.lock();
516        if owned.as_ref().is_some_and(|monitor| !monitor.is_finished()) {
517            return Ok(());
518        }
519        if owned.take().is_some() {
520            tracing::warn!("replacing an unexpectedly terminated coordinated recovery supervisor");
521        }
522        *owned = Some(crate::coordinated_recovery::spawn_monitor(self, &runtime));
523        Ok(())
524    }
525
526    #[cfg(feature = "cluster")]
527    pub(super) async fn quiesce_recovery_monitor_until(
528        &self,
529        deadline: tokio::time::Instant,
530    ) -> Result<(), DbError> {
531        let Some(mut monitor) = self.recovery_monitor.lock().take() else {
532            return Ok(());
533        };
534        match tokio::time::timeout_at(deadline, &mut monitor).await {
535            Ok(Ok(())) => Ok(()),
536            Ok(Err(error)) => Err(DbError::Pipeline(format!(
537                "coordinated recovery supervisor failed during shutdown: {error}"
538            ))),
539            Err(_) => {
540                *self.recovery_monitor.lock() = Some(monitor);
541                Err(DbError::Pipeline(
542                    "coordinated recovery supervisor did not quiesce before the shutdown deadline"
543                        .into(),
544                ))
545            }
546        }
547    }
548
549    /// Close resources created by an unsuccessful `start_inner` attempt.
550    pub(super) async fn cleanup_failed_start(&self) -> Result<(), DbError> {
551        const CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
552        let deadline = tokio::time::Instant::now() + CLEANUP_TIMEOUT;
553        // INVARIANT: startup installs its generation token before preparing connectors. Cancel it
554        // even when startup fails before runtime launch so recovery can prove the Created process
555        // has no live generation and a partially launched task observes the same terminal signal.
556        self.runtime_shutdown.read().cancel();
557        self.shutdown_signal.notify_one();
558        self.join_runtime_watcher_until(deadline).await?;
559        #[cfg(feature = "cluster")]
560        if self.is_cluster_runtime() {
561            super::retire_cluster_compute_generation_until(
562                &self.rotation_execution_fence,
563                &self.pending_vnode_transition,
564                &self.installed_vnode_state,
565                deadline,
566            )
567            .await
568            .map_err(|_| {
569                DbError::Pipeline("failed-start graph retirement deadline expired".into())
570            })?;
571        }
572        self.quiesce_checkpoint_decision_until(deadline).await?;
573        {
574            let mut coordinator = tokio::time::timeout_at(deadline, self.coordinator.lock())
575                .await
576                .map_err(|_| {
577                    DbError::Checkpoint(
578                        "failed-start cleanup could not reacquire checkpoint coordinator ownership; durability fences remain held"
579                            .into(),
580                    )
581            })?;
582            if let Some(coordinator) = coordinator.as_mut() {
583                tokio::time::timeout_at(deadline, coordinator.reconcile_sink_open_witness())
584                    .await
585                    .map_err(|_| {
586                        DbError::Checkpoint(format!(
587                            "failed-start checkpoint reconciliation exceeded {CLEANUP_TIMEOUT:?}; durability fences remain held"
588                        ))
589                    })??;
590                coordinator
591                    .reconcile_sink_open_witness_until(deadline)
592                    .await?;
593                coordinator.clear_sinks()?;
594            }
595        }
596        *self.control_tx.lock() = None;
597        *self.force_ckpt_tx.lock() = None;
598        self.quiesce_connector_generation_until(deadline).await?;
599        #[cfg(feature = "cluster")]
600        if self
601            .topology_cut_hold
602            .load(std::sync::atomic::Ordering::Acquire)
603        {
604            return Ok(());
605        }
606        *self.checkpoint_namespace_lock.lock() = None;
607        Ok(())
608    }
609
610    pub(super) async fn await_startup_attempt_until(
611        &self,
612        attempt: &StartupAttempt,
613        deadline: tokio::time::Instant,
614        operation: &str,
615    ) -> Result<(), DbError> {
616        match tokio::time::timeout_at(deadline, attempt.wait()).await {
617            Ok(Ok(())) => Ok(()),
618            Ok(Err(error)) => {
619                tracing::debug!(%error, %operation, "startup reached a failed terminal state");
620                Ok(())
621            }
622            Err(_) => Err(DbError::Pipeline(format!(
623                "{operation} could not observe terminal startup before its deadline; startup remains fenced"
624            ))),
625        }
626    }
627
628    pub(super) async fn quiesce_connector_generation_until(
629        &self,
630        deadline: tokio::time::Instant,
631    ) -> Result<(), DbError> {
632        let (sources, sinks, startup) = tokio::join!(
633            self.quiesce_owned_source_tasks_until(deadline),
634            self.quiesce_owned_sink_handles_until(deadline),
635            self.quiesce_owned_connector_task_fences_until(deadline),
636        );
637        let mut failures = Vec::new();
638        if let Err(error) = sources {
639            failures.push(format!("sources: {error}"));
640        }
641        if let Err(error) = sinks {
642            failures.push(format!("sinks: {error}"));
643        }
644        if let Err(error) = startup {
645            failures.push(format!("startup: {error}"));
646        }
647        if failures.is_empty() {
648            Ok(())
649        } else {
650            Err(DbError::Connector(format!(
651                "connector generation remains fenced: {}",
652                failures.join("; ")
653            )))
654        }
655    }
656
657    pub(super) async fn quiesce_owned_connector_task_fences_until(
658        &self,
659        deadline: tokio::time::Instant,
660    ) -> Result<(), DbError> {
661        let fences = {
662            let mut owned = self.owned_connector_task_fences.lock();
663            owned.retain(|fence| !fence.is_finished());
664            owned.clone()
665        };
666        if fences.is_empty() {
667            return Ok(());
668        }
669
670        futures::future::join_all(fences.iter().map(|fence| fence.wait_until(deadline))).await;
671        let unresolved_names = {
672            let mut owned = self.owned_connector_task_fences.lock();
673            owned.retain(|fence| !fence.is_finished());
674            owned
675                .iter()
676                .map(|fence| fence.name().to_owned())
677                .collect::<Vec<_>>()
678        };
679        if unresolved_names.is_empty() {
680            Ok(())
681        } else {
682            Err(DbError::Connector(format!(
683                "cannot replace a pipeline while pre-actor connector tasks remain unresolved: {}",
684                unresolved_names.join(", ")
685            )))
686        }
687    }
688
689    pub(super) async fn quiesce_owned_source_tasks_until(
690        &self,
691        deadline: tokio::time::Instant,
692    ) -> Result<(), DbError> {
693        let tasks = self.owned_source_tasks.lock().clone();
694        if tasks.is_empty() {
695            return Ok(());
696        }
697
698        // Signal every task before awaiting any one task. Aborting retires the owned connector
699        // generation; its lease remains fenced until the stable supervisor observes task exit.
700        for task in &tasks {
701            task.request_shutdown();
702            task.abort();
703        }
704        let completions =
705            futures::future::join_all(tasks.iter().map(|task| task.wait_until(deadline))).await;
706        for (task, finished) in tasks.iter().zip(completions) {
707            if finished {
708                task.log_terminal_outcome();
709            }
710        }
711
712        let unresolved_names = {
713            let mut owned = self.owned_source_tasks.lock();
714            owned.retain(|task| !task.is_finished());
715            owned
716                .iter()
717                .map(|task| task.name().to_owned())
718                .collect::<Vec<_>>()
719        };
720        if unresolved_names.is_empty() {
721            return Ok(());
722        }
723        Err(DbError::Connector(format!(
724            "cannot replace a pipeline while prior source tasks remain unresolved: {}",
725            unresolved_names.join(", ")
726        )))
727    }
728
729    pub(super) async fn quiesce_owned_sink_handles_until(
730        &self,
731        deadline: tokio::time::Instant,
732    ) -> Result<(), DbError> {
733        let handles = {
734            let mut owned = self.owned_sink_handles.lock();
735            owned.retain(crate::sink_task::SinkTaskHandle::has_unresolved_task);
736            owned.clone()
737        };
738        if handles.is_empty() {
739            return Ok(());
740        }
741        if deadline <= tokio::time::Instant::now() {
742            return Err(DbError::Connector(
743                "sink-generation quiescence budget was exhausted before terminal cleanup began; \
744                 prior actors remain fenced"
745                    .into(),
746            ));
747        }
748
749        // Poll every close in the same turn so independent actors share one restart budget. Each
750        // close has its own wrapper at the shared deadline: one slow actor cannot discard an
751        // already-published sticky failure from another actor. Cancellation leaves the DB-owned
752        // handles in place and any close that crossed admission continues in its stable driver.
753        let close_results =
754            futures::future::join_all(handles.iter().cloned().map(|handle| async move {
755                let name = handle.name().to_owned();
756                let result = tokio::time::timeout_at(deadline, handle.close()).await;
757                (name, result)
758            }))
759            .await;
760        let mut failures = close_results
761            .into_iter()
762            .filter_map(|(name, result)| match result {
763                Ok(Ok(())) => None,
764                Ok(Err(error)) => Some(format!("{name}: {error}")),
765                Err(_) => Some(format!(
766                    "{name}: shared sink-generation close deadline expired"
767                )),
768            })
769            .collect::<Vec<_>>();
770
771        // A close result is not a terminal proof: timeout, disconnection, or a panicked close
772        // driver can publish immediately while the actor or a connector child remains live.
773        // Spend the remainder of the same generation deadline observing both proofs before the
774        // registry decides whether replacement is safe.
775        futures::future::join_all(
776            handles
777                .iter()
778                .map(|handle| handle.wait_terminal_until(deadline)),
779        )
780        .await;
781
782        let unresolved_names = {
783            let mut owned = self.owned_sink_handles.lock();
784            owned.retain(crate::sink_task::SinkTaskHandle::has_unresolved_task);
785            owned
786                .iter()
787                .map(|handle| handle.name().to_owned())
788                .collect::<Vec<_>>()
789        };
790        if unresolved_names.is_empty() {
791            for failure in failures {
792                tracing::warn!(%failure, "terminal sink cleanup reported an error");
793            }
794            return Ok(());
795        }
796        failures.push(format!("still active: {}", unresolved_names.join(", ")));
797        Err(DbError::Connector(format!(
798            "cannot replace a pipeline while prior sink actors remain unresolved: {}",
799            failures.join("; ")
800        )))
801    }
802}