#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#[cfg(test)]
mod tests;
use std::fmt::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
const BUCKET_BOUNDS_MS: [u64; 9] = [1, 5, 10, 50, 100, 500, 1000, 5000, 10000];
#[derive(Debug, Default)]
pub struct LatencyHistogram {
buckets: [AtomicU64; 10],
sum_ns: AtomicU64,
count: AtomicU64,
}
impl LatencyHistogram {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn observe(&self, duration: Duration) {
let ns_u128 = duration.as_nanos();
let ns = if ns_u128 > u128::from(u64::MAX) {
u64::MAX
} else {
ns_u128 as u64
};
self.sum_ns.fetch_add(ns, Ordering::Relaxed);
self.count.fetch_add(1, Ordering::Relaxed);
let ms_u128 = duration.as_millis();
let ms = if ms_u128 > u128::from(u64::MAX) {
u64::MAX
} else {
ms_u128 as u64
};
let bucket_idx = BUCKET_BOUNDS_MS
.iter()
.position(|&bound| ms <= bound)
.unwrap_or(BUCKET_BOUNDS_MS.len());
self.buckets[bucket_idx].fetch_add(1, Ordering::Relaxed);
}
#[must_use]
pub fn count(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
#[must_use]
pub fn sum_ns(&self) -> u64 {
self.sum_ns.load(Ordering::Relaxed)
}
#[must_use]
pub fn avg_ns(&self) -> f64 {
let count = self.count();
if count == 0 {
0.0
} else {
self.sum_ns() as f64 / count as f64
}
}
#[must_use]
pub fn bucket_counts(&self) -> [u64; 10] {
let mut counts = [0u64; 10];
for (i, bucket) in self.buckets.iter().enumerate() {
counts[i] = bucket.load(Ordering::Relaxed);
}
counts
}
pub fn reset(&self) {
self.sum_ns.store(0, Ordering::Relaxed);
self.count.store(0, Ordering::Relaxed);
for bucket in &self.buckets {
bucket.store(0, Ordering::Relaxed);
}
}
}
#[derive(Debug, Default)]
pub struct GraphMetrics {
edges_total: AtomicU64,
edge_inserts_total: AtomicU64,
edge_deletes_total: AtomicU64,
traversals_total: AtomicU64,
traversal_nodes_visited: AtomicU64,
pub edge_insert_latency: LatencyHistogram,
pub traversal_latency: LatencyHistogram,
pub query_latency: LatencyHistogram,
}
impl GraphMetrics {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_edge_insert(&self) {
self.edge_inserts_total.fetch_add(1, Ordering::Relaxed);
self.edges_total.fetch_add(1, Ordering::Relaxed);
}
pub fn record_edge_inserts_batch(&self, count: u64, latency: Duration) {
if count == 0 {
return;
}
self.edge_inserts_total.fetch_add(count, Ordering::Relaxed);
self.edges_total.fetch_add(count, Ordering::Relaxed);
self.edge_insert_latency.observe(latency);
}
pub fn record_edge_delete(&self) {
self.edge_deletes_total.fetch_add(1, Ordering::Relaxed);
self.edges_total
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |x| {
Some(x.saturating_sub(1))
})
.ok();
}
#[must_use]
pub fn edges_total(&self) -> u64 {
self.edges_total.load(Ordering::Relaxed)
}
#[must_use]
pub fn edge_inserts_total(&self) -> u64 {
self.edge_inserts_total.load(Ordering::Relaxed)
}
#[must_use]
pub fn edge_deletes_total(&self) -> u64 {
self.edge_deletes_total.load(Ordering::Relaxed)
}
pub fn record_traversal(&self, latency: Duration, nodes_visited: u64) {
self.traversals_total.fetch_add(1, Ordering::Relaxed);
self.traversal_nodes_visited
.fetch_add(nodes_visited, Ordering::Relaxed);
self.traversal_latency.observe(latency);
}
#[must_use]
pub fn traversals_total(&self) -> u64 {
self.traversals_total.load(Ordering::Relaxed)
}
#[must_use]
pub fn traversal_nodes_visited(&self) -> u64 {
self.traversal_nodes_visited.load(Ordering::Relaxed)
}
pub fn record_query(&self, latency: Duration) {
self.query_latency.observe(latency);
}
fn append_samples(&self, output: &mut String, collection: &str) {
let _ = writeln!(
output,
"velesdb_graph_edges_total{{collection=\"{collection}\"}} {}",
self.edges_total()
);
let _ = writeln!(
output,
"velesdb_graph_edge_inserts_total{{collection=\"{collection}\"}} {}",
self.edge_inserts_total()
);
let _ = writeln!(
output,
"velesdb_graph_edge_deletes_total{{collection=\"{collection}\"}} {}",
self.edge_deletes_total()
);
let _ = writeln!(
output,
"velesdb_graph_traversals_total{{collection=\"{collection}\"}} {}",
self.traversals_total()
);
let _ = writeln!(
output,
"velesdb_graph_traversal_nodes_visited_total{{collection=\"{collection}\"}} {}",
self.traversal_nodes_visited()
);
}
fn append_histogram_samples(&self, output: &mut String, collection: &str) {
let histograms = [&self.edge_insert_latency, &self.traversal_latency];
for (infix, histogram) in HISTOGRAM_FAMILIES.into_iter().zip(histograms) {
let counts = histogram.bucket_counts();
let mut cumulative = 0u64;
for (i, &bound_ms) in BUCKET_BOUNDS_MS.iter().enumerate() {
cumulative += counts[i];
#[allow(clippy::cast_precision_loss)]
let bound = bound_ms as f64 / 1000.0;
let _ = writeln!(
output,
"velesdb_graph_{infix}_duration_seconds_bucket{{collection=\"{collection}\",le=\"{bound}\"}} {cumulative}"
);
}
cumulative += counts[BUCKET_BOUNDS_MS.len()];
let _ = writeln!(
output,
"velesdb_graph_{infix}_duration_seconds_bucket{{collection=\"{collection}\",le=\"+Inf\"}} {cumulative}"
);
#[allow(clippy::cast_precision_loss)]
let sum_seconds = histogram.sum_ns() as f64 / 1_000_000_000.0;
let _ = writeln!(
output,
"velesdb_graph_{infix}_duration_seconds_sum{{collection=\"{collection}\"}} {sum_seconds}"
);
let _ = writeln!(
output,
"velesdb_graph_{infix}_duration_seconds_count{{collection=\"{collection}\"}} {}",
histogram.count()
);
}
}
pub fn reset(&self) {
self.edges_total.store(0, Ordering::Relaxed);
self.edge_inserts_total.store(0, Ordering::Relaxed);
self.edge_deletes_total.store(0, Ordering::Relaxed);
self.traversals_total.store(0, Ordering::Relaxed);
self.traversal_nodes_visited.store(0, Ordering::Relaxed);
self.edge_insert_latency.reset();
self.traversal_latency.reset();
self.query_latency.reset();
}
}
const COUNTER_FAMILIES: [(&str, &str, &str); 5] = [
(
"velesdb_graph_edges_total",
"gauge",
"Current number of edges",
),
(
"velesdb_graph_edge_inserts_total",
"counter",
"Total edge insertions",
),
(
"velesdb_graph_edge_deletes_total",
"counter",
"Total edge deletions",
),
(
"velesdb_graph_traversals_total",
"counter",
"Total traversals executed",
),
(
"velesdb_graph_traversal_nodes_visited_total",
"counter",
"Total nodes visited across traversals",
),
];
const HISTOGRAM_FAMILIES: [&str; 2] = ["edge_insert", "traversal"];
#[must_use]
pub fn to_prometheus(per_collection: &[(&str, &GraphMetrics)]) -> String {
if per_collection.is_empty() {
return String::new();
}
let mut output = String::with_capacity(1024 * per_collection.len());
for (name, kind, help) in COUNTER_FAMILIES {
let _ = writeln!(output, "# HELP {name} {help}");
let _ = writeln!(output, "# TYPE {name} {kind}");
}
for infix in HISTOGRAM_FAMILIES {
let _ = writeln!(
output,
"# HELP velesdb_graph_{infix}_duration_seconds {} latency histogram",
infix.replace('_', " ")
);
let _ = writeln!(
output,
"# TYPE velesdb_graph_{infix}_duration_seconds histogram"
);
}
for (collection, metrics) in per_collection {
metrics.append_samples(&mut output, collection);
metrics.append_histogram_samples(&mut output, collection);
}
output
}