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