laminar_db/pipeline_lifecycle/
cluster_startup.rs1#[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 #[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 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 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 #[cfg(feature = "cluster")]
332 pub(crate) fn set_shuffle_recovery_gen(&self, gen: u64) {
333 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 #[cfg(feature = "cluster")]
350 pub async fn prepare_cluster_startup_recovery_generation(
351 &self,
352 deadline: tokio::time::Instant,
353 ) -> Result<(), DbError> {
354 *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 #[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 #[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 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 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 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 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 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}