Skip to main content

velesdb_core/collection/graph/metrics/
mod.rs

1//! Performance metrics for graph operations (EPIC-019 US-006).
2//!
3//! Provides low-overhead, thread-safe metrics for monitoring:
4//! - Operation counters (inserts, deletes, traversals)
5//! - Latency histograms
6//! - Memory usage estimates
7//!
8//! Metrics use atomic operations with relaxed ordering for minimal overhead (~1-5ns per op).
9
10// Reason: Numeric casts in metrics are intentional:
11// - All casts are for histogram bucketing and latency calculations
12// - f64/u64 conversions for computing percentiles and averages
13// - Values bounded by practical limits (bucket counts, durations)
14// - Precision loss acceptable for metrics (approximate by design)
15#![allow(clippy::cast_precision_loss)]
16#![allow(clippy::cast_possible_truncation)]
17
18#[cfg(test)]
19mod tests;
20
21use std::fmt::Write;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::time::Duration;
24
25/// Latency histogram buckets (milliseconds).
26///
27/// These are the upper bounds exported as Prometheus `le` labels, and a
28/// Prometheus bucket is inclusive: an observation equal to a bound belongs to
29/// that bound's bucket, not the next one.
30const BUCKET_BOUNDS_MS: [u64; 9] = [1, 5, 10, 50, 100, 500, 1000, 5000, 10000];
31
32/// Simple latency histogram with fixed buckets.
33///
34/// Buckets: ≤1ms, ≤5ms, ≤10ms, ≤50ms, ≤100ms, ≤500ms, ≤1s, ≤5s, ≤10s, >10s
35#[derive(Debug, Default)]
36pub struct LatencyHistogram {
37    /// Bucket counts [≤1ms, ≤5ms, ≤10ms, ≤50ms, ≤100ms, ≤500ms, ≤1s, ≤5s, ≤10s, >10s]
38    buckets: [AtomicU64; 10],
39    /// Sum of all observed durations in nanoseconds
40    sum_ns: AtomicU64,
41    /// Total number of observations
42    count: AtomicU64,
43}
44
45impl LatencyHistogram {
46    /// Creates a new empty histogram.
47    #[must_use]
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Records a duration observation.
53    ///
54    /// # Note
55    ///
56    /// For extremely large durations (> 584 years), nanoseconds are capped at u64::MAX
57    /// to prevent truncation. This is acceptable since such durations indicate a bug.
58    pub fn observe(&self, duration: Duration) {
59        // Cap at u64::MAX for durations > 584 years (u128 -> u64 truncation protection)
60        let ns_u128 = duration.as_nanos();
61        let ns = if ns_u128 > u128::from(u64::MAX) {
62            u64::MAX
63        } else {
64            ns_u128 as u64
65        };
66        self.sum_ns.fetch_add(ns, Ordering::Relaxed);
67        self.count.fetch_add(1, Ordering::Relaxed);
68
69        // Same protection for milliseconds (though less likely to overflow)
70        let ms_u128 = duration.as_millis();
71        let ms = if ms_u128 > u128::from(u64::MAX) {
72            u64::MAX
73        } else {
74            ms_u128 as u64
75        };
76        let bucket_idx = BUCKET_BOUNDS_MS
77            .iter()
78            .position(|&bound| ms <= bound)
79            .unwrap_or(BUCKET_BOUNDS_MS.len());
80        self.buckets[bucket_idx].fetch_add(1, Ordering::Relaxed);
81    }
82
83    /// Returns the total count of observations.
84    #[must_use]
85    pub fn count(&self) -> u64 {
86        self.count.load(Ordering::Relaxed)
87    }
88
89    /// Returns the sum of all durations in nanoseconds.
90    #[must_use]
91    pub fn sum_ns(&self) -> u64 {
92        self.sum_ns.load(Ordering::Relaxed)
93    }
94
95    /// Returns the average duration in nanoseconds.
96    #[must_use]
97    pub fn avg_ns(&self) -> f64 {
98        let count = self.count();
99        if count == 0 {
100            0.0
101        } else {
102            self.sum_ns() as f64 / count as f64
103        }
104    }
105
106    /// Returns bucket counts as an array.
107    #[must_use]
108    pub fn bucket_counts(&self) -> [u64; 10] {
109        let mut counts = [0u64; 10];
110        for (i, bucket) in self.buckets.iter().enumerate() {
111            counts[i] = bucket.load(Ordering::Relaxed);
112        }
113        counts
114    }
115
116    /// Resets all counters to zero.
117    pub fn reset(&self) {
118        self.sum_ns.store(0, Ordering::Relaxed);
119        self.count.store(0, Ordering::Relaxed);
120        for bucket in &self.buckets {
121            bucket.store(0, Ordering::Relaxed);
122        }
123    }
124}
125
126/// Graph-specific performance metrics.
127///
128/// Thread-safe counters and histograms for monitoring graph operations.
129///
130/// # Example
131///
132/// ```rust,ignore
133/// use velesdb_core::collection::graph::GraphMetrics;
134///
135/// let metrics = GraphMetrics::new();
136///
137/// // Record an edge insertion (counters only — see `record_edge_inserts_batch`
138/// // for the batch path, which is what feeds `edge_insert_latency`)
139/// metrics.record_edge_insert();
140///
141/// // Get statistics
142/// println!("Total edges inserted: {}", metrics.edge_inserts_total());
143/// ```
144#[derive(Debug, Default)]
145pub struct GraphMetrics {
146    // Edge counters
147    edges_total: AtomicU64,
148    edge_inserts_total: AtomicU64,
149    edge_deletes_total: AtomicU64,
150
151    // Traversal counters
152    traversals_total: AtomicU64,
153    traversal_nodes_visited: AtomicU64,
154
155    // Latency histograms
156    /// Edge insertion latency histogram. Populated by the batch insert path
157    /// only — the single-edge path records counters without a clock read
158    /// (see `record_edge_insert`).
159    pub edge_insert_latency: LatencyHistogram,
160    /// Traversal latency histogram
161    pub traversal_latency: LatencyHistogram,
162    /// Query latency histogram
163    pub query_latency: LatencyHistogram,
164}
165
166impl GraphMetrics {
167    /// Creates a new metrics instance with all counters at zero.
168    #[must_use]
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    // =========================================================================
174    // Edge metrics
175    // =========================================================================
176
177    /// Records an edge insertion.
178    ///
179    /// Counters only — no clock read. The single-edge path runs per write, so
180    /// a per-call `Instant::now()` plus histogram bucketing was a real cost
181    /// paid on every insert; nothing reads it. `edge_insert_latency` is
182    /// still fed by `record_edge_inserts_batch`, which pays that cost once
183    /// per batch instead of once per edge.
184    pub fn record_edge_insert(&self) {
185        self.edge_inserts_total.fetch_add(1, Ordering::Relaxed);
186        self.edges_total.fetch_add(1, Ordering::Relaxed);
187    }
188
189    /// Records a batch edge insertion.
190    ///
191    /// Bumps the insert/edge counters by `count` and observes the batch
192    /// `latency` once, avoiding a per-edge `Instant::now()` on the bulk path.
193    pub fn record_edge_inserts_batch(&self, count: u64, latency: Duration) {
194        if count == 0 {
195            return;
196        }
197        self.edge_inserts_total.fetch_add(count, Ordering::Relaxed);
198        self.edges_total.fetch_add(count, Ordering::Relaxed);
199        self.edge_insert_latency.observe(latency);
200    }
201
202    /// Records an edge deletion.
203    ///
204    /// Counters only — no clock read. See `record_edge_insert` for why: the
205    /// per-delete `Instant::now()` this used to take had no reader.
206    ///
207    /// Uses saturating subtraction to prevent underflow.
208    pub fn record_edge_delete(&self) {
209        self.edge_deletes_total.fetch_add(1, Ordering::Relaxed);
210        self.edges_total
211            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |x| {
212                Some(x.saturating_sub(1))
213            })
214            .ok();
215    }
216
217    /// Returns total edge count.
218    #[must_use]
219    pub fn edges_total(&self) -> u64 {
220        self.edges_total.load(Ordering::Relaxed)
221    }
222
223    /// Returns total edge insertions.
224    #[must_use]
225    pub fn edge_inserts_total(&self) -> u64 {
226        self.edge_inserts_total.load(Ordering::Relaxed)
227    }
228
229    /// Returns total edge deletions.
230    #[must_use]
231    pub fn edge_deletes_total(&self) -> u64 {
232        self.edge_deletes_total.load(Ordering::Relaxed)
233    }
234
235    // =========================================================================
236    // Traversal metrics
237    // =========================================================================
238
239    /// Records a traversal with latency and nodes visited.
240    pub fn record_traversal(&self, latency: Duration, nodes_visited: u64) {
241        self.traversals_total.fetch_add(1, Ordering::Relaxed);
242        self.traversal_nodes_visited
243            .fetch_add(nodes_visited, Ordering::Relaxed);
244        self.traversal_latency.observe(latency);
245    }
246
247    /// Returns total traversal count.
248    #[must_use]
249    pub fn traversals_total(&self) -> u64 {
250        self.traversals_total.load(Ordering::Relaxed)
251    }
252
253    /// Returns total nodes visited across all traversals.
254    #[must_use]
255    pub fn traversal_nodes_visited(&self) -> u64 {
256        self.traversal_nodes_visited.load(Ordering::Relaxed)
257    }
258
259    // =========================================================================
260    // Query metrics
261    // =========================================================================
262
263    /// Records a query latency.
264    pub fn record_query(&self, latency: Duration) {
265        self.query_latency.observe(latency);
266    }
267
268    // =========================================================================
269    // Export
270    // =========================================================================
271
272    /// Appends this store's samples for every family, tagged with `collection`.
273    ///
274    /// Emits sample lines only — never `# HELP` or `# TYPE`. Those belong to
275    /// the metric *family*, not to one collection, and are written once by
276    /// [`to_prometheus`].
277    fn append_samples(&self, output: &mut String, collection: &str) {
278        let _ = writeln!(
279            output,
280            "velesdb_graph_edges_total{{collection=\"{collection}\"}} {}",
281            self.edges_total()
282        );
283        let _ = writeln!(
284            output,
285            "velesdb_graph_edge_inserts_total{{collection=\"{collection}\"}} {}",
286            self.edge_inserts_total()
287        );
288        let _ = writeln!(
289            output,
290            "velesdb_graph_edge_deletes_total{{collection=\"{collection}\"}} {}",
291            self.edge_deletes_total()
292        );
293        let _ = writeln!(
294            output,
295            "velesdb_graph_traversals_total{{collection=\"{collection}\"}} {}",
296            self.traversals_total()
297        );
298        let _ = writeln!(
299            output,
300            "velesdb_graph_traversal_nodes_visited_total{{collection=\"{collection}\"}} {}",
301            self.traversal_nodes_visited()
302        );
303    }
304
305    /// Appends this store's histogram samples, tagged with `collection`.
306    ///
307    /// Sample lines only, for the same reason as [`Self::append_samples`].
308    /// The `le` labels are derived from `BUCKET_BOUNDS_MS`, the same array
309    /// that decides which bucket an observation lands in, so the exported
310    /// bound can never disagree with the bucket that counted it.
311    fn append_histogram_samples(&self, output: &mut String, collection: &str) {
312        // Zipped against HISTOGRAM_FAMILIES rather than re-listing the names:
313        // the preamble in `to_prometheus` walks that same array, so a family
314        // added on one side cannot go undeclared on the other.
315        let histograms = [&self.edge_insert_latency, &self.traversal_latency];
316        for (infix, histogram) in HISTOGRAM_FAMILIES.into_iter().zip(histograms) {
317            let counts = histogram.bucket_counts();
318            let mut cumulative = 0u64;
319
320            for (i, &bound_ms) in BUCKET_BOUNDS_MS.iter().enumerate() {
321                cumulative += counts[i];
322                #[allow(clippy::cast_precision_loss)]
323                let bound = bound_ms as f64 / 1000.0;
324                let _ = writeln!(
325                    output,
326                    "velesdb_graph_{infix}_duration_seconds_bucket{{collection=\"{collection}\",le=\"{bound}\"}} {cumulative}"
327                );
328            }
329            cumulative += counts[BUCKET_BOUNDS_MS.len()];
330            let _ = writeln!(
331                output,
332                "velesdb_graph_{infix}_duration_seconds_bucket{{collection=\"{collection}\",le=\"+Inf\"}} {cumulative}"
333            );
334
335            #[allow(clippy::cast_precision_loss)]
336            let sum_seconds = histogram.sum_ns() as f64 / 1_000_000_000.0;
337            let _ = writeln!(
338                output,
339                "velesdb_graph_{infix}_duration_seconds_sum{{collection=\"{collection}\"}} {sum_seconds}"
340            );
341            let _ = writeln!(
342                output,
343                "velesdb_graph_{infix}_duration_seconds_count{{collection=\"{collection}\"}} {}",
344                histogram.count()
345            );
346        }
347    }
348
349    /// Resets all metrics to zero.
350    pub fn reset(&self) {
351        self.edges_total.store(0, Ordering::Relaxed);
352        self.edge_inserts_total.store(0, Ordering::Relaxed);
353        self.edge_deletes_total.store(0, Ordering::Relaxed);
354        self.traversals_total.store(0, Ordering::Relaxed);
355        self.traversal_nodes_visited.store(0, Ordering::Relaxed);
356        self.edge_insert_latency.reset();
357        self.traversal_latency.reset();
358        self.query_latency.reset();
359    }
360}
361
362/// The metric families this module exports, each with its Prometheus type.
363///
364/// Declared once here so the `# HELP`/`# TYPE` preamble and the sample lines
365/// cannot drift apart: [`to_prometheus`] walks this list, and every entry it
366/// names is emitted by [`GraphMetrics::append_samples`].
367const COUNTER_FAMILIES: [(&str, &str, &str); 5] = [
368    (
369        "velesdb_graph_edges_total",
370        "gauge",
371        "Current number of edges",
372    ),
373    (
374        "velesdb_graph_edge_inserts_total",
375        "counter",
376        "Total edge insertions",
377    ),
378    (
379        "velesdb_graph_edge_deletes_total",
380        "counter",
381        "Total edge deletions",
382    ),
383    (
384        "velesdb_graph_traversals_total",
385        "counter",
386        "Total traversals executed",
387    ),
388    (
389        "velesdb_graph_traversal_nodes_visited_total",
390        "counter",
391        "Total nodes visited across traversals",
392    ),
393];
394
395/// Latency histogram families, as (metric infix, accessor).
396const HISTOGRAM_FAMILIES: [&str; 2] = ["edge_insert", "traversal"];
397
398/// Renders the graph metrics of several collections as one Prometheus
399/// exposition.
400///
401/// A `GraphMetrics` lives on an edge store, so there is one per collection,
402/// while the metric names are shared across all of them. Concatenating a
403/// per-collection block would therefore repeat `# HELP` and `# TYPE` for the
404/// same family and publish several samples under an identical — empty — label
405/// set. Prometheus rejects a duplicated family declaration and, for the
406/// duplicated series, keeps whichever it saw last: the exposition would be
407/// invalid and silently lossy at once.
408///
409/// Each family is instead declared once, and every sample carries the
410/// collection it came from. Collection names are `[A-Za-z0-9_-]` only
411/// (`validation::is_valid_name_char`), so no label value can contain a quote,
412/// a backslash or a newline and none needs escaping — pinned by
413/// `a_collection_name_can_never_need_label_escaping`.
414///
415/// Returns an empty string for an empty input rather than a preamble
416/// describing families with no samples.
417#[must_use]
418pub fn to_prometheus(per_collection: &[(&str, &GraphMetrics)]) -> String {
419    if per_collection.is_empty() {
420        return String::new();
421    }
422
423    let mut output = String::with_capacity(1024 * per_collection.len());
424
425    // Preamble: every family declared exactly once, counters then histograms.
426    for (name, kind, help) in COUNTER_FAMILIES {
427        let _ = writeln!(output, "# HELP {name} {help}");
428        let _ = writeln!(output, "# TYPE {name} {kind}");
429    }
430    for infix in HISTOGRAM_FAMILIES {
431        let _ = writeln!(
432            output,
433            "# HELP velesdb_graph_{infix}_duration_seconds {} latency histogram",
434            infix.replace('_', " ")
435        );
436        let _ = writeln!(
437            output,
438            "# TYPE velesdb_graph_{infix}_duration_seconds histogram"
439        );
440    }
441
442    // Samples: one labelled block per collection.
443    for (collection, metrics) in per_collection {
444        metrics.append_samples(&mut output, collection);
445        metrics.append_histogram_samples(&mut output, collection);
446    }
447
448    output
449}