use std::time::Duration;
use crate::RoundReport;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Metrics {
pub rounds_total: u64,
pub rounds_failed: u64,
pub tables_synced: u64,
pub rows_ingested: u64,
pub vertices_written: u64,
pub edges_written: u64,
pub deletes: u64,
pub assets_emitted: u64,
}
impl Metrics {
pub fn record_round(&mut self, report: &RoundReport) -> bool {
self.rounds_total += 1;
let failed = report.is_failure();
if failed {
self.rounds_failed += 1;
}
for (_table, rows, sink) in &report.synced {
self.tables_synced += 1;
self.rows_ingested += *rows as u64;
self.vertices_written += sink.vertices_written as u64;
self.edges_written += sink.edges_written as u64;
self.deletes += sink.deleted as u64;
}
self.assets_emitted += report.synced.len() as u64;
failed
}
}
pub fn render_prometheus(m: &Metrics) -> String {
fn counter(out: &mut String, name: &str, help: &str, value: u64) {
use std::fmt::Write;
let _ = writeln!(out, "# HELP bifrost_{name} {help}");
let _ = writeln!(out, "# TYPE bifrost_{name} counter");
let _ = writeln!(out, "bifrost_{name} {value}");
}
let mut out = String::new();
counter(
&mut out,
"rounds_total",
"Sync rounds attempted.",
m.rounds_total,
);
counter(
&mut out,
"rounds_failed",
"Sync rounds that made no progress.",
m.rounds_failed,
);
counter(
&mut out,
"tables_synced",
"Per-table successful syncs.",
m.tables_synced,
);
counter(
&mut out,
"rows_ingested",
"Source rows ingested.",
m.rows_ingested,
);
counter(
&mut out,
"vertices_written",
"Vertices MERGE'd into the graph.",
m.vertices_written,
);
counter(
&mut out,
"edges_written",
"Edges MERGE'd into the graph.",
m.edges_written,
);
counter(
&mut out,
"deletes",
"Nodes DETACH DELETE'd (CDC).",
m.deletes,
);
counter(
&mut out,
"assets_emitted",
"AIP-82 asset events emitted.",
m.assets_emitted,
);
out
}
#[derive(Debug, Clone)]
pub struct Backoff {
interval: Duration,
max: Duration,
current: Duration,
}
impl Backoff {
pub fn new(interval: Duration, max: Duration) -> Self {
let max = max.max(interval);
Self {
interval,
max,
current: interval,
}
}
pub fn interval(&self) -> Duration {
self.interval
}
pub fn max(&self) -> Duration {
self.max
}
pub fn next_sleep(&mut self, failed: bool) -> Duration {
if !failed {
self.current = self.interval;
return self.interval;
}
let sleep = self.current;
let doubled = self.current.saturating_mul(2);
self.current = doubled.min(self.max).max(self.interval);
sleep
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mutation::SinkReport;
fn synced_report() -> RoundReport {
RoundReport {
synced: vec![
(
"a".into(),
10,
SinkReport {
vertices_written: 10,
edges_written: 4,
deleted: 1,
},
),
(
"b".into(),
5,
SinkReport {
vertices_written: 5,
edges_written: 0,
deleted: 0,
},
),
],
failed: vec![],
}
}
fn failed_report() -> RoundReport {
RoundReport {
synced: vec![],
failed: vec![("a".into(), "boom".into())],
}
}
fn idle_report() -> RoundReport {
RoundReport::default()
}
#[test]
fn record_round_accumulates_every_counter() {
let mut m = Metrics::default();
assert!(
!m.record_round(&synced_report()),
"a fully-synced round is not a failure"
);
assert_eq!(m.rounds_total, 1);
assert_eq!(m.rounds_failed, 0);
assert_eq!(m.tables_synced, 2);
assert_eq!(m.rows_ingested, 15);
assert_eq!(m.vertices_written, 15);
assert_eq!(m.edges_written, 4);
assert_eq!(m.deletes, 1);
assert_eq!(m.assets_emitted, 2);
m.record_round(&synced_report());
assert_eq!(m.rounds_total, 2);
assert_eq!(m.tables_synced, 4);
assert_eq!(m.rows_ingested, 30);
assert_eq!(m.assets_emitted, 4);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::record_round_accumulates_every_counter",
m.rounds_total == 2
&& m.tables_synced == 4
&& m.rows_ingested == 30
&& m.assets_emitted == 4,
&format!(
"after 2 rounds: tables={}, rows={}, assets={}",
m.tables_synced, m.rows_ingested, m.assets_emitted
),
);
}
#[test]
fn failed_round_increments_rounds_failed_only() {
let mut m = Metrics::default();
assert!(
m.record_round(&failed_report()),
"an all-failed round is a failure"
);
assert_eq!(m.rounds_total, 1);
assert_eq!(m.rounds_failed, 1);
assert_eq!(m.tables_synced, 0);
assert_eq!(m.assets_emitted, 0);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::failed_round_increments_rounds_failed_only",
m.rounds_failed == 1 && m.tables_synced == 0,
&format!(
"rounds_failed={}, tables_synced={}",
m.rounds_failed, m.tables_synced
),
);
}
#[test]
fn idle_round_counts_but_is_not_a_failure() {
let mut m = Metrics::default();
let is_failure = m.record_round(&idle_report());
assert!(!is_failure);
assert_eq!(m.rounds_total, 1);
assert_eq!(m.rounds_failed, 0);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::idle_round_counts_but_is_not_a_failure",
!is_failure && m.rounds_total == 1 && m.rounds_failed == 0,
"idle round bumps rounds_total, not rounds_failed",
);
}
#[test]
fn partial_failure_is_progress_not_a_failure() {
let mut m = Metrics::default();
let report = RoundReport {
synced: vec![(
"a".into(),
3,
SinkReport {
vertices_written: 3,
..Default::default()
},
)],
failed: vec![("b".into(), "boom".into())],
};
let is_failure = m.record_round(&report);
assert!(!is_failure);
assert_eq!(m.rounds_failed, 0);
assert_eq!(m.tables_synced, 1);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::partial_failure_is_progress_not_a_failure",
!is_failure && m.rounds_failed == 0 && m.tables_synced == 1,
"1 synced + 1 failed = progress, not a backoff failure",
);
}
#[test]
fn render_prometheus_emits_typed_counters() {
let mut m = Metrics::default();
m.record_round(&synced_report());
let text = render_prometheus(&m);
assert!(text.contains("# TYPE bifrost_rows_ingested counter"));
assert!(text.contains("# HELP bifrost_rounds_total Sync rounds attempted."));
assert!(text.contains("bifrost_rows_ingested 15"));
assert!(text.contains("bifrost_vertices_written 15"));
assert!(text.contains("bifrost_edges_written 4"));
assert!(text.contains("bifrost_deletes 1"));
assert!(text.contains("bifrost_assets_emitted 2"));
assert!(text.contains("bifrost_rounds_total 1"));
assert!(text.ends_with('\n'));
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::render_prometheus_emits_typed_counters",
text.contains("bifrost_rows_ingested 15")
&& text.contains("# TYPE bifrost_rows_ingested counter")
&& text.ends_with('\n'),
"valid prometheus exposition with typed counters",
);
}
#[test]
fn backoff_doubles_on_consecutive_failures_up_to_cap() {
let base = Duration::from_secs(1);
let mut b = Backoff::new(base, Duration::from_secs(10));
let seq: Vec<u64> = (0..6).map(|_| b.next_sleep(true).as_secs()).collect();
assert_eq!(seq, vec![1, 2, 4, 8, 10, 10]);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::backoff_doubles_on_consecutive_failures_up_to_cap",
seq == vec![1, 2, 4, 8, 10, 10],
&format!("backoff secs={seq:?}"),
);
}
#[test]
fn backoff_resets_to_base_on_success() {
let base = Duration::from_secs(1);
let mut b = Backoff::new(base, Duration::from_secs(16));
assert_eq!(b.next_sleep(true).as_secs(), 1);
assert_eq!(b.next_sleep(true).as_secs(), 2);
assert_eq!(b.next_sleep(true).as_secs(), 4);
assert_eq!(b.next_sleep(false).as_secs(), 1);
let after_reset_1 = b.next_sleep(true).as_secs();
let after_reset_2 = b.next_sleep(true).as_secs();
assert_eq!(after_reset_1, 1);
assert_eq!(after_reset_2, 2);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::backoff_resets_to_base_on_success",
after_reset_1 == 1 && after_reset_2 == 2,
"success resets backoff to base, then climbs again",
);
}
#[test]
fn backoff_healthy_path_keeps_cadence() {
let base = Duration::from_secs(5);
let mut b = Backoff::new(base, Duration::from_secs(50));
let mut steady = true;
for _ in 0..10 {
steady &= b.next_sleep(false) == base;
}
assert!(steady);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::backoff_healthy_path_keeps_cadence",
steady,
"10 healthy rounds all sleep the base interval",
);
}
#[test]
fn backoff_clamps_cap_below_interval_up_to_interval() {
let base = Duration::from_secs(30);
let mut b = Backoff::new(base, Duration::from_secs(5));
assert_eq!(b.max(), base);
assert_eq!(b.next_sleep(true), base);
assert_eq!(b.next_sleep(true), base);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics::backoff_clamps_cap_below_interval_up_to_interval",
b.max() == base,
&format!("cap clamped up to base interval {}s", base.as_secs()),
);
}
}