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 => {
83                            drop(owned);
84                            return Ok(());
85                        }
86                        DbState::Starting => {
87                            return Err(DbError::Pipeline(
88                                "shutdown found Starting without an incomplete owned startup attempt"
89                                    .into(),
90                            ));
91                        }
92                        DbState::ShuttingDown => break false,
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    pub(super) async fn stop_pipeline_with_lifecycle_authority(
242        &self,
243        authority: PipelineLifecycleAuthority,
244    ) -> Result<(), DbError> {
245        let stop_timeout = match authority {
246            PipelineLifecycleAuthority::Public => PUBLIC_PIPELINE_STOP_TIMEOUT,
247            #[cfg(feature = "cluster")]
248            PipelineLifecycleAuthority::CoordinatedRecovery => {
249                self.coordinated_recovery_stop_timeout()
250            }
251        };
252        let deadline = checked_pipeline_deadline(stop_timeout, "pipeline stop")?;
253        let first_stop = loop {
254            let startup = {
255                let owned = self.startup_attempt.lock();
256                #[cfg(feature = "cluster")]
257                self.ensure_pipeline_lifecycle_authorized(authority, "stop")?;
258                #[cfg(not(feature = "cluster"))]
259                Self::ensure_pipeline_lifecycle_authorized(authority, "stop");
260                if let Some(in_flight) = owned.as_ref().filter(|attempt| !attempt.is_complete()) {
261                    Arc::clone(in_flight)
262                } else {
263                    match DbState::load(&self.state) {
264                        DbState::Created | DbState::Stopped => {
265                            drop(owned);
266                            return Ok(());
267                        }
268                        DbState::Starting => {
269                            return Err(DbError::InvalidOperation(
270                                "pipeline stop found Starting without an incomplete owned startup attempt"
271                                    .into(),
272                            ));
273                        }
274                        DbState::ShuttingDown => break false,
275                        observed @ (DbState::Running | DbState::Faulted) => {
276                            if DbState::compare_exchange(
277                                observed,
278                                DbState::ShuttingDown,
279                                &self.state,
280                            )
281                            .is_ok()
282                            {
283                                break true;
284                            }
285                            continue;
286                        }
287                    }
288                }
289            };
290            self.await_startup_attempt_until(&startup, deadline, "pipeline stop")
291                .await
292                .map_err(|error| DbError::InvalidOperation(error.to_string()))?;
293        };
294        self.runtime_shutdown.write().cancel();
295        if first_stop {
296            *self.force_ckpt_tx.lock() = None;
297            // Clear up front so DDL during/after shutdown registers for the next start()
298            // instead of hot-adding into the dying coordinator's channel.
299            *self.control_tx.lock() = None;
300            self.shutdown_signal.notify_one();
301        }
302
303        #[cfg(test)]
304        {
305            let stop_after_claim_gate = { self.stop_after_claim_gate.lock().clone() };
306            if let Some((entered, release)) = stop_after_claim_gate {
307                entered.notify_one();
308                release.notified().await;
309            }
310        }
311
312        let _topology = tokio::time::timeout_at(deadline, self.topology_ddl_lock.write())
313            .await
314            .map_err(|_| {
315                DbError::InvalidOperation(format!(
316                    "pipeline stop could not acquire topology ownership within {stop_timeout:?}; catalog mutation remains fenced"
317                ))
318            })?;
319        let _lifecycle = tokio::time::timeout_at(deadline, self.lifecycle_lock.lock())
320            .await
321            .map_err(|_| {
322                DbError::InvalidOperation(format!(
323                    "pipeline stop could not acquire lifecycle ownership within {stop_timeout:?}; \
324                     an earlier lifecycle operation remains fenced"
325                ))
326            })?;
327        let mut runtime_handle = tokio::time::timeout_at(deadline, self.runtime_handle.lock())
328            .await
329            .map_err(|_| {
330                DbError::InvalidOperation(
331                    "pipeline stop could not reacquire runtime watcher ownership; runtime remains fenced in ShuttingDown"
332                        .into(),
333                )
334            })?;
335        if let Some(handle) = runtime_handle.as_mut() {
336            match tokio::time::timeout_at(deadline, handle).await {
337                Ok(Ok(())) => {
338                    runtime_handle.take();
339                    tracing::info!("Pipeline stopped cleanly");
340                }
341                Ok(Err(e)) => {
342                    runtime_handle.take();
343                    tracing::warn!(error = %e, "Pipeline task panicked during stop");
344                }
345                Err(_) => {
346                    tracing::warn!(
347                        timeout = ?stop_timeout,
348                        "Pipeline stop still draining; will finalize when the coordinator exits"
349                    );
350                    return Err(DbError::InvalidOperation(
351                        "pipeline stop is taking longer than expected; coordinator still \
352                         draining, retry shortly"
353                            .into(),
354                    ));
355                }
356            }
357        }
358        drop(runtime_handle);
359
360        // Do not clear the installed predecessor binding while the old compute generation can
361        // still observe its staged transition. The joined runtime cannot hold a graph read fence;
362        // taking the write side now retires both claims atomically with respect to callbacks and
363        // assignment adoption, and retaining it through `Created` publication closes the final
364        // lifecycle race.
365        #[cfg(feature = "cluster")]
366        let _retired_cluster_generation = if self.is_cluster_runtime() {
367            Some(
368                retire_cluster_compute_generation_until(
369                    &self.rotation_execution_fence,
370                    &self.pending_vnode_transition,
371                    &self.installed_vnode_state,
372                    deadline,
373                )
374                .await
375                .map_err(|_| {
376                    DbError::InvalidOperation(
377                        "pipeline stop could not retire cluster vnode state before its deadline; \
378                         runtime remains fenced in ShuttingDown"
379                            .into(),
380                    )
381                })?,
382            )
383        } else {
384            None
385        };
386
387        // Do not announce Created or release the exclusive deployment lock while a timed-out
388        // decision create can still mutate the recovery frontier. A later stop retry resumes here.
389        self.quiesce_checkpoint_decision_until(deadline).await?;
390        self.reconcile_sink_open_witness_until(deadline).await?;
391        self.quiesce_connector_generation_until(deadline).await?;
392
393        *self.checkpoint_namespace_lock.lock() = None;
394        if self.is_closed() {
395            // A concurrent shutdown owns the terminal transition. Leaving ShuttingDown in place
396            // is intentional if that shutdown was cancelled; a retry must finish its teardown.
397            return Ok(());
398        }
399        if self
400            .terminal_pipeline_halt
401            .load(std::sync::atomic::Ordering::Acquire)
402        {
403            match DbState::compare_exchange(DbState::ShuttingDown, DbState::Faulted, &self.state) {
404                Ok(_) | Err(DbState::Faulted) => return Ok(()),
405                Err(observed) => {
406                    return Err(DbError::InvalidOperation(format!(
407                        "terminal pipeline stop completed from unexpected lifecycle state {observed:?}; restart remains fenced"
408                    )));
409                }
410            }
411        }
412        match DbState::compare_exchange(DbState::ShuttingDown, DbState::Created, &self.state) {
413            Ok(_) | Err(DbState::Created | DbState::Stopped) => Ok(()),
414            Err(observed) => Err(DbError::InvalidOperation(format!(
415                "pipeline stop completed from unexpected lifecycle state {observed:?}; restart remains fenced"
416            ))),
417        }
418    }
419}