Skip to main content

laminar_db/
metrics_api.rs

1//! Pipeline metrics and state query methods for `LaminarDB`.
2//!
3//! Reopens `impl LaminarDB` to keep the main `db.rs` focused on dispatch.
4
5use std::sync::Arc;
6
7use crate::db::{DbState, LaminarDB};
8use crate::error::DbError;
9
10impl LaminarDB {
11    /// Time elapsed since the database was created.
12    #[must_use]
13    pub fn uptime(&self) -> std::time::Duration {
14        self.start_time.elapsed()
15    }
16
17    /// Inject prometheus engine metrics. Called once at startup before `start()`.
18    ///
19    /// The metrics report this database's per-node memory budgets. A cluster database also
20    /// reports the shuffle endpoints its builder installed.
21    pub fn set_engine_metrics(&self, metrics: Arc<crate::engine_metrics::EngineMetrics>) {
22        metrics
23            .flow
24            .observe_memory_budgets(&crate::memory_budget::node_budgets(
25                &self.config,
26                self.runtime_mode(),
27            ));
28        #[cfg(feature = "cluster")]
29        {
30            let sender = self.shuffle_sender.lock().clone();
31            let receiver = self.shuffle_receiver.lock().clone();
32            if let (Some(sender), Some(receiver)) = (sender, receiver) {
33                metrics.flow.shuffle.observe(&sender, &receiver);
34            }
35        }
36        *self.engine_metrics.lock() = Some(metrics);
37    }
38
39    /// Inject a shared Prometheus registry for connector-level metrics.
40    ///
41    /// Called once at startup, after the registry is constructed but before
42    /// `start()`. Connectors created after this call will register their
43    /// metrics on this registry so they appear in the scrape output.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error after the database leaves `Created` or when a registry
48    /// was already installed.
49    pub fn set_prometheus_registry(
50        &self,
51        registry: Arc<prometheus::Registry>,
52    ) -> Result<(), DbError> {
53        let _startup = self.startup_attempt.lock();
54        let mut slot = self.prometheus_registry.lock();
55        let state = DbState::load(&self.state);
56        if state != DbState::Created {
57            return Err(DbError::InvalidOperation(format!(
58                "Prometheus registry can only be installed while the database is Created, not {state:?}"
59            )));
60        }
61        if slot.is_some() {
62            return Err(DbError::InvalidOperation(
63                "Prometheus registry is already installed".into(),
64            ));
65        }
66        self.connector_registry
67            .register_schema_resolution_metrics(&registry)?;
68        *slot = Some(registry);
69        Ok(())
70    }
71
72    /// Get the engine metrics if set.
73    #[must_use]
74    pub fn engine_metrics(&self) -> Option<Arc<crate::engine_metrics::EngineMetrics>> {
75        self.engine_metrics.lock().clone()
76    }
77
78    /// Read process-local committed subscription output-log health without storage I/O.
79    #[cfg(feature = "cluster")]
80    #[must_use]
81    pub fn cluster_subscription_output_health(
82        &self,
83    ) -> Option<crate::metrics::ClusterSubscriptionOutputHealth> {
84        let metrics = self.engine_metrics.lock();
85        let metrics = &metrics.as_ref()?.cluster_subscription;
86        Some(crate::metrics::ClusterSubscriptionOutputHealth {
87            active_readers: nonnegative_gauge(metrics.active_readers.get()),
88            pending_bytes: nonnegative_gauge(metrics.pending_bytes.get()),
89            retained_bytes: nonnegative_gauge(metrics.retained_bytes.get()),
90            orphan_bytes: nonnegative_gauge(metrics.orphan_bytes.get()),
91            open_failures: metrics.open_failures_total.get(),
92            segment_write_failures: metrics.segment_write_failures_total.get(),
93            manifest_failures: metrics.manifest_failures_total.get(),
94            integrity_failures: metrics.integrity_failures_total.get(),
95            stale_writer_rejections: metrics.stale_writer_rejections_total.get(),
96            sequence_gaps: metrics.sequence_gaps_total.get(),
97            lag_disconnects: metrics.gateway_lag_disconnects_total.get(),
98        })
99    }
100
101    /// Read one bounded, process-bound page of local cluster checkpoint barrier timings.
102    ///
103    /// This is an in-memory diagnostic read. It performs no checkpoint, state-backend, network,
104    /// filesystem, or object-store operation. A continuation cursor is the pair of
105    /// `expected_process` and `after_sequence`; callers must retain both from the prior page.
106    /// Snapshot counters may advance immediately after this call, so certification must finish
107    /// with a quiescent repeated read rather than treating one page as a stable cut.
108    ///
109    /// # Errors
110    /// Returns an error when a continuation omits or mismatches its process identity, live process
111    /// authority cannot be sampled without waiting, authority changes during the read, or the
112    /// bounded ledger snapshot rejects its cursor/page request.
113    #[cfg(feature = "cluster")]
114    pub fn checkpoint_barrier_timing_snapshot(
115        &self,
116        expected_process: Option<laminar_core::cluster::control::LocalProcessAuthorityIdentity>,
117        after_sequence: u64,
118        limit: usize,
119    ) -> Result<
120        crate::checkpoint_timing::CheckpointBarrierTimingPage,
121        crate::checkpoint_timing::CheckpointBarrierTimingReadError,
122    > {
123        use crate::checkpoint_timing::{
124            CheckpointBarrierTimingPage, CheckpointBarrierTimingReadError,
125        };
126
127        if after_sequence != 0 && expected_process.is_none() {
128            return Err(CheckpointBarrierTimingReadError::ProcessIdentityRequired);
129        }
130        let controller = self
131            .cluster_controller
132            .try_lock()
133            .and_then(|controller| controller.clone())
134            .ok_or(CheckpointBarrierTimingReadError::ProcessIdentityUnavailable)?;
135        let before = controller
136            .try_live_local_process_authority_identity()
137            .map_err(|_| CheckpointBarrierTimingReadError::ProcessIdentityUnavailable)?;
138        if let Some(expected) = expected_process {
139            if expected != before {
140                return Err(CheckpointBarrierTimingReadError::ProcessIdentityMismatch {
141                    expected,
142                    actual: before,
143                });
144            }
145        }
146
147        let snapshot = self
148            .checkpoint_barrier_timings
149            .snapshot_after(after_sequence, limit)?;
150        let after = controller
151            .try_live_local_process_authority_identity()
152            .map_err(|_| CheckpointBarrierTimingReadError::ProcessIdentityUnavailable)?;
153        if before != after {
154            return Err(CheckpointBarrierTimingReadError::ProcessIdentityChanged { before, after });
155        }
156        if let Some(actual) = snapshot.process {
157            if actual != before {
158                return Err(CheckpointBarrierTimingReadError::LedgerProcessMismatch {
159                    expected: before,
160                    actual,
161                });
162            }
163        }
164        debug_assert!(snapshot
165            .records
166            .iter()
167            .all(|record| record.process == before));
168        Ok(CheckpointBarrierTimingPage {
169            process: before,
170            snapshot,
171        })
172    }
173
174    /// Get the current pipeline state as a string.
175    pub fn pipeline_state(&self) -> &'static str {
176        match DbState::load(&self.state) {
177            DbState::Created => "Created",
178            DbState::Starting => "Starting",
179            DbState::Running => "Running",
180            DbState::ShuttingDown => "ShuttingDown",
181            DbState::Stopped => "Stopped",
182            DbState::Faulted => "Faulted",
183        }
184    }
185
186    /// The last runtime-fault or terminal resource-exhaustion reason. Populated when state is
187    /// `Faulted`; cleared on a clean start.
188    #[must_use]
189    pub fn last_fault(&self) -> Option<String> {
190        self.last_fault.lock().clone()
191    }
192
193    /// Get a pipeline-wide metrics snapshot.
194    ///
195    /// Reads prometheus engine metrics and catalog sizes to produce a
196    /// point-in-time view of pipeline health.
197    #[must_use]
198    #[allow(clippy::cast_sign_loss)]
199    pub fn metrics(&self) -> crate::metrics::PipelineMetrics {
200        let guard = self.engine_metrics.lock();
201        let (ingested, emitted, dropped, cycles, batches, mv_updates, mv_bytes) =
202            if let Some(ref m) = *guard {
203                (
204                    m.events_ingested.get(),
205                    m.events_emitted.get(),
206                    m.events_dropped.get(),
207                    m.cycles.get(),
208                    m.batches.get(),
209                    m.views.updates.get(),
210                    m.views.bytes_stored.get() as u64,
211                )
212            } else {
213                (0, 0, 0, 0, 0, 0, 0)
214            };
215        crate::metrics::PipelineMetrics {
216            total_events_ingested: ingested,
217            total_events_emitted: emitted,
218            total_events_dropped: dropped,
219            total_cycles: cycles,
220            total_batches: batches,
221            uptime: self.start_time.elapsed(),
222            state: self.pipeline_state_enum(),
223            source_count: self.catalog.list_sources().len(),
224            stream_count: self.catalog.list_streams().len(),
225            sink_count: self.catalog.list_sinks().len(),
226            pipeline_watermark: self.pipeline_watermark(),
227            mv_updates,
228            mv_bytes_stored: mv_bytes,
229        }
230    }
231
232    /// Get metrics for a single source by name.
233    #[must_use]
234    pub fn source_metrics(&self, name: &str) -> Option<crate::metrics::SourceMetrics> {
235        let entry = self.catalog.get_source(name)?;
236        let pending = entry.source.pending();
237        let capacity = entry.source.capacity();
238        Some(crate::metrics::SourceMetrics {
239            name: entry.name.clone(),
240            total_events: entry.source.sequence(),
241            pending,
242            capacity,
243            is_backpressured: entry.is_backpressured(),
244            watermark: entry.source.current_watermark(),
245            utilization: crate::metrics::utilization(pending, capacity),
246        })
247    }
248
249    /// Get metrics for all registered sources.
250    #[must_use]
251    pub fn all_source_metrics(&self) -> Vec<crate::metrics::SourceMetrics> {
252        self.catalog
253            .list_sources()
254            .iter()
255            .filter_map(|name| self.source_metrics(name))
256            .collect()
257    }
258
259    /// Get metrics for a single stream by name.
260    #[must_use]
261    pub fn stream_metrics(&self, name: &str) -> Option<crate::metrics::StreamMetrics> {
262        let entry = self.catalog.get_stream_entry(name)?;
263        let sql = self
264            .connector_manager
265            .lock()
266            .streams()
267            .get(name)
268            .map(|reg| reg.query_sql.clone());
269        Some(crate::metrics::StreamMetrics {
270            name: entry.name.clone(),
271            total_events: entry.emitted_rows(),
272            sql,
273        })
274    }
275
276    /// Get metrics for all registered streams.
277    #[must_use]
278    pub fn all_stream_metrics(&self) -> Vec<crate::metrics::StreamMetrics> {
279        self.catalog
280            .list_streams()
281            .iter()
282            .filter_map(|name| self.stream_metrics(name))
283            .collect()
284    }
285
286    /// Get the total number of events processed (ingested + emitted).
287    #[must_use]
288    pub fn total_events_processed(&self) -> u64 {
289        let guard = self.engine_metrics.lock();
290        if let Some(ref m) = *guard {
291            m.events_ingested.get() + m.events_emitted.get()
292        } else {
293            0
294        }
295    }
296
297    /// Returns the global pipeline watermark (minimum across all source watermarks).
298    ///
299    /// Returns `i64::MIN` if no watermark-enabled sources exist or no events
300    /// have been processed.
301    #[must_use]
302    pub fn pipeline_watermark(&self) -> i64 {
303        self.pipeline_watermark
304            .load(std::sync::atomic::Ordering::Relaxed)
305    }
306
307    pub(crate) fn pipeline_state_enum(&self) -> crate::metrics::PipelineState {
308        match DbState::load(&self.state) {
309            DbState::Created => crate::metrics::PipelineState::Created,
310            DbState::Starting => crate::metrics::PipelineState::Starting,
311            DbState::Running => crate::metrics::PipelineState::Running,
312            DbState::ShuttingDown => crate::metrics::PipelineState::ShuttingDown,
313            DbState::Stopped => crate::metrics::PipelineState::Stopped,
314            DbState::Faulted => crate::metrics::PipelineState::Faulted,
315        }
316    }
317
318    /// Cancel a running query by ID.
319    ///
320    /// Marks the query as inactive in the catalog. Future subscription
321    /// polls for this query will receive no more data.
322    ///
323    /// # Errors
324    ///
325    /// Returns `DbError` if the query is not found.
326    pub fn cancel_query(&self, query_id: u64) -> Result<(), DbError> {
327        if self.catalog.deactivate_query(query_id) {
328            Ok(())
329        } else {
330            Err(DbError::QueryNotFound(query_id.to_string()))
331        }
332    }
333
334    /// Get the number of registered sources.
335    pub fn source_count(&self) -> usize {
336        self.catalog.list_sources().len()
337    }
338
339    /// Get the number of registered sinks.
340    pub fn sink_count(&self) -> usize {
341        self.catalog.list_sinks().len()
342    }
343
344    /// Returns checkpoint statistics if available (non-blocking).
345    ///
346    /// Uses `try_lock()` on the coordinator mutex. Returns `None` if
347    /// the coordinator is not initialized or the lock is contended.
348    pub fn checkpoint_stats_nonblocking(
349        &self,
350    ) -> Option<crate::checkpoint_coordinator::CheckpointStats> {
351        let guard = self.coordinator.try_lock().ok()?;
352        guard
353            .as_ref()
354            .map(crate::checkpoint_coordinator::CheckpointCoordinator::stats)
355    }
356
357    /// Get the number of active queries.
358    pub fn active_query_count(&self) -> usize {
359        self.catalog
360            .list_queries()
361            .iter()
362            .filter(|(_, _, active)| *active)
363            .count()
364    }
365}
366
367#[cfg(feature = "cluster")]
368fn nonnegative_gauge(value: i64) -> u64 {
369    u64::try_from(value).unwrap_or(0)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[cfg(feature = "cluster")]
377    async fn cluster_db_with_live_process() -> (
378        Arc<LaminarDB>,
379        laminar_core::cluster::control::LocalProcessAuthorityIdentity,
380    ) {
381        use laminar_core::cluster::control::{
382            ClusterController, ClusterKv, LeaseDeadline, ProcessLeaseAuthority, ProcessLeaseOutcome,
383        };
384        use laminar_core::cluster::discovery::{NodeId, NodeInfo};
385
386        let node = NodeId(71);
387        let boot = uuid::Uuid::from_u128(71);
388        let kv: Arc<dyn ClusterKv> =
389            Arc::new(laminar_core::cluster::control::InMemoryKv::new(node));
390        let (_members_tx, members_rx) = tokio::sync::watch::channel(Vec::<NodeInfo>::new());
391        let controller = Arc::new(ClusterController::new_with_recovery_incarnation(
392            node,
393            Arc::clone(&kv),
394            kv,
395            None,
396            members_rx,
397            boot,
398        ));
399        controller
400            .set_process_lease_deadline(Arc::new(LeaseDeadline::live_for(
401                std::time::Duration::from_secs(30),
402            )))
403            .unwrap();
404        let authority = Arc::new(
405            ProcessLeaseAuthority::new(
406                Arc::new(object_store::memory::InMemory::new()),
407                std::time::Duration::from_secs(30),
408            )
409            .unwrap(),
410        );
411        let ProcessLeaseOutcome::Acquired(lease) = authority
412            .store_for(node)
413            .try_acquire(boot, 0)
414            .await
415            .unwrap()
416        else {
417            panic!("empty process authority must grant the test process");
418        };
419        controller.set_process_lease_authority(authority).unwrap();
420        controller
421            .publish_leased_recovery_incarnation(&lease)
422            .await
423            .unwrap();
424        let process = controller
425            .try_live_local_process_authority_identity()
426            .unwrap();
427
428        let db = Arc::new(
429            LaminarDB::open_with_config_and_vars_and_rules(
430                crate::config::LaminarConfig::default(),
431                std::collections::HashMap::new(),
432                &[],
433                None,
434                crate::db::RuntimeMode::Cluster,
435            )
436            .unwrap(),
437        );
438        db.set_cluster_controller(controller).unwrap();
439        (db, process)
440    }
441
442    #[cfg(feature = "cluster")]
443    fn timing_observation(
444        process: laminar_core::cluster::control::LocalProcessAuthorityIdentity,
445        checkpoint_id: u64,
446    ) -> crate::checkpoint_timing::CheckpointBarrierTimingObservation {
447        crate::checkpoint_timing::CheckpointBarrierTimingObservation {
448            process,
449            attempt: laminar_core::checkpoint::CheckpointAttempt::canonical(checkpoint_id),
450            role: crate::checkpoint_timing::CheckpointBarrierRole::Follower,
451            assignment_version: 3,
452            assignment_digest: [9; 32],
453            pipeline_stall_ns: 100,
454            local_barrier_ns: 60,
455            aligned_resume_ns: Some(20),
456            durable_tail_handoff: true,
457            deadline_exhausted: false,
458        }
459    }
460
461    #[test]
462    fn prometheus_registry_is_single_assignment() {
463        let db = LaminarDB::open().unwrap();
464        db.set_prometheus_registry(Arc::new(prometheus::Registry::new()))
465            .unwrap();
466
467        let error = db
468            .set_prometheus_registry(Arc::new(prometheus::Registry::new()))
469            .unwrap_err()
470            .to_string();
471
472        assert!(error.contains("already installed"), "{error}");
473    }
474
475    #[test]
476    fn prometheus_registry_cannot_change_after_start_begins() {
477        let db = LaminarDB::open().unwrap();
478        DbState::Starting.store(&db.state);
479
480        let error = db
481            .set_prometheus_registry(Arc::new(prometheus::Registry::new()))
482            .unwrap_err()
483            .to_string();
484
485        DbState::Created.store(&db.state);
486        assert!(error.contains("Created"), "{error}");
487    }
488
489    #[test]
490    fn prometheus_registry_install_is_serialized_with_startup_claim() {
491        let db = Arc::new(LaminarDB::open().unwrap());
492        let startup = db.startup_attempt.lock();
493        let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
494        let installing = Arc::clone(&db);
495        let install = std::thread::spawn(move || {
496            started_tx.send(()).unwrap();
497            installing.set_prometheus_registry(Arc::new(prometheus::Registry::new()))
498        });
499        started_rx.recv().unwrap();
500        DbState::Starting.store(&db.state);
501        drop(startup);
502
503        let error = install.join().unwrap().unwrap_err().to_string();
504
505        DbState::Created.store(&db.state);
506        assert!(error.contains("Created"), "{error}");
507    }
508
509    #[test]
510    fn attached_metrics_report_the_database_memory_budgets() {
511        let db = LaminarDB::open_with_config(crate::LaminarConfig {
512            source_queue_max_bytes: 4096,
513            ..crate::LaminarConfig::default()
514        })
515        .unwrap();
516        let registry = prometheus::Registry::new();
517        db.set_engine_metrics(Arc::new(crate::engine_metrics::EngineMetrics::new(
518            &registry,
519        )));
520
521        let budgets = registry
522            .gather()
523            .into_iter()
524            .find(|family| family.name() == "memory_budget_bytes")
525            .unwrap();
526        let budget = |area: &str| {
527            budgets
528                .get_metric()
529                .iter()
530                .find(|metric| metric.get_label()[0].value() == area)
531                .map(|metric| metric.get_gauge().value())
532        };
533        assert_eq!(budget("source_queue"), Some(4096.0));
534        assert_eq!(budget("managed_state"), Some(256.0 * 1024.0 * 1024.0));
535        assert_eq!(budget("subscription_logs"), Some(256.0 * 1024.0 * 1024.0));
536        assert_eq!(budget("shuffle_outbound"), None);
537    }
538
539    #[cfg(feature = "cluster")]
540    #[test]
541    fn cluster_subscription_health_is_aggregated_without_identity_labels() {
542        let db = LaminarDB::open().unwrap();
543        let registry = prometheus::Registry::new();
544        let metrics = Arc::new(crate::engine_metrics::EngineMetrics::new(&registry));
545        metrics.cluster_subscription.active_readers.set(2);
546        metrics.cluster_subscription.pending_bytes.set(128);
547        metrics.cluster_subscription.integrity_failures_total.inc();
548        db.set_engine_metrics(metrics);
549
550        let health = db.cluster_subscription_output_health().unwrap();
551        assert_eq!(health.active_readers, 2);
552        assert_eq!(health.pending_bytes, 128);
553        assert_eq!(health.integrity_failures, 1);
554        assert_eq!(health.retained_bytes, 0);
555    }
556
557    #[cfg(feature = "cluster")]
558    #[tokio::test]
559    async fn checkpoint_timing_cursor_is_bound_to_the_live_process() {
560        use crate::checkpoint_timing::CheckpointBarrierTimingReadError;
561
562        let (db, process) = cluster_db_with_live_process().await;
563        assert!(db
564            .checkpoint_barrier_timings
565            .try_record(timing_observation(process, 1)));
566        assert!(db
567            .checkpoint_barrier_timings
568            .try_record(timing_observation(process, 2)));
569
570        assert_eq!(
571            db.checkpoint_barrier_timing_snapshot(None, 1, 1),
572            Err(CheckpointBarrierTimingReadError::ProcessIdentityRequired)
573        );
574        let mut stale_process = process;
575        stale_process.process_term += 1;
576        assert_eq!(
577            db.checkpoint_barrier_timing_snapshot(Some(stale_process), 1, 1),
578            Err(CheckpointBarrierTimingReadError::ProcessIdentityMismatch {
579                expected: stale_process,
580                actual: process,
581            })
582        );
583
584        let first = db.checkpoint_barrier_timing_snapshot(None, 0, 1).unwrap();
585        assert_eq!(first.process, process);
586        assert_eq!(first.snapshot.process, Some(process));
587        assert_eq!(first.snapshot.records[0].sequence, 1);
588        let second = db
589            .checkpoint_barrier_timing_snapshot(Some(process), 1, 1)
590            .unwrap();
591        assert_eq!(second.snapshot.records[0].sequence, 2);
592    }
593
594    #[cfg(feature = "cluster")]
595    #[tokio::test]
596    async fn checkpoint_timing_read_rejects_a_foreign_ledger_domain() {
597        use crate::checkpoint_timing::CheckpointBarrierTimingReadError;
598
599        let (db, process) = cluster_db_with_live_process().await;
600        let mut foreign = process;
601        foreign.process_term += 1;
602        assert!(db
603            .checkpoint_barrier_timings
604            .try_record(timing_observation(foreign, 1)));
605
606        assert_eq!(
607            db.checkpoint_barrier_timing_snapshot(None, 0, 1),
608            Err(CheckpointBarrierTimingReadError::LedgerProcessMismatch {
609                expected: process,
610                actual: foreign,
611            })
612        );
613    }
614}