Skip to main content

laminar_db/pipeline_lifecycle/
shutdown.rs

1use super::{
2    checked_pipeline_deadline, Arc, DbError, DbState, LaminarDB, PipelineLifecycleAuthority,
3    PUBLIC_PIPELINE_STOP_TIMEOUT,
4};
5#[cfg(feature = "cluster")]
6use super::{
7    configured_checkpoint_timeout, coordinated_recovery_stop_ceiling, report_cluster_terminal_halt,
8    retire_cluster_compute_generation_until,
9};
10
11impl LaminarDB {
12    pub(super) async fn quiesce_checkpoint_decision_until(
13        &self,
14        deadline: tokio::time::Instant,
15    ) -> Result<(), DbError> {
16        let _coordinator = tokio::time::timeout_at(deadline, self.coordinator.lock())
17            .await
18            .map_err(|_| {
19                DbError::Checkpoint(
20                    "[LDB-6038] teardown could not acquire checkpoint coordinator ownership; durable decision writes remain fenced"
21                        .into(),
22                )
23            })?;
24        Ok(())
25    }
26
27    pub(super) async fn reconcile_sink_open_witness_until(
28        &self,
29        deadline: tokio::time::Instant,
30    ) -> Result<(), DbError> {
31        let mut coordinator = tokio::time::timeout_at(deadline, self.coordinator.lock())
32            .await
33            .map_err(|_| {
34                DbError::Checkpoint(
35                    "[LDB-6050] teardown could not acquire checkpoint coordinator ownership for sink-open reconciliation"
36                        .into(),
37                )
38            })?;
39        if let Some(coordinator) = coordinator.as_mut() {
40            coordinator
41                .reconcile_sink_open_witness_until(deadline)
42                .await?;
43        }
44        Ok(())
45    }
46
47    /// Shut down the streaming pipeline gracefully. Idempotent.
48    ///
49    /// # Errors
50    ///
51    /// Returns `Err` if the watcher task panicked.
52    pub async fn shutdown(&self) -> Result<(), DbError> {
53        const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
54        // Terminal intent takes precedence over a concurrent restartable stop. Publishing it
55        // before lifecycle arbitration also prevents a brief stop-created state from admitting
56        // a new startup while shutdown is queued behind that stop.
57        self.close();
58        #[cfg(feature = "cluster")]
59        if self.is_cluster_runtime()
60            && self
61                .terminal_pipeline_halt
62                .load(std::sync::atomic::Ordering::Acquire)
63        {
64            // Catalog bootstrap can fail before the recovery monitor exists. Shutdown is then the
65            // last live owner capable of proving the permanent disposition to shared authority;
66            // it must not quiesce that authority or publish Stopped until durable proof exists.
67            let controller = { self.cluster_controller.lock().clone() };
68            report_cluster_terminal_halt(controller, Arc::clone(&self.pending_recovery_fault))
69                .await;
70            self.latch_durable_terminal_recovery_fence();
71        }
72        let deadline = tokio::time::Instant::now() + SHUTDOWN_TIMEOUT;
73        #[cfg(feature = "cluster")]
74        self.quiesce_recovery_monitor_until(deadline).await?;
75        let first_shutdown = loop {
76            let startup = {
77                let owned = self.startup_attempt.lock();
78                if let Some(in_flight) = owned.as_ref().filter(|attempt| !attempt.is_complete()) {
79                    Arc::clone(in_flight)
80                } else {
81                    match DbState::load(&self.state) {
82                        DbState::Stopped if self.checkpoint_namespace_lock.lock().is_none() => {
83                            return Ok(());
84                        }
85                        // Offline DDL may acquire ownership after a restartable stop.
86                        DbState::Stopped | DbState::ShuttingDown => break false,
87                        DbState::Starting => {
88                            return Err(DbError::Pipeline(
89                                "shutdown found Starting without an incomplete owned startup attempt"
90                                    .into(),
91                            ));
92                        }
93                        observed @ (DbState::Created | DbState::Running | DbState::Faulted) => {
94                            if DbState::compare_exchange(
95                                observed,
96                                DbState::ShuttingDown,
97                                &self.state,
98                            )
99                            .is_ok()
100                            {
101                                break true;
102                            }
103                            continue;
104                        }
105                    }
106                }
107            };
108            self.await_startup_attempt_until(&startup, deadline, "pipeline shutdown")
109                .await?;
110        };
111        self.runtime_shutdown.write().cancel();
112        if first_shutdown {
113            *self.force_ckpt_tx.lock() = None;
114            self.shutdown_signal.notify_one();
115        }
116
117        let _topology = tokio::time::timeout_at(deadline, self.topology_ddl_lock.write())
118            .await
119            .map_err(|_| {
120                DbError::Pipeline(format!(
121                    "pipeline shutdown could not acquire topology ownership within \
122                     {SHUTDOWN_TIMEOUT:?}; catalog mutation remains fenced"
123                ))
124            })?;
125        let _lifecycle = tokio::time::timeout_at(deadline, self.lifecycle_lock.lock())
126            .await
127            .map_err(|_| {
128                DbError::Pipeline(format!(
129                    "pipeline shutdown could not acquire lifecycle ownership within \
130                     {SHUTDOWN_TIMEOUT:?}; startup/stop remains fenced"
131                ))
132            })?;
133
134        let mut runtime_handle = tokio::time::timeout_at(deadline, self.runtime_handle.lock())
135            .await
136            .map_err(|_| {
137                DbError::Pipeline(
138                    "pipeline shutdown could not reacquire runtime watcher ownership; runtime remains fenced in ShuttingDown"
139                        .into(),
140                )
141            })?;
142        let mut watcher_error = None;
143        if let Some(handle) = runtime_handle.as_mut() {
144            match tokio::time::timeout_at(deadline, handle).await {
145                Ok(Ok(())) => {
146                    runtime_handle.take();
147                }
148                Ok(Err(error)) => {
149                    runtime_handle.take();
150                    watcher_error = Some(DbError::Pipeline(format!(
151                        "pipeline watcher failed during shutdown: {error}"
152                    )));
153                }
154                Err(_) => {
155                    return Err(DbError::Pipeline(format!(
156                        "pipeline shutdown exceeded {SHUTDOWN_TIMEOUT:?}; runtime is still \
157                         draining and remains fenced in ShuttingDown; retry shutdown"
158                    )));
159                }
160            }
161        }
162        drop(runtime_handle);
163
164        // The runtime can call `complete_pending_vnode_transition` until its join handle has
165        // finished. Retire the staged transition and its predecessor binding only after that
166        // generation is gone, and keep the graph rotation write fence through terminal lifecycle
167        // publication so no callback or assignment adoption can observe a split pair.
168        #[cfg(feature = "cluster")]
169        let _retired_cluster_generation = if self.is_cluster_runtime() {
170            Some(
171                retire_cluster_compute_generation_until(
172                    &self.rotation_execution_fence,
173                    &self.pending_vnode_transition,
174                    &self.installed_vnode_state,
175                    deadline,
176                )
177                .await
178                .map_err(|_| {
179                    DbError::Pipeline(
180                        "pipeline shutdown could not retire cluster vnode state before its \
181                         deadline; runtime remains fenced in ShuttingDown"
182                            .into(),
183                    )
184                })?,
185            )
186        } else {
187            None
188        };
189        if watcher_error.is_none() {
190            if let Some(fault) = self.last_fault.lock().clone() {
191                watcher_error = Some(DbError::Pipeline(format!(
192                    "pipeline faulted while shutting down: {fault}"
193                )));
194            } else {
195                tracing::info!("Pipeline shut down cleanly");
196            }
197        }
198
199        // Compute has stopped producing new checkpoint work. Keep every deployment/state fence
200        // until an already-issued remote decision create reaches a terminal client-side state.
201        self.quiesce_checkpoint_decision_until(deadline).await?;
202        self.reconcile_sink_open_witness_until(deadline).await?;
203        self.quiesce_connector_generation_until(deadline).await?;
204
205        *self.checkpoint_namespace_lock.lock() = None;
206        DbState::Stopped.store(&self.state);
207        watcher_error.map_or(Ok(()), Err)
208    }
209
210    /// Stop the streaming pipeline so it can be restarted.
211    ///
212    /// # Errors
213    /// Returns [`DbError::InvalidOperation`] if the pipeline is still starting
214    /// or the coordinator does not exit within the stop timeout.
215    pub async fn stop_pipeline(&self) -> Result<(), DbError> {
216        self.stop_pipeline_with_lifecycle_authority(PipelineLifecycleAuthority::Public)
217            .await
218    }
219
220    /// End-to-end deadline owned by the recovery stop itself. This is derived only from the
221    /// immutable database configuration: consulting the live coordinator here can deadlock with
222    /// the coordinator task that stop is joining. Two cleanup windows cover an admitted attempt
223    /// and its retained tail, with a final margin for lifecycle and connector settlement.
224    #[cfg(feature = "cluster")]
225    pub(crate) fn coordinated_recovery_stop_timeout(&self) -> std::time::Duration {
226        let cleanup_timeout =
227            crate::checkpoint_coordinator::CheckpointConfig::default().cleanup_timeout;
228        coordinated_recovery_stop_ceiling(
229            configured_checkpoint_timeout(&self.config),
230            cleanup_timeout,
231        )
232    }
233
234    /// Recovery-owned stop after the lifecycle fence is published.
235    #[cfg(feature = "cluster")]
236    pub(crate) async fn stop_pipeline_for_coordinated_recovery(&self) -> Result<(), DbError> {
237        self.stop_pipeline_with_lifecycle_authority(PipelineLifecycleAuthority::CoordinatedRecovery)
238            .await
239    }
240
241    /// Reuse terminal observation without releasing the namespace or publishing Created.
242    /// Only the exact-root preparation path calls this after authority validation.
243    #[cfg(feature = "cluster")]
244    pub(crate) async fn stop_pipeline_for_topology_retirement(&self) -> Result<(), DbError> {
245        self.stop_pipeline_with_lifecycle_authority(PipelineLifecycleAuthority::TopologyRetirement)
246            .await
247    }
248
249    pub(super) async fn stop_pipeline_with_lifecycle_authority(
250        &self,
251        authority: PipelineLifecycleAuthority,
252    ) -> Result<(), DbError> {
253        let stop_timeout = self.pipeline_stop_timeout(authority)?;
254        let deadline = checked_pipeline_deadline(stop_timeout, "pipeline stop")?;
255        let first_stop = loop {
256            let startup = {
257                let owned = self.startup_attempt.lock();
258                #[cfg(feature = "cluster")]
259                self.ensure_pipeline_lifecycle_authorized(authority, "stop")?;
260                #[cfg(not(feature = "cluster"))]
261                Self::ensure_pipeline_lifecycle_authorized(authority, "stop");
262                if let Some(in_flight) = owned.as_ref().filter(|attempt| !attempt.is_complete()) {
263                    Arc::clone(in_flight)
264                } else {
265                    match DbState::load(&self.state) {
266                        DbState::Created | DbState::Stopped => {
267                            #[cfg(feature = "cluster")]
268                            match self.claim_cold_recovery_stop(authority)? {
269                                Some(true) => break true,
270                                Some(false) => {}
271                                None => continue,
272                            }
273                            drop(owned);
274                            return Ok(());
275                        }
276                        DbState::Starting => {
277                            return Err(DbError::InvalidOperation(
278                                "pipeline stop found Starting without an incomplete owned startup attempt"
279                                    .into(),
280                            ));
281                        }
282                        DbState::ShuttingDown => break false,
283                        observed @ (DbState::Running | DbState::Faulted) => {
284                            if DbState::compare_exchange(
285                                observed,
286                                DbState::ShuttingDown,
287                                &self.state,
288                            )
289                            .is_ok()
290                            {
291                                break true;
292                            }
293                            continue;
294                        }
295                    }
296                }
297            };
298            self.await_startup_attempt_until(&startup, deadline, "pipeline stop")
299                .await
300                .map_err(|error| DbError::InvalidOperation(error.to_string()))?;
301        };
302        self.runtime_shutdown.write().cancel();
303        if first_stop {
304            *self.force_ckpt_tx.lock() = None;
305            // Clear up front so DDL during/after shutdown registers for the next start()
306            // instead of hot-adding into the dying coordinator's channel.
307            *self.control_tx.lock() = None;
308            self.shutdown_signal.notify_one();
309        }
310
311        #[cfg(test)]
312        {
313            let stop_after_claim_gate = { self.stop_after_claim_gate.lock().clone() };
314            if let Some((entered, release)) = stop_after_claim_gate {
315                entered.notify_one();
316                release.notified().await;
317            }
318        }
319
320        let _topology = tokio::time::timeout_at(deadline, self.topology_ddl_lock.write())
321            .await
322            .map_err(|_| {
323                DbError::InvalidOperation(format!(
324                    "pipeline stop could not acquire topology ownership within {stop_timeout:?}; catalog mutation remains fenced"
325                ))
326            })?;
327        let _lifecycle = tokio::time::timeout_at(deadline, self.lifecycle_lock.lock())
328            .await
329            .map_err(|_| {
330                DbError::InvalidOperation(format!(
331                    "pipeline stop could not acquire lifecycle ownership within {stop_timeout:?}; \
332                     an earlier lifecycle operation remains fenced"
333                ))
334            })?;
335        let mut runtime_handle = tokio::time::timeout_at(deadline, self.runtime_handle.lock())
336            .await
337            .map_err(|_| {
338                DbError::InvalidOperation(
339                    "pipeline stop could not reacquire runtime watcher ownership; runtime remains fenced in ShuttingDown"
340                        .into(),
341                )
342            })?;
343        if let Some(handle) = runtime_handle.as_mut() {
344            match tokio::time::timeout_at(deadline, handle).await {
345                Ok(Ok(())) => {
346                    runtime_handle.take();
347                    tracing::info!("Pipeline stopped cleanly");
348                }
349                Ok(Err(e)) => {
350                    runtime_handle.take();
351                    #[cfg(feature = "cluster")]
352                    if authority == PipelineLifecycleAuthority::TopologyRetirement {
353                        let reason = format!(
354                            "parent runtime watcher failed during topology retirement: {e}"
355                        );
356                        self.last_fault.lock().get_or_insert_with(|| reason.clone());
357                        return Err(DbError::Pipeline(reason));
358                    }
359                    tracing::warn!(error = %e, "Pipeline task panicked during stop");
360                }
361                Err(_) => {
362                    tracing::warn!(
363                        timeout = ?stop_timeout,
364                        "Pipeline stop still draining; will finalize when the coordinator exits"
365                    );
366                    return Err(DbError::InvalidOperation(
367                        "pipeline stop is taking longer than expected; coordinator still \
368                         draining, retry shortly"
369                            .into(),
370                    ));
371                }
372            }
373        }
374        drop(runtime_handle);
375
376        // Do not clear the installed predecessor binding while the old compute generation can
377        // still observe its staged transition. The joined runtime cannot hold a graph read fence;
378        // taking the write side now retires both claims atomically with respect to callbacks and
379        // assignment adoption, and retaining it through `Created` publication closes the final
380        // lifecycle race.
381        #[cfg(feature = "cluster")]
382        let _retired_cluster_generation = if self.is_cluster_runtime() {
383            Some(
384                retire_cluster_compute_generation_until(
385                    &self.rotation_execution_fence,
386                    &self.pending_vnode_transition,
387                    &self.installed_vnode_state,
388                    deadline,
389                )
390                .await
391                .map_err(|_| {
392                    DbError::InvalidOperation(
393                        "pipeline stop could not retire cluster vnode state before its deadline; \
394                         runtime remains fenced in ShuttingDown"
395                            .into(),
396                    )
397                })?,
398            )
399        } else {
400            None
401        };
402
403        // Do not announce Created or release the exclusive deployment lock while a timed-out
404        // decision create can still mutate the recovery frontier. A later stop retry resumes here.
405        self.quiesce_checkpoint_decision_until(deadline).await?;
406        self.reconcile_sink_open_witness_until(deadline).await?;
407        self.quiesce_connector_generation_until(deadline).await?;
408
409        #[cfg(feature = "cluster")]
410        if authority == PipelineLifecycleAuthority::TopologyRetirement {
411            // Keep T's identity, exact cut, namespace and ShuttingDown fence until a future
412            // authorized target install or coordinated recovery takes ownership. Cancellation
413            // at any earlier await leaves all unresolved handles in their existing DB registries.
414            return Ok(());
415        }
416        *self.checkpoint_namespace_lock.lock() = None;
417        if self.is_closed() {
418            // A concurrent shutdown owns the terminal transition. Leaving ShuttingDown in place
419            // is intentional if that shutdown was cancelled; a retry must finish its teardown.
420            return Ok(());
421        }
422        if self
423            .terminal_pipeline_halt
424            .load(std::sync::atomic::Ordering::Acquire)
425        {
426            match DbState::compare_exchange(DbState::ShuttingDown, DbState::Faulted, &self.state) {
427                Ok(_) | Err(DbState::Faulted) => return Ok(()),
428                Err(observed) => {
429                    return Err(DbError::InvalidOperation(format!(
430                        "terminal pipeline stop completed from unexpected lifecycle state {observed:?}; restart remains fenced"
431                    )));
432                }
433            }
434        }
435        match DbState::compare_exchange(DbState::ShuttingDown, DbState::Created, &self.state) {
436            Ok(_) | Err(DbState::Created | DbState::Stopped) => Ok(()),
437            Err(observed) => Err(DbError::InvalidOperation(format!(
438                "pipeline stop completed from unexpected lifecycle state {observed:?}; restart remains fenced"
439            ))),
440        }
441    }
442}
443
444impl LaminarDB {
445    #[cfg_attr(not(feature = "cluster"), allow(clippy::unnecessary_wraps))]
446    // Cluster builds reject startup-only authority through the same typed result.
447    fn pipeline_stop_timeout(
448        &self,
449        authority: PipelineLifecycleAuthority,
450    ) -> Result<std::time::Duration, DbError> {
451        let stop_timeout = match authority {
452            PipelineLifecycleAuthority::Public => PUBLIC_PIPELINE_STOP_TIMEOUT,
453            #[cfg(feature = "cluster")]
454            PipelineLifecycleAuthority::CoordinatedRecovery => {
455                self.coordinated_recovery_stop_timeout()
456            }
457            #[cfg(feature = "cluster")]
458            PipelineLifecycleAuthority::TopologyRetirement => std::time::Duration::from_secs(45),
459            #[cfg(feature = "cluster")]
460            PipelineLifecycleAuthority::TopologyInstallation => {
461                return Err(DbError::InvalidOperation(
462                    "topology installation authority is confined to held startup".into(),
463                ));
464            }
465        };
466        Ok(stop_timeout)
467    }
468
469    #[cfg(feature = "cluster")]
470    fn claim_cold_recovery_stop(
471        &self,
472        authority: PipelineLifecycleAuthority,
473    ) -> Result<Option<bool>, DbError> {
474        if authority == PipelineLifecycleAuthority::TopologyRetirement {
475            return Err(DbError::InvalidOperation(
476                "topology retirement lost the held parent runtime".into(),
477            ));
478        }
479        if authority == PipelineLifecycleAuthority::CoordinatedRecovery {
480            // A cold target has no actors yet, but teardown must retire its owned token/handles.
481            return Ok(
482                match DbState::compare_exchange(
483                    DbState::Created,
484                    DbState::ShuttingDown,
485                    &self.state,
486                ) {
487                    Ok(_) => Some(true),
488                    Err(DbState::Stopped) => Some(false),
489                    Err(_) => None,
490                },
491            );
492        }
493        Ok(Some(false))
494    }
495}