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}