laminar_db/pipeline_lifecycle/
cluster_startup.rs1#[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 #[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 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 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 #[cfg(feature = "cluster")]
296 pub(crate) fn set_shuffle_recovery_gen(&self, gen: u64) {
297 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 #[cfg(feature = "cluster")]
314 pub async fn prepare_cluster_startup_recovery_generation(
315 &self,
316 deadline: tokio::time::Instant,
317 ) -> Result<(), DbError> {
318 *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 #[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 #[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 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 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 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 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 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}