Skip to main content

laminar_db/pipeline_lifecycle/
startup.rs

1#[cfg(feature = "cluster")]
2use super::TopologyStartup;
3use super::{
4    checked_pipeline_deadline, panic_message, publish_runtime_fault_state, required_recovery_scope,
5    Arc, CheckpointStorageScope, DbError, DbState, DeliveryGuarantee, FutureExt, LaminarDB,
6    PipelineLifecycleAuthority, RuntimeMode, StartupAttempt, StartupDriverGuard,
7};
8#[cfg(feature = "cluster")]
9use super::{report_cluster_terminal_halt, retire_cluster_compute_generation};
10use laminar_core::storage_location::StorageProvider;
11
12mod checkpoint_initialization;
13
14impl LaminarDB {
15    /// Publish `Running` only if the compute watcher did not fault during startup.
16    ///
17    /// The compute thread publishes a competing `Starting -> Faulted` transition before exit and
18    /// the watcher reinforces it. Ignoring a lost CAS here would let coordinated recovery
19    /// acknowledge a process whose compute loop has already died.
20    pub(super) fn finish_start_transition(&self) -> Result<(), DbError> {
21        // Hold the generation read lock through publication. Every cancellation path takes the
22        // write lock, so cancellation and `Starting -> Running` have one linearization order.
23        let runtime_shutdown = self.runtime_shutdown.read();
24        if self.is_closed() || runtime_shutdown.is_cancelled() {
25            return match DbState::compare_exchange(
26                DbState::Starting,
27                DbState::Created,
28                &self.state,
29            ) {
30                Ok(_) | Err(DbState::Created) => Err(DbError::Shutdown),
31                Err(DbState::Faulted) => Err(DbError::Pipeline(format!(
32                    "pipeline faulted while its cancelled generation was leaving startup: {}",
33                    self.last_fault()
34                        .unwrap_or_else(|| "compute loop exited without a fault reason".into())
35                ))),
36                Err(observed) => Err(DbError::InvalidOperation(format!(
37                    "cancelled pipeline startup completed from an unexpected lifecycle state: {observed:?}"
38                ))),
39            };
40        }
41        match DbState::compare_exchange(DbState::Starting, DbState::Running, &self.state) {
42            Ok(_) => Ok(()),
43            Err(DbState::Faulted) => Err(DbError::Pipeline(format!(
44                "pipeline faulted while entering the runtime control loop: {}",
45                self.last_fault()
46                    .unwrap_or_else(|| "compute loop exited without a fault reason".into())
47            ))),
48            Err(observed) => Err(DbError::InvalidOperation(format!(
49                "pipeline startup completed from an unexpected lifecycle state: {observed:?}"
50            ))),
51        }
52    }
53
54    /// Start the streaming pipeline. Idempotent if already running. On failure
55    /// (or recovering from `Faulted`) it rebuilds from the surviving catalog.
56    ///
57    /// The first start logs a warning when the per-node memory budgets add up to more than the
58    /// process's cgroup memory limit.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the pipeline cannot be started.
63    pub async fn start(self: &Arc<Self>) -> Result<(), DbError> {
64        if DbState::load(&self.state) == DbState::Created {
65            crate::memory_budget::warn_if_over_cgroup_limit(&self.config, self.runtime_mode());
66        }
67        self.start_with_lifecycle_authority(PipelineLifecycleAuthority::Public)
68            .await
69    }
70
71    /// Recovery-owned restart after an exact stopped quorum. The persistent recovery lifecycle
72    /// fence rejects public starts until the round's committed release, while this path alone may
73    /// rebuild the still-gated data plane for `Start`.
74    #[cfg(feature = "cluster")]
75    pub(crate) async fn start_for_coordinated_recovery(self: &Arc<Self>) -> Result<(), DbError> {
76        self.ensure_terminal_halt_allows_start(PipelineLifecycleAuthority::CoordinatedRecovery)?;
77        // RECOVERY: a durable Start is published only after cluster-wide artifact settlement.
78        *self.startup_checkpoint_artifact_audit.lock() = None;
79        if !self.is_cluster_runtime() {
80            return self
81                .start_with_lifecycle_authority(PipelineLifecycleAuthority::CoordinatedRecovery)
82                .await;
83        }
84        let controller = self
85            .cluster_controller
86            .lock()
87            .clone()
88            .ok_or_else(|| DbError::Checkpoint("recovery has no controller".into()))?;
89        let announcement = controller
90            .observe_recover_control()
91            .await
92            .map_err(|error| DbError::Checkpoint(error.to_string()))?;
93        if let Some(start) = announcement.filter(|start| start.round.topology_binding().is_some()) {
94            let laminar_core::cluster::control::RecoverPhase::Start { epoch } = start.phase else {
95                return Err(laminar_core::cluster::control::TopologyError::Fenced.into());
96            };
97            if *self.recover_target_epoch.lock() != Some(epoch) {
98                return Err(laminar_core::cluster::control::TopologyError::Fenced.into());
99            }
100            if DbState::load(&self.state) == DbState::Running {
101                return Box::pin(self.certify_recovered_cluster_topology(&start)).await;
102            }
103            let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45);
104            let mut image = Box::pin(self.prepare_coordinated_topology_restore(&start)).await?;
105            tokio::time::timeout_at(
106                deadline,
107                self.prepare_coordinated_topology_transport(&mut image),
108            )
109            .await
110            .map_err(|_| laminar_core::cluster::control::TopologyError::Contended)??;
111            let _schema_creation =
112                tokio::time::timeout_at(deadline, self.schema_creation_lock.lock())
113                    .await
114                    .map_err(|_| {
115                        DbError::Pipeline(
116                            "schema preparation exceeded the recovery deadline".into(),
117                        )
118                    })?;
119            return Box::pin(self.start_with_runtime_image(
120                PipelineLifecycleAuthority::CoordinatedRecovery,
121                Some(TopologyStartup { image, deadline }),
122            ))
123            .await;
124        }
125        self.start_with_lifecycle_authority(PipelineLifecycleAuthority::CoordinatedRecovery)
126            .await
127    }
128
129    pub(super) async fn start_with_lifecycle_authority(
130        self: &Arc<Self>,
131        authority: PipelineLifecycleAuthority,
132    ) -> Result<(), DbError> {
133        if self.is_closed() {
134            return Err(DbError::Shutdown);
135        }
136        self.ensure_terminal_halt_allows_start(authority)?;
137        #[cfg(feature = "cluster")]
138        self.ensure_pipeline_lifecycle_authorized(authority, "start")?;
139        #[cfg(not(feature = "cluster"))]
140        Self::ensure_pipeline_lifecycle_authorized(authority, "start");
141        #[cfg(feature = "cluster")]
142        if self.is_cluster_runtime() {
143            let catalog = self.catalog_manifest_store.lock().clone();
144            if let Some(catalog) = catalog {
145                let live = catalog.live_topology_commit().await.map_err(|error| {
146                    DbError::Pipeline(format!(
147                        "[{}] catalog manifest load failed: {error}",
148                        laminar_core::error_codes::RECOVERY_FAILED
149                    ))
150                })?;
151                if live.is_some() {
152                    return Err(laminar_core::cluster::control::TopologyError::Conflict(
153                        "committed target requires migration-root reconstruction, installation and Release; ordinary startup cannot restore the parent checkpoint".into(),
154                    ).into());
155                }
156            }
157        }
158        let _schema_creation = self.schema_creation_lock.lock().await;
159        self.start_with_runtime_image(
160            authority,
161            #[cfg(feature = "cluster")]
162            None,
163        )
164        .await
165    }
166
167    #[cfg(feature = "cluster")]
168    pub(crate) async fn start_with_topology_image(
169        self: &Arc<Self>,
170        image: crate::db::PreparedTopologyRestore,
171        deadline: tokio::time::Instant,
172    ) -> Result<(), DbError> {
173        let _schema_creation = tokio::time::timeout_at(deadline, self.schema_creation_lock.lock())
174            .await
175            .map_err(|_| {
176                DbError::Pipeline(
177                    "schema preparation exceeded the topology installation deadline".into(),
178                )
179            })?;
180        self.start_with_runtime_image(
181            PipelineLifecycleAuthority::TopologyInstallation,
182            Some(TopologyStartup { image, deadline }),
183        )
184        .await
185    }
186
187    async fn start_with_runtime_image(
188        self: &Arc<Self>,
189        authority: PipelineLifecycleAuthority,
190        #[cfg(feature = "cluster")] mut topology: Option<TopologyStartup>,
191    ) -> Result<(), DbError> {
192        if self.is_closed() {
193            return Err(DbError::Shutdown);
194        }
195        self.connector_registry.freeze();
196        let runtime = self.control_runtime.handle()?;
197        let attempt = {
198            let mut owned = self.startup_attempt.lock();
199            self.ensure_terminal_halt_allows_start(authority)?;
200            #[cfg(feature = "cluster")]
201            self.ensure_pipeline_lifecycle_authorized(authority, "start")?;
202            #[cfg(not(feature = "cluster"))]
203            Self::ensure_pipeline_lifecycle_authorized(authority, "start");
204            loop {
205                // Cleanup may publish Created/Faulted just before the owner publishes its sticky
206                // result. The registered incomplete attempt remains authoritative through that
207                // narrow interval; never overlap it with a replacement generation.
208                if let Some(in_flight) = owned.as_ref().filter(|attempt| !attempt.is_complete()) {
209                    break Arc::clone(in_flight);
210                }
211                match DbState::load(&self.state) {
212                    DbState::Running => return Ok(()),
213                    DbState::Starting => {
214                        break owned.clone().ok_or_else(|| {
215                            DbError::Pipeline(
216                                "pipeline is Starting without an owned startup attempt; restart is fenced"
217                                    .into(),
218                            )
219                        })?;
220                    }
221                    DbState::Stopped => {
222                        return Err(DbError::InvalidOperation(
223                            "Cannot start a stopped pipeline. Create a new LaminarDB instance."
224                                .into(),
225                        ));
226                    }
227                    DbState::ShuttingDown
228                        if {
229                            #[cfg(feature = "cluster")]
230                            {
231                                authority != PipelineLifecycleAuthority::TopologyInstallation
232                            }
233                            #[cfg(not(feature = "cluster"))]
234                            {
235                                true
236                            }
237                        } =>
238                    {
239                        return Err(DbError::InvalidOperation(
240                            "cannot start pipeline: shutdown/stop in progress".into(),
241                        ));
242                    }
243                    claimed @ (DbState::Created | DbState::Faulted | DbState::ShuttingDown) => {
244                        // A compute fault publishes the cluster recovery fence before Faulted.
245                        // Re-read that fence after observing the state so a public restart cannot
246                        // slip through the fence-before-state publication window.
247                        self.ensure_terminal_halt_allows_start(authority)?;
248                        #[cfg(feature = "cluster")]
249                        self.ensure_pipeline_lifecycle_authorized(authority, "start")?;
250                        #[cfg(not(feature = "cluster"))]
251                        Self::ensure_pipeline_lifecycle_authorized(authority, "start");
252                        #[cfg(feature = "cluster")]
253                        if authority == PipelineLifecycleAuthority::TopologyInstallation
254                            && (topology.is_none()
255                                || !matches!(claimed, DbState::Created | DbState::ShuttingDown)
256                                || self.last_fault.lock().is_some()
257                                || !self.owned_source_tasks.lock().is_empty()
258                                || !self.owned_sink_handles.lock().is_empty()
259                                || !self.owned_connector_task_fences.lock().is_empty())
260                        {
261                            return Err(
262                                laminar_core::cluster::control::TopologyError::Fenced.into()
263                            );
264                        }
265                        let attempt = Arc::new(StartupAttempt::new());
266                        // Publish ownership before Starting so stop/shutdown can always find the
267                        // exact attempt they must await.
268                        *owned = Some(Arc::clone(&attempt));
269                        let (start_tx, start_rx) = std::sync::mpsc::sync_channel(1);
270                        let db = Arc::clone(self);
271                        let driver_attempt = Arc::clone(&attempt);
272                        let emergency_attempt = Arc::clone(&attempt);
273                        let driver_runtime = runtime.clone();
274                        #[cfg(feature = "cluster")]
275                        let driver_topology = topology.take();
276                        let startup_thread = match std::thread::Builder::new()
277                            .name("laminar-start".into())
278                            .spawn(move || {
279                                if !matches!(start_rx.recv(), Ok(true)) {
280                                    return;
281                                }
282                                let result =
283                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
284                                        driver_runtime.block_on(db.clone().drive_start_attempt(
285                                            driver_attempt,
286                                            claimed == DbState::Faulted,
287                                            authority,
288                                            #[cfg(feature = "cluster")]
289                                            driver_topology,
290                                        ));
291                                    }));
292                                if result.is_err() && !emergency_attempt.is_complete() {
293                                    let message =
294                                        "startup owner thread panicked before terminal cleanup";
295                                    db.last_fault.lock().get_or_insert(message.into());
296                                    DbState::Faulted.store(&db.state);
297                                    emergency_attempt
298                                        .complete(Err(DbError::Pipeline(message.into())));
299                                }
300                            }) {
301                            Ok(thread) => thread,
302                            Err(error) => {
303                                *owned = None;
304                                return Err(DbError::Pipeline(format!(
305                                    "failed to spawn startup owner thread: {error}"
306                                )));
307                            }
308                        };
309                        // The attempt is the durable owner; the short-lived OS thread is detached.
310                        drop(startup_thread);
311                        if DbState::compare_exchange(claimed, DbState::Starting, &self.state)
312                            .is_err()
313                        {
314                            let _ = start_tx.send(false);
315                            *owned = None;
316                            #[cfg(feature = "cluster")]
317                            if authority == PipelineLifecycleAuthority::TopologyInstallation {
318                                return Err(
319                                    laminar_core::cluster::control::TopologyError::Fenced.into()
320                                );
321                            }
322                            continue;
323                        }
324                        if start_tx.send(true).is_err() {
325                            let message = "startup owner thread exited before accepting ownership";
326                            self.last_fault.lock().get_or_insert(message.into());
327                            DbState::Faulted.store(&self.state);
328                            attempt.complete(Err(DbError::Pipeline(message.into())));
329                        }
330                        break attempt;
331                    }
332                }
333            }
334        };
335        attempt.wait().await
336    }
337
338    pub(super) async fn drive_start_attempt(
339        self: Arc<Self>,
340        attempt: Arc<StartupAttempt>,
341        starting_from_fault: bool,
342        authority: PipelineLifecycleAuthority,
343        #[cfg(feature = "cluster")] topology: Option<TopologyStartup>,
344    ) {
345        let terminal = StartupDriverGuard::new(&self, Arc::clone(&attempt));
346        let result = std::panic::AssertUnwindSafe(Box::pin(self.run_claimed_start(
347            starting_from_fault,
348            #[cfg(feature = "cluster")]
349            topology,
350        )))
351        .catch_unwind()
352        .await;
353        let result = match result {
354            Ok(result) => result,
355            Err(panic) => {
356                let reason = format!("startup driver panicked: {}", panic_message(panic.as_ref()));
357                *self.last_fault.lock() = Some(reason.clone());
358                let cleanup = std::panic::AssertUnwindSafe(self.cleanup_failed_start())
359                    .catch_unwind()
360                    .await;
361                DbState::Faulted.store(&self.state);
362                match cleanup {
363                    Ok(Ok(())) => Err(DbError::Pipeline(reason)),
364                    Ok(Err(error)) if error.requires_pipeline_halt() => {
365                        tracing::error!(
366                            panic = %reason,
367                            cleanup_error = %error,
368                            "terminal cleanup error superseded a generic startup panic"
369                        );
370                        Err(error)
371                    }
372                    Ok(Err(error)) => Err(DbError::Pipeline(format!(
373                        "{reason}; failed-start cleanup remains fenced: {error}"
374                    ))),
375                    Err(cleanup_panic) => Err(DbError::Pipeline(format!(
376                        "{reason}; failed-start cleanup panicked: {}",
377                        panic_message(cleanup_panic.as_ref())
378                    ))),
379                }
380            }
381        };
382        let result = if result.is_err() && DbState::load(&self.state) == DbState::Starting {
383            match self.cleanup_failed_start().await {
384                Ok(()) => {
385                    if result
386                        .as_ref()
387                        .err()
388                        .is_some_and(DbError::requires_pipeline_halt)
389                    {
390                        DbState::Faulted.store(&self.state);
391                    } else {
392                        let _ = DbState::compare_exchange(
393                            DbState::Starting,
394                            DbState::Created,
395                            &self.state,
396                        );
397                    }
398                    result
399                }
400                Err(error) => {
401                    DbState::Faulted.store(&self.state);
402                    if result
403                        .as_ref()
404                        .err()
405                        .is_some_and(DbError::requires_pipeline_halt)
406                    {
407                        tracing::error!(cleanup_error = %error, "cleanup also failed after a permanent startup error");
408                        result
409                    } else {
410                        Err(error)
411                    }
412                }
413            }
414        } else {
415            result
416        };
417        let result = self.normalize_start_result_for_terminal_latch(result);
418        self.terminalize_start_attempt_if_needed(authority, &result)
419            .await;
420        terminal.finish(result);
421    }
422
423    pub(super) fn normalize_start_result_for_terminal_latch(
424        &self,
425        result: Result<(), DbError>,
426    ) -> Result<(), DbError> {
427        if !self
428            .terminal_pipeline_halt
429            .load(std::sync::atomic::Ordering::Acquire)
430        {
431            return result;
432        }
433        match result {
434            Err(error) if error.requires_pipeline_halt() => Err(error),
435            Ok(()) | Err(_) => Err(DbError::PipelineTerminal(
436                self.last_fault()
437                    .unwrap_or_else(|| "terminal startup failure".into()),
438            )),
439        }
440    }
441
442    /// A startup owner outlives callers that time out while awaiting it. Terminalization therefore
443    /// belongs here, before the sticky attempt result is published, rather than in a recovery
444    /// monitor that may already have dropped its receiver.
445    #[cfg_attr(
446        not(feature = "cluster"),
447        allow(unknown_lints, clippy::unused_async, clippy::unused_async_trait_impl)
448    )]
449    pub(super) async fn terminalize_start_attempt_if_needed(
450        &self,
451        authority: PipelineLifecycleAuthority,
452        result: &Result<(), DbError>,
453    ) {
454        let Err(error) = result else {
455            return;
456        };
457        if !error.requires_pipeline_halt() {
458            return;
459        }
460        let _ = authority;
461        let reason = if self
462            .terminal_pipeline_halt
463            .load(std::sync::atomic::Ordering::Acquire)
464        {
465            self.last_fault().unwrap_or_else(|| error.to_string())
466        } else {
467            error.to_string()
468        };
469        *self.last_fault.lock() = Some(reason.clone());
470
471        #[cfg(feature = "cluster")]
472        if self.is_cluster_runtime() {
473            self.latch_local_terminal_pipeline_halt();
474            let controller = self.cluster_controller.lock().clone();
475            if let Some(controller) = controller.as_deref() {
476                controller.set_recovering(true);
477                if let Err(publication_error) = crate::coordinated_recovery::queue_local_fault(
478                    controller,
479                    &self.pending_recovery_fault,
480                ) {
481                    tracing::error!(
482                        %publication_error,
483                        "could not retain terminal startup fault request"
484                    );
485                }
486            }
487            let generation = Arc::clone(&self.rotation_execution_fence)
488                .write_owned()
489                .await;
490            retire_cluster_compute_generation(
491                &self.pending_vnode_transition,
492                &self.installed_vnode_state,
493            );
494            publish_runtime_fault_state(&self.state);
495            drop(generation);
496            tracing::error!(
497                %reason,
498                "pipeline startup hit a permanent error; awaiting durable terminal fencing"
499            );
500            report_cluster_terminal_halt(controller, Arc::clone(&self.pending_recovery_fault))
501                .await;
502            self.latch_durable_terminal_recovery_fence();
503            return;
504        }
505
506        self.terminal_pipeline_halt
507            .store(true, std::sync::atomic::Ordering::SeqCst);
508        publish_runtime_fault_state(&self.state);
509        tracing::error!(
510            %reason,
511            "pipeline startup hit a permanent error; automatic restart is disabled"
512        );
513    }
514
515    pub(super) async fn run_claimed_start(
516        &self,
517        starting_from_fault: bool,
518        #[cfg(feature = "cluster")] topology: Option<TopologyStartup>,
519    ) -> Result<(), DbError> {
520        const FAULT_RESTART_QUIESCE_TIMEOUT: std::time::Duration =
521            std::time::Duration::from_secs(10);
522        #[cfg(feature = "cluster")]
523        let deadline = topology.as_ref().map(|startup| startup.deadline);
524        #[cfg(not(feature = "cluster"))]
525        let deadline: Option<tokio::time::Instant> = None;
526        let (_topology, _lifecycle) = self.lock_startup_ownership(deadline).await?;
527        self.ensure_catalog_cleanup_unfenced("pipeline start")?;
528        if DbState::load(&self.state) != DbState::Starting {
529            return Err(DbError::Pipeline(
530                "startup ownership was superseded before the driver entered the lifecycle".into(),
531            ));
532        }
533
534        let generation_quiesce_deadline =
535            tokio::time::Instant::now() + FAULT_RESTART_QUIESCE_TIMEOUT;
536        self.runtime_shutdown.read().cancel();
537        self.shutdown_signal.notify_one();
538        self.join_runtime_watcher_until(generation_quiesce_deadline)
539            .await?;
540        if let Err(error) = self
541            .quiesce_connector_generation_until(generation_quiesce_deadline)
542            .await
543        {
544            if starting_from_fault || error.requires_pipeline_halt() {
545                DbState::Faulted.store(&self.state);
546            } else {
547                DbState::Created.store(&self.state);
548            }
549            return Err(error);
550        }
551
552        if starting_from_fault {
553            let deadline = tokio::time::Instant::now() + FAULT_RESTART_QUIESCE_TIMEOUT;
554            if let Err(error) = self.quiesce_checkpoint_decision_until(deadline).await {
555                // Retain the old coordinator and deployment fence. A supervisor/manual retry may
556                // resume once the owned decision write reaches a terminal state.
557                DbState::Faulted.store(&self.state);
558                return Err(error);
559            }
560        }
561
562        // Clear on entry, not after start_inner — otherwise a panic during this
563        // startup (watcher → Faulted + reason) would be immediately overwritten.
564        *self.last_fault.lock() = None;
565        {
566            let mut guard = self.engine_metrics.lock();
567            if guard.is_none() {
568                *guard = Some(Arc::new(crate::engine_metrics::EngineMetrics::new(
569                    &prometheus::Registry::new(),
570                )));
571            }
572        }
573
574        #[cfg(feature = "cluster")]
575        let catalog_restore = match deadline {
576            Some(deadline) => {
577                tokio::time::timeout_at(deadline, self.restore_catalog_from_manifest())
578                    .await
579                    .map_err(|_| {
580                        DbError::Pipeline(
581                            "topology catalog replay exceeded the installation deadline".into(),
582                        )
583                    })
584                    .and_then(|result| result)
585            }
586            None => self.restore_catalog_from_manifest().await,
587        };
588        #[cfg(feature = "cluster")]
589        if let Err(error) = catalog_restore {
590            return self.fail_catalog_bootstrap_start(error);
591        }
592
593        // Drain a shutdown permit a prior fault's `notify_one()` left with no
594        // waiter, so the new coordinator's `notified()` doesn't fire at once.
595        tokio::select! {
596            biased;
597            () = self.shutdown_signal.notified() => {}
598            () = std::future::ready(()) => {}
599        }
600
601        let start = Box::pin(self.start_inner(
602            #[cfg(feature = "cluster")]
603            topology.map(|startup| startup.image),
604        ));
605        #[cfg(feature = "cluster")]
606        let result = if let Some(deadline) = deadline {
607            tokio::time::timeout_at(deadline, start)
608                .await
609                .map_err(|_| laminar_core::cluster::control::TopologyError::Contended.into())
610                .and_then(|result| result)
611        } else {
612            start.await
613        };
614        #[cfg(not(feature = "cluster"))]
615        let result = start.await;
616        match result {
617            Ok(()) => {
618                // CAS, not store: don't clobber a Faulted set by the watcher if the compute thread
619                // already panicked during startup. Losing that CAS is a failed start, not success.
620                match self.finish_start_transition() {
621                    Ok(()) => Ok(()),
622                    Err(error) => match self.cleanup_failed_start().await {
623                        Ok(()) => {
624                            if error.requires_pipeline_halt() {
625                                DbState::Faulted.store(&self.state);
626                            }
627                            Err(error)
628                        }
629                        Err(cleanup_error) => {
630                            DbState::Faulted.store(&self.state);
631                            if error.requires_pipeline_halt() {
632                                tracing::error!(
633                                    %cleanup_error,
634                                    "failed-start cleanup also failed after a permanent startup error"
635                                );
636                                Err(error)
637                            } else if cleanup_error.requires_pipeline_halt() {
638                                tracing::error!(
639                                    startup_error = %error,
640                                    %cleanup_error,
641                                    "terminal cleanup error superseded a generic startup failure"
642                                );
643                                Err(cleanup_error)
644                            } else {
645                                Err(DbError::Pipeline(format!(
646                                    "{error}; failed-start cleanup remains fenced: {cleanup_error}"
647                                )))
648                            }
649                        }
650                    },
651                }
652            }
653            Err(e) => {
654                match self.cleanup_failed_start().await {
655                    Ok(()) => {
656                        if e.requires_pipeline_halt() {
657                            DbState::Faulted.store(&self.state);
658                        } else {
659                            // Reset so a retry re-runs startup rather than silently returning Ok.
660                            DbState::Created.store(&self.state);
661                        }
662                        Err(e)
663                    }
664                    Err(cleanup_error) => {
665                        DbState::Faulted.store(&self.state);
666                        if e.requires_pipeline_halt() {
667                            tracing::error!(
668                                %cleanup_error,
669                                "failed-start cleanup also failed after a permanent startup error"
670                            );
671                            Err(e)
672                        } else if cleanup_error.requires_pipeline_halt() {
673                            tracing::error!(
674                                startup_error = %e,
675                                %cleanup_error,
676                                "terminal cleanup error superseded a generic startup failure"
677                            );
678                            Err(cleanup_error)
679                        } else {
680                            Err(DbError::Pipeline(format!(
681                                "{e}; failed-start cleanup remains fenced: {cleanup_error}"
682                            )))
683                        }
684                    }
685                }
686            }
687        }
688    }
689
690    pub(super) fn validate_startup_durability(
691        &self,
692        startup_runtime: RuntimeMode,
693    ) -> Result<Option<Arc<dyn object_store::ObjectStore>>, DbError> {
694        #[cfg(feature = "cluster")]
695        let has_injected_decision_store = self.decision_store.lock().is_some();
696        #[cfg(not(feature = "cluster"))]
697        let has_injected_decision_store = false;
698        if startup_runtime == RuntimeMode::Local
699            && self.config.delivery_guarantee != DeliveryGuarantee::BestEffort
700            && (self.config.object_store_url.as_deref().is_some_and(|url| {
701                StorageProvider::detect_uri(url) != Some(StorageProvider::Local)
702            }) || has_injected_decision_store)
703        {
704            return Err(DbError::Config(
705                "[LDB-0014] a local replay-capable deployment with a shared cloud checkpoint \
706                 namespace or injected decision store is not admitted until its writer lease is \
707                 term-fenced. Use a built-in or file:// local checkpoint directory, or \
708                 best_effort delivery"
709                    .into(),
710            ));
711        }
712
713        #[cfg(feature = "cluster")]
714        let injected_cluster_checkpoint_store = self.cluster_checkpoint_object_store();
715        #[cfg(not(feature = "cluster"))]
716        let injected_cluster_checkpoint_store: Option<Arc<dyn object_store::ObjectStore>> = None;
717
718        if self.config.checkpoint.is_some()
719            && self.config.delivery_guarantee != DeliveryGuarantee::BestEffort
720        {
721            // Without an object-store URL the checkpoint store is a local directory and thus
722            // survives a same-node process restart. Explicit URLs are classified fail-closed;
723            // notably memory:// cannot own source acknowledgements under a replay guarantee.
724            let checkpoint_scope = if injected_cluster_checkpoint_store.is_some() {
725                CheckpointStorageScope::ClusterShared
726            } else {
727                match self.config.object_store_url.as_deref() {
728                    Some(url) => CheckpointStorageScope::for_url(url),
729                    None => CheckpointStorageScope::NodeDurable,
730                }
731            };
732            let required = required_recovery_scope(startup_runtime);
733            if !checkpoint_scope.satisfies(required) {
734                return Err(DbError::Config(format!(
735                    "[LDB-5036] {startup_runtime:?} {:?} delivery requires {required:?} \
736                     checkpoint/decision storage, but the configured checkpoint store is \
737                     {checkpoint_scope:?}; use the built-in checkpoint data_dir for \
738                     node-local recovery, or a supported shared object store",
739                    self.config.delivery_guarantee
740                )));
741            }
742        }
743
744        Ok(injected_cluster_checkpoint_store)
745    }
746}
747
748impl LaminarDB {
749    async fn lock_startup_ownership(
750        &self,
751        deadline: Option<tokio::time::Instant>,
752    ) -> Result<
753        (
754            tokio::sync::RwLockWriteGuard<'_, ()>,
755            tokio::sync::MutexGuard<'_, ()>,
756        ),
757        DbError,
758    > {
759        let topology = match deadline {
760            Some(deadline) => tokio::time::timeout_at(deadline, self.topology_ddl_lock.write())
761                .await
762                .map_err(|_| {
763                    DbError::Pipeline(
764                        "topology installation exceeded its lifecycle deadline".into(),
765                    )
766                })?,
767            None => self.topology_ddl_lock.write().await,
768        };
769        let lifecycle = match deadline {
770            Some(deadline) => tokio::time::timeout_at(deadline, self.lifecycle_lock.lock())
771                .await
772                .map_err(|_| {
773                    DbError::Pipeline(
774                        "topology installation exceeded its lifecycle deadline".into(),
775                    )
776                })?,
777            None => self.lifecycle_lock.lock().await,
778        };
779        Ok((topology, lifecycle))
780    }
781
782    #[cfg(feature = "cluster")]
783    fn fail_catalog_bootstrap_start(&self, error: DbError) -> Result<(), DbError> {
784        if let Err(cleanup_error) =
785            self.ensure_catalog_cleanup_unfenced("catalog bootstrap rollback")
786        {
787            // `CatalogBootstrapGuard` has already tried to remove every replayed object.
788            // An incomplete rollback is a terminal per-instance fence: never turn it into a
789            // retryable startup failure by publishing `Created` over the guard's `Faulted`.
790            DbState::Faulted.store(&self.state);
791            if error.requires_pipeline_halt() {
792                tracing::error!(
793                    %cleanup_error,
794                    "catalog rollback also failed after a permanent startup error"
795                );
796                return Err(error);
797            }
798            if cleanup_error.requires_pipeline_halt() {
799                return Err(DbError::PipelineTerminal(format!(
800                        "{error}; catalog bootstrap rollback remains terminally fenced: {cleanup_error}"
801                    )));
802            }
803            return Err(DbError::Pipeline(format!(
804                "{error}; catalog bootstrap rollback remains terminally fenced: {cleanup_error}"
805            )));
806        }
807
808        // No runtime resources have been constructed and catalog rollback completed. Publish
809        // a retryable state only if no concurrent fault superseded this startup generation.
810        if error.requires_pipeline_halt() {
811            DbState::Faulted.store(&self.state);
812            return Err(error);
813        }
814        match DbState::compare_exchange(DbState::Starting, DbState::Created, &self.state) {
815            Ok(_) => Err(error),
816            Err(observed) => Err(DbError::Pipeline(format!(
817                "{error}; catalog bootstrap rollback completed but startup was superseded by \
818                     lifecycle state {observed:?}: {}",
819                self.last_fault()
820                    .unwrap_or_else(|| "no fault reason was recorded".into())
821            ))),
822        }
823    }
824}