Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-ci-metrics/src/cursor.rs
atlas b072fbf4d6 push ci run metrics to victoriametrics, with a grafana board
A swarm-ci-metrics host unit on a 5-minute timer lists every forge
repo's Actions runs as the new read-only ci-metrics account and pushes
each finished run's duration and queue wait over OTLP, stamped at the
run's stop time. The first pass backfills from run 1. swarm-controller
mints the account's token into bao and keeps a ci-metrics team with
read on the Actions unit in every organisation.

Closes #5376
2026-10-16 20:43:11 +02:00

157 lines
5.5 KiB
Rust

//! Which runs of each repo are already pushed, kept in one file in the unit's
//! state directory.
//!
//! Per repo, a run number `low` below which every run is handled, and the
//! handled run numbers at or above it. A run is handled once it is pushed or
//! skipped. A run still going holds `low` at or below it, so a run that
//! finishes after a later one is still pushed.
//!
//! The file is replaced whole after every push the collector accepted, and
//! never before. A crash between that push and the write re-pushes those runs
//! on the next pass; nothing is pushed only to be forgotten, and nothing is
//! marked handled before it is pushed. A missing file is a cursor at run 0,
//! so the first pass is the backfill.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write as _;
use std::path::Path;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
/// One repo's cursor.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoCursor {
/// Every run numbered below this is handled.
pub low: u64,
/// Handled runs numbered `low` or above.
pub handled: BTreeSet<u64>,
}
impl RepoCursor {
/// Whether the walk down the newest-first run list has passed every run
/// it still has to look at.
pub fn is_below(&self, run: u64) -> bool {
run < self.low
}
pub fn is_handled(&self, run: u64) -> bool {
self.is_below(run) || self.handled.contains(&run)
}
pub fn mark(&mut self, runs: impl IntoIterator<Item = u64>) {
self.handled.extend(runs);
}
/// Move `low` up once the pass has handled what it could: to the lowest
/// run still going, or past the newest run seen when none is. Never down.
pub fn advance(&mut self, lowest_pending: Option<u64>, newest_seen: Option<u64>) {
let to = match (lowest_pending, newest_seen) {
(Some(pending), _) => pending,
(None, Some(newest)) => newest + 1,
(None, None) => return,
};
self.low = self.low.max(to);
let low = self.low;
self.handled.retain(|&run| run >= low);
}
}
/// Every repo's cursor, keyed by `owner/name`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct State {
pub repos: BTreeMap<String, RepoCursor>,
}
impl State {
/// The state at `path`, or an empty one when there is no file.
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("parsing the cursor at {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading the cursor at {}", path.display())),
}
}
/// Replace the file at `path`: a reader sees the old state or the new one,
/// never a mix.
pub fn save(&self, path: &Path) -> Result<()> {
let dir = path.parent().context("the cursor path has no directory")?;
let tmp = path.with_extension("tmp");
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
file.write_all(&serde_json::to_vec(self)?)?;
file.sync_all()?;
std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
std::fs::File::open(dir)?.sync_all()?;
Ok(())
}
pub fn repo(&mut self, repo: &str) -> &mut RepoCursor {
self.repos.entry(repo.to_owned()).or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_is_a_cursor_at_run_zero() {
let dir = tempfile::tempdir().expect("a scratch dir");
let mut state = State::load(&dir.path().join("cursor.json")).expect("no file is fine");
let c = state.repo("o/r");
assert_eq!(c.low, 0);
assert!(!c.is_handled(1));
}
#[test]
fn a_run_still_going_holds_low_and_one_finished_above_it_stays_handled() {
let mut c = RepoCursor::default();
// 7 is running; 8 and 9 finished first.
c.mark([5, 6, 8, 9]);
c.advance(Some(7), Some(9));
assert_eq!(c.low, 7);
assert!(c.is_handled(6));
assert!(!c.is_handled(7));
assert!(c.is_handled(8));
// 7 finishes.
c.mark([7]);
c.advance(None, Some(9));
assert_eq!(c.low, 10);
assert!(c.handled.is_empty());
}
#[test]
fn low_never_moves_down() {
let mut c = RepoCursor {
low: 10,
handled: BTreeSet::new(),
};
c.advance(Some(4), Some(12));
assert_eq!(c.low, 10);
c.advance(None, None);
assert_eq!(c.low, 10);
}
#[test]
fn a_saved_state_reads_back_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().expect("a scratch dir");
let path = dir.path().join("cursor.json");
let mut state = State::default();
state.repo("o/r").mark([3, 4]);
state.repo("o/r").advance(Some(3), Some(4));
state.save(&path).expect("save");
assert_eq!(State::load(&path).expect("load"), state);
assert!(!path.with_extension("tmp").exists());
}
#[test]
fn a_corrupt_file_is_an_error_not_a_fresh_backfill() {
let dir = tempfile::tempdir().expect("a scratch dir");
let path = dir.path().join("cursor.json");
std::fs::write(&path, b"{").expect("write");
assert!(State::load(&path).is_err());
}
}