//! One repo's pass: which of the runs read from the forge to push, and the //! order of pushes and cursor writes that keeps a crash from losing a run. use std::collections::BTreeMap; use anyhow::Result; use forgejo_api::structs::ActionRun; use time::OffsetDateTime; use crate::cursor::State; use crate::sample::{RunSamples, Skip, Verdict, classify}; /// Runs per OTLP request: two points each, well under any body limit on the /// way to the collector. pub const CHUNK: usize = 100; /// What one pass does with a repo's runs. #[derive(Debug, Default)] pub struct Plan { /// Finished runs not yet pushed, oldest first, by run number. pub push: Vec<(u64, RunSamples)>, /// Finished runs that become no sample, by run number. pub skip: Vec<(u64, Skip)>, /// The lowest run number still going. pub lowest_pending: Option, /// The highest run number read. pub newest_seen: Option, } /// Sort `runs` of `repo` against its cursor in `state`. A run listed twice /// (the list shifts while it is paged) counts once; a run with no number is /// left out. pub fn plan(state: &State, repo: &str, runs: &[ActionRun], now: OffsetDateTime) -> Plan { let cursor = state.repos.get(repo).cloned().unwrap_or_default(); let by_number: BTreeMap = runs .iter() .filter_map(|r| { let n = u64::try_from(r.index_in_repo?).ok()?; Some((n, r)) }) .collect(); let mut plan = Plan { newest_seen: by_number.keys().next_back().copied(), ..Plan::default() }; for (&n, run) in &by_number { if cursor.is_handled(n) { continue; } match classify(repo, run, now) { Verdict::Push(s) => plan.push.push((n, *s)), Verdict::Skip(why) => plan.skip.push((n, why)), Verdict::Pending => { plan.lowest_pending.get_or_insert(n); } } } plan } /// Push `plan` for `repo` in chunks of [`CHUNK`] runs, marking each chunk in /// `state` and saving it only after `push` accepted that chunk; then move the /// cursor and save once more. The skips are marked with the first save. /// /// A failed push returns before anything it carried is marked. A crash after /// a push and before its save leaves those runs unmarked, so they go again. pub async fn apply( state: &mut State, repo: &str, plan: Plan, mut push: impl AsyncFnMut(&[RunSamples]) -> Result<()>, mut save: impl FnMut(&State) -> Result<()>, ) -> Result<()> { state.repo(repo).mark(plan.skip.iter().map(|(n, _)| *n)); for chunk in plan.push.chunks(CHUNK) { let samples: Vec = chunk.iter().map(|(_, s)| s.clone()).collect(); push(&samples).await?; state.repo(repo).mark(chunk.iter().map(|(n, _)| *n)); save(state)?; } state .repo(repo) .advance(plan.lowest_pending, plan.newest_seen); save(state) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn now() -> OffsetDateTime { OffsetDateTime::parse( "2026-10-16T18:00:00+02:00", &time::format_description::well_known::Rfc3339, ) .expect("a timestamp") } fn run(n: i64, status: &str) -> ActionRun { let mut v = json!({ "index_in_repo": n, "status": status, "event": "pull_request", "prettyref": format!("#{n}"), "workflow_id": "ci.yml", "commit_sha": format!("{n:040}"), "created": "2026-10-16T16:50:38+02:00", "started": "2026-10-16T16:55:17+02:00", "stopped": "2026-10-16T17:13:30+02:00", }); let obj = v.as_object_mut().expect("an object"); for key in ["updated", "html_url"] { obj.insert(key.to_owned(), serde_json::Value::Null); } serde_json::from_value(v).expect("an ActionRun") } /// `count` finished runs numbered from 1, newest first as the forge lists. fn history(count: i64) -> Vec { (1..=count).rev().map(|n| run(n, "success")).collect() } fn pushed_numbers(samples: &[RunSamples]) -> Vec { samples.iter().map(|s| s.labels.pr.clone()).collect() } #[tokio::test] async fn the_first_pass_pushes_every_finished_run_and_the_next_pushes_none() { let mut state = State::default(); let runs = history(250); let p = plan(&state, "o/r", &runs, now()); assert_eq!(p.push.len(), 250); let mut requests = 0; apply( &mut state, "o/r", p, async |_| { requests += 1; Ok(()) }, |_| Ok(()), ) .await .expect("apply"); assert_eq!(requests, 3); assert_eq!(state.repos["o/r"].low, 251); let again = plan(&state, "o/r", &runs, now()); assert!(again.push.is_empty() && again.skip.is_empty()); } #[tokio::test] async fn a_failed_push_marks_nothing_it_carried_and_the_next_pass_resumes() { let mut state = State::default(); let runs = history(250); let mut saved = None; let mut calls = 0; let p = plan(&state, "o/r", &runs, now()); let err = apply( &mut state, "o/r", p, async |_| { calls += 1; if calls == 2 { anyhow::bail!("the collector answered 503"); } Ok(()) }, |s| { saved = Some(s.clone()); Ok(()) }, ) .await; assert!(err.is_err()); // What survives the crash is the last save: the first chunk. let state = saved.expect("the first chunk was saved"); assert_eq!(state.repos["o/r"].handled.len(), CHUNK); assert_eq!(state.repos["o/r"].low, 0); let resumed = plan(&state, "o/r", &runs, now()); let numbers: Vec = resumed.push.iter().map(|(n, _)| *n).collect(); assert_eq!(numbers, (101..=250).collect::>()); } #[tokio::test] async fn a_crash_between_a_push_and_its_save_pushes_those_runs_again() { let mut state = State::default(); let runs = history(3); let mut first = Vec::new(); let p = plan(&state, "o/r", &runs, now()); let err = apply( &mut state, "o/r", p, async |s| { first.extend(pushed_numbers(s)); Ok(()) }, |_| Err(anyhow::anyhow!("disk full")), ) .await; assert!(err.is_err()); // The file on disk is still the empty state. let on_disk = State::default(); let again = plan(&on_disk, "o/r", &runs, now()); let second: Vec = again .push .iter() .map(|(_, s)| s.labels.pr.clone()) .collect(); assert_eq!(first, second); } #[tokio::test] async fn a_run_finishing_after_a_newer_one_is_still_pushed() { let mut state = State::default(); let mut runs = vec![run(3, "success"), run(2, "running"), run(1, "success")]; let mut pushed = Vec::new(); let p = plan(&state, "o/r", &runs, now()); apply( &mut state, "o/r", p, async |s| { pushed.extend(pushed_numbers(s)); Ok(()) }, |_| Ok(()), ) .await .expect("apply"); assert_eq!(pushed, ["1", "3"]); assert_eq!(state.repos["o/r"].low, 2); runs[1] = run(2, "failure"); let next = plan(&state, "o/r", &runs, now()); let numbers: Vec = next.push.iter().map(|(n, _)| *n).collect(); assert_eq!(numbers, [2]); } #[test] fn skips_are_counted_not_pushed_and_a_relisted_run_counts_once() { let state = State::default(); let runs = vec![ run(3, "cancelled"), run(2, "success"), run(2, "success"), run(1, "skipped"), ]; let p = plan(&state, "o/r", &runs, now()); assert_eq!(p.push.len(), 1); assert_eq!(p.skip, [(1, Skip::NotRun), (3, Skip::Cancelled)]); assert_eq!(p.newest_seen, Some(3)); } }