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