Skip to main content

laminar_db/pipeline_lifecycle/
authority.rs

1#[cfg(feature = "cluster")]
2use super::latch_cluster_terminal_data_plane;
3use super::{Arc, DbError, LaminarDB, PipelineLifecycleAuthority};
4
5impl LaminarDB {
6    /// Permanently fence this process after a deterministic pipeline halt. This is process-local;
7    /// remote durable terminal evidence uses [`Self::latch_durable_terminal_recovery_fence`].
8    #[cfg(feature = "cluster")]
9    pub(crate) fn latch_local_terminal_pipeline_halt(&self) {
10        latch_cluster_terminal_data_plane(
11            &self.cluster_authority_transition,
12            &self.terminal_pipeline_halt,
13            &self.source_gate,
14            &self.coordinated_recovery_fenced,
15        );
16    }
17
18    /// Permanently fence this DB instance after terminal evidence is durable anywhere in the
19    /// cluster authority namespace. Healthy peers retain a distinct local-halt latch so they can
20    /// still quiesce and acknowledge the terminal Prepare.
21    #[cfg(feature = "cluster")]
22    pub(crate) fn latch_durable_terminal_recovery_fence(&self) {
23        latch_cluster_terminal_data_plane(
24            &self.cluster_authority_transition,
25            &self.durable_terminal_recovery_fence,
26            &self.source_gate,
27            &self.coordinated_recovery_fenced,
28        );
29    }
30
31    #[cfg(feature = "cluster")]
32    pub(super) fn validate_fresh_cluster_vnode_start(&self) -> Result<(), DbError> {
33        if self.has_unapplied_vnode_transition() {
34            return Err(DbError::Checkpoint(
35                "[LDB-6031] cluster startup found staged vnode state but no exact recovered \
36                 checkpoint; refusing a fresh graph"
37                    .into(),
38            ));
39        }
40        Ok(())
41    }
42
43    /// Prepare the success marker for a graph generation with no vnode transition callbacks.
44    /// Startup holds `assignment_adoption_lock`, so the registry cannot be overtaken while durable
45    /// history is revalidated. The marker is installed at the compute-generation ready boundary.
46    #[cfg(feature = "cluster")]
47    pub(super) async fn prepare_graph_ready_vnode_state_binding(
48        &self,
49        deadline: tokio::time::Instant,
50    ) -> Result<Option<crate::vnode_transition_staging::InstalledVnodeStateBinding>, DbError> {
51        let registry = self.vnode_registry.lock().clone().ok_or_else(|| {
52            DbError::Checkpoint(
53                "cluster graph readiness has no vnode registry for installed-state binding".into(),
54            )
55        })?;
56        let assignment = registry.versioned_snapshot();
57        if assignment.version() == 0 || self.has_unapplied_vnode_transition() {
58            return Ok(None);
59        }
60
61        let pipeline_identity = self
62            .coordinator
63            .lock()
64            .await
65            .as_ref()
66            .map(crate::checkpoint_coordinator::CheckpointCoordinator::bound_pipeline_identity)
67            .transpose()?;
68        let Some(pipeline_identity) = pipeline_identity else {
69            return Ok(None);
70        };
71        let controller = self.cluster_controller.lock().clone().ok_or_else(|| {
72            DbError::Checkpoint(
73                "cluster graph readiness has no controller for assignment validation".into(),
74            )
75        })?;
76        let store = self
77            .assignment_snapshot_store
78            .lock()
79            .clone()
80            .ok_or_else(|| {
81                DbError::Checkpoint(
82                    "cluster graph readiness has no durable assignment history".into(),
83                )
84            })?;
85        let durable = tokio::time::timeout_at(deadline, store.load_version(assignment.version()))
86            .await
87            .map_err(|_| {
88                DbError::Checkpoint(format!(
89                    "assignment {} history read timed out at graph readiness",
90                    assignment.version()
91                ))
92            })?
93            .map_err(|error| DbError::Checkpoint(error.to_string()))?
94            .ok_or_else(|| {
95                DbError::Checkpoint(format!(
96                    "assignment {} is absent from durable history at graph readiness",
97                    assignment.version()
98                ))
99            })?;
100        tokio::time::timeout_at(
101            deadline,
102            crate::rebalance::audit_assignment_snapshot_authority(
103                &store,
104                Some(controller.as_ref()),
105                &durable,
106            ),
107        )
108        .await
109        .map_err(|_| {
110            DbError::Checkpoint(format!(
111                "assignment {} authority audit timed out at graph readiness",
112                assignment.version()
113            ))
114        })?
115        .map_err(DbError::Checkpoint)?;
116        let durable_owners = durable
117            .to_vnode_vec(registry.vnode_count())
118            .map_err(|error| DbError::Checkpoint(error.to_string()))?;
119        if durable.draining
120            || durable.version != assignment.version()
121            || durable_owners.as_slice() != assignment.owners()
122        {
123            return Err(DbError::Checkpoint(format!(
124                "assignment {} durable history does not match the graph-ready registry",
125                assignment.version()
126            )));
127        }
128        let fence = durable
129            .assignment_fence()
130            .map_err(|error| DbError::Checkpoint(error.to_string()))?;
131        match fence.participant_incarnation(controller.instance_id().0) {
132            Some(boot_incarnation) if boot_incarnation == controller.recovery_incarnation() => {}
133            Some(_) => {
134                return Err(DbError::Checkpoint(format!(
135                    "assignment {} names a different local process incarnation at graph readiness",
136                    assignment.version()
137                )));
138            }
139            None => return Ok(None),
140        }
141
142        // Revalidate after external I/O even though startup still owns assignment adoption.
143        let current = registry.versioned_snapshot();
144        if current.version() != assignment.version()
145            || current.owners() != assignment.owners()
146            || self.has_unapplied_vnode_transition()
147        {
148            return Err(DbError::Checkpoint(format!(
149                "assignment {} changed or gained vnode work before graph-ready publication",
150                assignment.version()
151            )));
152        }
153        Ok(Some(
154            crate::vnode_transition_staging::InstalledVnodeStateBinding::new(
155                fence,
156                pipeline_identity,
157            )?,
158        ))
159    }
160
161    /// Returns `true` if the database has been shut down.
162    pub fn is_closed(&self) -> bool {
163        self.shutdown.load(std::sync::atomic::Ordering::Acquire)
164    }
165
166    /// Fence new work and wake the runtime so it can shut down.
167    pub fn close(&self) {
168        let runtime_shutdown = self.runtime_shutdown.write();
169        self.shutdown
170            .store(true, std::sync::atomic::Ordering::Release);
171        #[cfg(feature = "cluster")]
172        self.assignment_restore_shutdown.cancel();
173        runtime_shutdown.cancel();
174        self.shutdown_signal.notify_one();
175    }
176
177    /// Enable auto-restart from the last checkpoint on a fault. Without it, a fault parks
178    /// in `Faulted` for manual restart (the embedded default).
179    pub fn enable_supervision(self: &Arc<Self>) {
180        *self.supervisor_self.lock() = Arc::downgrade(self);
181    }
182
183    /// Select the next coordinated start's recovery cut. `None` selects the latest durable head.
184    /// The value is taken by startup when a checkpoint coordinator is present.
185    #[cfg(feature = "cluster")]
186    pub(crate) fn set_recover_target_epoch(&self, epoch: Option<u64>) {
187        *self.recover_target_epoch.lock() = epoch;
188    }
189
190    /// Open or close the source-intake gate. Closed (`true`) during a coordinated round until
191    /// the restore quorum, so no node re-shuffles its replay into a peer that hasn't rebound.
192    #[cfg(feature = "cluster")]
193    pub(crate) fn set_source_gate(&self, closed: bool) {
194        if closed {
195            self.source_gate
196                .store(true, std::sync::atomic::Ordering::SeqCst);
197            return;
198        }
199        let _transition = self.cluster_authority_transition.lock();
200        if self
201            .terminal_pipeline_halt
202            .load(std::sync::atomic::Ordering::Acquire)
203            || self
204                .durable_terminal_recovery_fence
205                .load(std::sync::atomic::Ordering::Acquire)
206            || self
207                .pending_recovery_fault
208                .load(std::sync::atomic::Ordering::Acquire)
209                != 0
210        {
211            self.source_gate
212                .store(true, std::sync::atomic::Ordering::SeqCst);
213            return;
214        }
215        let process_authority_live = !self.is_cluster_runtime()
216            || self
217                .cluster_controller
218                .lock()
219                .as_ref()
220                .is_some_and(|controller| controller.process_lease_is_live());
221        if !process_authority_live {
222            self.source_gate
223                .store(true, std::sync::atomic::Ordering::SeqCst);
224            return;
225        }
226        if !self
227            .cluster_authority_revoked
228            .load(std::sync::atomic::Ordering::Acquire)
229        {
230            self.source_gate
231                .store(false, std::sync::atomic::Ordering::SeqCst);
232        }
233    }
234
235    /// Keep clustered source intake closed while startup restores state and certifies assignment.
236    /// Call before [`Self::start`]; [`Self::finish_cluster_startup`] is the only startup path that
237    /// opens the gate.
238    #[cfg(feature = "cluster")]
239    pub fn fence_cluster_startup(&self) {
240        // Every startup boundary invalidates prior same-process evidence. A later graph attempt
241        // must either publish a fresh pre-start audit or use finish's fail-closed durable fallback.
242        *self.startup_checkpoint_artifact_audit.lock() = None;
243        self.set_source_gate(true);
244        if let Some(controller) = self.cluster_controller.lock().as_ref() {
245            controller.set_recovering(true);
246        }
247    }
248
249    /// Retain exclusive lifecycle ownership across a coordinated stop/report/start/release round.
250    #[cfg(feature = "cluster")]
251    pub(crate) fn fence_coordinated_recovery_lifecycle(&self) {
252        let _lifecycle_claim = self.startup_attempt.lock();
253        self.set_source_gate(true);
254        self.coordinated_recovery_fenced
255            .store(true, std::sync::atomic::Ordering::Release);
256    }
257
258    /// Release lifecycle ownership after terminal consumption unless a replacement fault won the
259    /// transition; its outstanding latch keeps public mutation fenced.
260    #[cfg(feature = "cluster")]
261    pub(crate) fn release_coordinated_recovery_lifecycle(&self) {
262        let _lifecycle_claim = self.startup_attempt.lock();
263        let keep_fenced = self
264            .terminal_pipeline_halt
265            .load(std::sync::atomic::Ordering::Acquire)
266            || self
267                .durable_terminal_recovery_fence
268                .load(std::sync::atomic::Ordering::Acquire)
269            || self
270                .pending_recovery_fault
271                .load(std::sync::atomic::Ordering::Acquire)
272                != 0;
273        self.coordinated_recovery_fenced
274            .store(keep_fenced, std::sync::atomic::Ordering::Release);
275        if keep_fenced {
276            self.set_source_gate(true);
277        }
278    }
279
280    #[cfg(feature = "cluster")]
281    pub(super) fn ensure_pipeline_lifecycle_authorized(
282        &self,
283        authority: PipelineLifecycleAuthority,
284        operation: &str,
285    ) -> Result<(), DbError> {
286        if self
287            .coordinated_recovery_fenced
288            .load(std::sync::atomic::Ordering::Acquire)
289            && authority == PipelineLifecycleAuthority::Public
290        {
291            return Err(DbError::InvalidOperation(format!(
292                "pipeline {operation} is fenced by coordinated recovery"
293            )));
294        }
295        Ok(())
296    }
297
298    pub(super) fn ensure_terminal_halt_allows_start(
299        &self,
300        authority: PipelineLifecycleAuthority,
301    ) -> Result<(), DbError> {
302        let _ = authority;
303        let local_halt = self
304            .terminal_pipeline_halt
305            .load(std::sync::atomic::Ordering::Acquire);
306        #[cfg(feature = "cluster")]
307        let durable_halt = self.is_cluster_runtime()
308            && self
309                .durable_terminal_recovery_fence
310                .load(std::sync::atomic::Ordering::Acquire);
311        #[cfg(not(feature = "cluster"))]
312        let durable_halt = false;
313        if local_halt || durable_halt {
314            return Err(DbError::PipelineTerminal(format!(
315                "cannot restart a permanently halted pipeline in this process: {}",
316                self.last_fault()
317                    .unwrap_or_else(|| "terminal halt reason was not recorded".into())
318            )));
319        }
320        Ok(())
321    }
322
323    #[cfg(not(feature = "cluster"))]
324    pub(super) fn ensure_pipeline_lifecycle_authorized(
325        authority: PipelineLifecycleAuthority,
326        operation: &str,
327    ) {
328        let _ = (authority, operation);
329    }
330
331    #[cfg(feature = "cluster")]
332    pub(crate) fn ensure_coordinated_recovery_mutation_unfenced(
333        &self,
334        operation: &str,
335    ) -> Result<(), DbError> {
336        self.ensure_pipeline_lifecycle_authorized(PipelineLifecycleAuthority::Public, operation)
337    }
338
339    /// Permanently withdraw this process's clustered data-plane authority after lease loss.
340    #[cfg(feature = "cluster")]
341    pub fn revoke_cluster_authority(&self) {
342        if !self.is_cluster_runtime() {
343            return;
344        }
345        let _transition = self.cluster_authority_transition.lock();
346        let first_revocation = !self
347            .cluster_authority_revoked
348            .swap(true, std::sync::atomic::Ordering::AcqRel);
349        self.source_gate
350            .store(true, std::sync::atomic::Ordering::SeqCst);
351        if first_revocation {
352            self.invalidate_shuffle_assignment_fence();
353        }
354        let controller = self.cluster_controller.lock().clone();
355        if let Some(controller) = controller.as_ref() {
356            controller.fence_process_lease();
357        }
358    }
359
360    /// Whether clustered source and shuffle intake is still fenced.
361    #[cfg(feature = "cluster")]
362    #[must_use]
363    pub fn cluster_intake_fenced(&self) -> bool {
364        self.cluster_authority_revoked
365            .load(std::sync::atomic::Ordering::Acquire)
366            || self.source_gate.load(std::sync::atomic::Ordering::Acquire)
367            || (self.is_cluster_runtime()
368                && self
369                    .cluster_controller
370                    .lock()
371                    .as_ref()
372                    .is_none_or(|controller| !controller.process_lease_is_live()))
373    }
374
375    /// Whether a clustered runtime is held by coordinated recovery lifecycle authority.
376    #[cfg(feature = "cluster")]
377    #[must_use]
378    pub fn coordinated_recovery_in_progress(&self) -> bool {
379        self.is_cluster_runtime()
380            && self
381                .coordinated_recovery_fenced
382                .load(std::sync::atomic::Ordering::Acquire)
383    }
384}