//! The `swarm.deploys.held` gauge: how many agents have a deploy held back by //! the build gate, exported on the jobq exporter. Grafana alerts when it stays //! above zero (`hh-deploys-held`, `nix/host-modules/swarm-grafana.nix`). //! //! An agent counts when its latest `BuildMeta` node is `Failed`, once per agent //! under one of two `reason` labels: //! - `failed`: `nix` failed, its timeout included. //! - `unavailable`: the build got no verdict (no builder, a forge error, no //! `internal/meta` head, a missing read grant). //! //! The ten-minute timer builds either again. A pending, running or finished //! build does not count, so the value falls to zero when a build succeeds. use std::sync::{Arc, Mutex}; use hive_jobq::State; use opentelemetry::KeyValue; use opentelemetry::metrics::MeterProvider as _; use opentelemetry_sdk::metrics::SdkMeterProvider; use crate::jobq::{SwarmNodeKind, SwarmResourceKind}; use crate::meta_build; /// The gauge's name. The Grafana rule selects it by this name. pub(crate) const METRIC: &str = "swarm.deploys.held"; /// Held agents as `(failed, unavailable)`. fn count(graph: &hive_jobq::Graph) -> (u64, u64) { let mut latest = std::collections::BTreeMap::new(); for n in graph.nodes() { if let SwarmNodeKind::BuildMeta { agent, .. } = &n.payload && latest .get(agent) .is_none_or(|l: &&hive_jobq::Node<_, _>| l.id < n.id) { latest.insert(agent, n); } } latest .into_values() .filter(|n| n.state == State::Failed) .fold((0, 0), |(failed, unavailable), n| { if n.error.as_deref().is_some_and(meta_build::is_no_verdict) { (failed, unavailable + 1) } else { (failed + 1, unavailable) } }) } /// Register the gauge on `provider`, reading `jobq` at each export. Nothing when /// no exporter is configured. pub(crate) fn register( provider: Option<&SdkMeterProvider>, jobq: &Arc>>, ) { let Some(provider) = provider else { return; }; let jobq = Arc::clone(jobq); provider .meter("hyperhive.deploy_gate") .u64_observable_gauge(METRIC) .with_description("agents whose deploy the build gate is holding back, by reason") .with_callback(move |obs| { let (failed, unavailable) = count( jobq.lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .graph(), ); obs.observe(failed, &[KeyValue::new("reason", "failed")]); obs.observe(unavailable, &[KeyValue::new("reason", "unavailable")]); }) .build(); } #[cfg(test)] mod tests { use hive_jobq::scheduler::{Outcome, Scheduler}; use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData, ResourceMetrics}; use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader}; use super::*; type Jobq = Arc>>; /// A scheduler where each of `builds` (agent, outcome) ran once, in order. async fn after(builds: &[(&str, Outcome)]) -> Jobq { let jobq: Jobq = Arc::new(Mutex::new(Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))); for (agent, outcome) in builds { jobq.lock() .unwrap() .append( SwarmNodeKind::BuildMeta { agent: (*agent).to_owned(), pr: None, }, Vec::new(), None, ) .expect("insert"); let outcome = outcome.clone(); Scheduler::claim_next(&jobq, move |_, _, b| async move { (b, outcome) }) .expect("runnable") .await .1 .expect("no growth"); } jobq } fn failed() -> Outcome { Outcome::Failed("error: attribute missing".to_owned()) } fn unavailable() -> Outcome { Outcome::Failed(format!("{}no builder", meta_build::NO_VERDICT_PREFIX)) } #[tokio::test] async fn a_failed_latest_build_holds_its_agent_by_reason() { let jobq = after(&[ ("atlas", failed()), ("argus", unavailable()), ("iris", Outcome::Done), ]) .await; assert_eq!(count(jobq.lock().unwrap().graph()), (1, 1)); } /// Only an agent's latest build counts: a green build after a red one /// releases it, and a red one after a green one holds it. #[tokio::test] async fn only_the_latest_build_of_an_agent_counts() { let released = after(&[("atlas", failed()), ("atlas", Outcome::Done)]).await; assert_eq!(count(released.lock().unwrap().graph()), (0, 0)); let held = after(&[("atlas", Outcome::Done), ("atlas", failed())]).await; assert_eq!(count(held.lock().unwrap().graph()), (1, 0)); } /// The gauge reads the graph at export time and labels both reasons, zero /// included, so a rule sees the series before anything is held. #[tokio::test] async fn the_gauge_exports_both_reasons() { let jobq = after(&[("atlas", failed())]).await; let exporter = InMemoryMetricExporter::default(); let reader = PeriodicReader::builder(exporter.clone()).build(); let provider = SdkMeterProvider::builder().with_reader(reader).build(); register(Some(&provider), &jobq); provider.force_flush().expect("flush"); let all = exporter.get_finished_metrics().expect("metrics"); let mut points = Vec::new(); for m in all .last() .into_iter() .flat_map(ResourceMetrics::scope_metrics) .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) .filter(|m| m.name() == METRIC) { let AggregatedMetrics::U64(MetricData::Gauge(g)) = m.data() else { panic!("not a u64 gauge: {:?}", m.data()); }; for p in g.data_points() { let reason = p .attributes() .find(|kv| kv.key.as_str() == "reason") .map(|kv| kv.value.to_string()) .expect("a reason"); points.push((reason, p.value())); } } points.sort(); assert_eq!( points, [("failed".to_owned(), 1), ("unavailable".to_owned(), 0)] ); } }