Skip to main content

laminar_db/pipeline_lifecycle/
startup.rs

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