use std::collections::BTreeMap;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::funnel::{DagStatus, EdgeKind, PlanDag, PlanDagEdge, PlanDagNode};
use crate::jobs::{kind, status, JobRecord, JobStore};
use nornir_build_thing::build_info::BuildInfoRow;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineStep {
pub id: String,
pub name: String,
#[serde(default)]
pub needs: Vec<String>,
#[serde(default)]
pub kind: String,
}
impl PipelineStep {
pub fn new(id: &str, name: &str, needs: &[&str]) -> Self {
PipelineStep {
id: id.to_string(),
name: name.to_string(),
needs: needs.iter().map(|s| s.to_string()).collect(),
kind: String::new(),
}
}
fn job_kind(&self) -> &str {
if self.kind.is_empty() { kind::CI_BUILD } else { &self.kind }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineSpec {
pub name: String,
pub steps: Vec<PipelineStep>,
}
impl PipelineSpec {
#[must_use]
pub fn to_plan_dag(&self) -> PlanDag {
let mut needed_by: BTreeMap<&str, u64> = BTreeMap::new();
for s in &self.steps {
for n in &s.needs {
*needed_by.entry(n.as_str()).or_default() += 1;
}
}
let nodes: Vec<PlanDagNode> = self
.steps
.iter()
.map(|s| PlanDagNode {
id: s.id.clone(),
title: s.name.clone(),
group: Some(self.name.clone()),
status: DagStatus::Idle,
is_bug: false,
staleness_hours: 0.0,
weight_bytes: 0,
fanout: needed_by.get(s.id.as_str()).copied().unwrap_or(0),
})
.collect();
let mut edges: Vec<PlanDagEdge> = Vec::new();
for s in &self.steps {
for n in &s.needs {
edges.push(PlanDagEdge { from: n.clone(), to: s.id.clone(), kind: EdgeKind::Dep });
}
}
edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
PlanDag { nodes, edges, sel_plan: Some(self.name.clone()) }
}
pub fn validate(&self) -> Result<()> {
if self.steps.is_empty() {
bail!("pipeline `{}` has no steps", self.name);
}
let mut seen: BTreeMap<&str, ()> = BTreeMap::new();
for s in &self.steps {
if seen.insert(s.id.as_str(), ()).is_some() {
bail!("pipeline `{}` has a duplicate step id `{}`", self.name, s.id);
}
}
for s in &self.steps {
for n in &s.needs {
if !seen.contains_key(n.as_str()) {
bail!("step `{}` needs unknown step `{}`", s.id, n);
}
}
}
self.to_plan_dag()
.topo_order()
.map_err(|c| anyhow::anyhow!("pipeline `{}` has a dependency cycle around {c:?}", self.name))?;
Ok(())
}
pub fn run_on_ledger(
&self,
store: &JobStore,
workspace: &str,
mut run_step: impl FnMut(&PipelineStep) -> Result<()>,
) -> Result<PipelineOutcome> {
self.validate()?;
let dag = self.to_plan_dag();
let order = dag
.topo_order()
.map_err(|c| anyhow::anyhow!("pipeline `{}` cycle around {c:?}", self.name))?;
let root = store.start(
kind::CI_BUILD,
&self.name,
workspace,
serde_json::json!({
"pipeline": self.name,
"steps": self.steps.len(),
"critical_path": dag.critical_path(),
"critical_path_len": dag.critical_path_len(),
}),
);
let root_id = root.job_id().to_string();
let mut job_id_of: BTreeMap<String, String> = BTreeMap::new();
let mut rec_of: BTreeMap<String, JobRecord> = BTreeMap::new();
for s in &self.steps {
let rec = new_child_record(&root_id, s, workspace, status::QUEUED);
store.submit(&rec)?;
job_id_of.insert(s.id.clone(), rec.job_id.clone());
rec_of.insert(s.id.clone(), rec);
}
let step_by_id: BTreeMap<&str, &PipelineStep> =
self.steps.iter().map(|s| (s.id.as_str(), s)).collect();
let mut final_status: BTreeMap<String, String> = BTreeMap::new();
for sid in &order {
let Some(step) = step_by_id.get(sid.as_str()) else { continue };
let rec = rec_of.get_mut(sid).expect("every step has a queued record");
let blocked = step
.needs
.iter()
.any(|n| final_status.get(n).map(|st| st != status::DONE).unwrap_or(true));
if blocked {
let blockers: Vec<&String> = step
.needs
.iter()
.filter(|n| final_status.get(n.as_str()).map(|st| st != status::DONE).unwrap_or(true))
.collect();
terminate(
rec,
status::FAILED,
serde_json::json!({ "step_id": step.id, "needs": step.needs, "blocked_by": blockers }),
);
store.submit(rec)?;
final_status.insert(sid.clone(), status::FAILED.to_string());
continue;
}
promote_running(rec);
store.submit(rec)?;
let st = match run_step(step) {
Ok(()) => {
terminate(rec, status::DONE, serde_json::json!({ "step_id": step.id, "needs": step.needs }));
status::DONE
}
Err(e) => {
terminate(
rec,
status::FAILED,
serde_json::json!({ "step_id": step.id, "needs": step.needs, "error": format!("{e:#}") }),
);
status::FAILED
}
};
store.submit(rec)?;
final_status.insert(sid.clone(), st.to_string());
}
let sub_jobs: Vec<SubJob> = self
.steps
.iter()
.map(|s| SubJob {
step_id: s.id.clone(),
job_id: job_id_of[&s.id].clone(),
status: final_status.get(&s.id).cloned().unwrap_or_else(|| status::QUEUED.to_string()),
needs: s.needs.clone(),
})
.collect();
let ok = sub_jobs.iter().all(|s| s.status == status::DONE);
if ok {
root.finish(serde_json::json!({ "steps": self.steps.len(), "outcome": "done" }), "");
} else {
let failed: Vec<&String> = sub_jobs.iter().filter(|s| s.status != status::DONE).map(|s| &s.step_id).collect();
root.fail_with_detail(serde_json::json!({ "failed_steps": failed }));
}
Ok(PipelineOutcome { root_id, dag, sub_jobs, ok })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubJob {
pub step_id: String,
pub job_id: String,
pub status: String,
pub needs: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PipelineOutcome {
pub root_id: String,
pub dag: PlanDag,
pub sub_jobs: Vec<SubJob>,
pub ok: bool,
}
impl PipelineOutcome {
#[must_use]
pub fn job_id_for(&self, step_id: &str) -> Option<&str> {
self.sub_jobs.iter().find(|s| s.step_id == step_id).map(|s| s.job_id.as_str())
}
#[must_use]
pub fn build_info_for<'a>(&self, rows: &'a [BuildInfoRow], step_id: &str) -> Vec<&'a BuildInfoRow> {
let Some(job_id) = self.job_id_for(step_id) else { return Vec::new() };
rows.iter().filter(|r| r.build_id == self.root_id && r.step_id == job_id).collect()
}
#[must_use]
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"root_id": self.root_id,
"ok": self.ok,
"sub_job_count": self.sub_jobs.len(),
"critical_path": self.dag.critical_path(),
"critical_path_len": self.dag.critical_path_len(),
"dep_edges": self.dag.dep_edges(),
"sub_jobs": self.sub_jobs.iter().map(|s| serde_json::json!({
"step_id": s.step_id,
"job_id": s.job_id,
"status": s.status,
"needs": s.needs,
})).collect::<Vec<_>>(),
})
}
}
fn now_micros() -> i64 {
chrono::Utc::now().timestamp_micros()
}
fn new_child_record(root_id: &str, step: &PipelineStep, ws: &str, initial_status: &str) -> JobRecord {
JobRecord {
job_id: uuid::Uuid::new_v4().to_string(),
kind: step.job_kind().to_string(),
target: step.name.clone(),
workspace: ws.to_string(),
status: initial_status.to_string(),
ts_start_micros: now_micros(),
ts_end_micros: None,
elapsed_ms: None,
detail_json: serde_json::json!({ "step_id": step.id, "needs": step.needs }).to_string(),
result_ref: String::new(),
parent_id: Some(root_id.to_string()),
node: None,
}
}
fn promote_running(rec: &mut JobRecord) {
rec.status = status::RUNNING.to_string();
rec.ts_start_micros = now_micros();
}
fn terminate(rec: &mut JobRecord, st: &str, detail: serde_json::Value) {
let end = now_micros();
rec.status = st.to_string();
rec.ts_end_micros = Some(end);
rec.elapsed_ms = Some((end - rec.ts_start_micros) / 1000);
rec.detail_json = detail.to_string();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jobs::JobSelector;
use nornir_build_thing::build_info::{BuildInfoKind, BuildInfoRow};
fn tmpdir(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("nornir-pipeline-{tag}-{}-{}", std::process::id(), now_micros()))
}
fn three_node() -> PipelineSpec {
PipelineSpec {
name: "build".into(),
steps: vec![
PipelineStep::new("fetch", "fetch sources", &[]),
PipelineStep::new("compile", "cargo build", &["fetch"]),
PipelineStep::new("test", "cargo test", &["compile"]),
],
}
}
fn row(build_id: &str, step_id: &str, kind: BuildInfoKind, name: &str) -> BuildInfoRow {
let mut r = BuildInfoRow::new(build_id, 1, kind);
r.step_id = step_id.to_string();
r.name = name.to_string();
r
}
#[test]
fn three_node_dag_makes_root_plus_three_subjobs_with_needs_and_status() {
let dir = tmpdir("3node");
let store = JobStore::open(&dir).unwrap();
let spec = three_node();
let dag = spec.to_plan_dag();
assert_eq!(dag.node_count(), 3, "one node per step");
assert_eq!(dag.dep_edges(), 2, "fetch→compile, compile→test");
assert_eq!(dag.critical_path(), vec!["fetch", "compile", "test"]);
assert_eq!(dag.critical_path_len(), 2, "two sequential waves define the schedule");
let out = spec.run_on_ledger(&store, "nordisk", |_step| Ok(())).unwrap();
assert!(out.ok, "all steps ran green");
let all = store.list(&JobSelector::All).unwrap();
assert_eq!(all.len(), 4, "one root + three sub-jobs on the ledger");
let root = all.iter().find(|r| r.job_id == out.root_id).expect("root on ledger");
assert_eq!(root.kind, kind::CI_BUILD);
assert_eq!(root.parent_id, None, "root is top-level");
assert_eq!(root.status, status::DONE);
let children: Vec<&JobRecord> =
all.iter().filter(|r| r.parent_id.as_deref() == Some(out.root_id.as_str())).collect();
assert_eq!(children.len(), 3, "three sub-jobs under the root");
assert!(children.iter().all(|r| r.status == status::DONE), "every sub-job done");
let compile = children.iter().find(|r| r.detail_json.contains("\"step_id\":\"compile\"")).unwrap();
assert!(compile.detail_json.contains("fetch"), "compile's needs edge (fetch) is on the ledger row");
let fetch = children.iter().find(|r| r.detail_json.contains("\"step_id\":\"fetch\"")).unwrap();
assert!(fetch.detail_json.contains("\"needs\":[]"), "fetch has no needs");
assert!(out.sub_jobs.iter().all(|s| s.status == status::DONE));
assert_eq!(out.sub_jobs.len(), 3);
nornir_testmatrix::functional_status(
"build-pipeline-subjobs",
"three_node_root_plus_subjobs",
all.len() == 4 && children.len() == 3 && dag.dep_edges() == 2,
"3-node DAG → 1 root + 3 sub-jobs with needs edges + per-step done",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn failed_step_blocks_dependents_and_fails_root() {
let dir = tmpdir("fail");
let store = JobStore::open(&dir).unwrap();
let spec = three_node();
let out = spec
.run_on_ledger(&store, "ws", |s| if s.id == "compile" { bail!("compile boom") } else { Ok(()) })
.unwrap();
assert!(!out.ok, "a failed step fails the pipeline");
let by = |id: &str| out.sub_jobs.iter().find(|s| s.step_id == id).unwrap();
assert_eq!(by("fetch").status, status::DONE, "fetch ran green");
assert_eq!(by("compile").status, status::FAILED, "compile failed");
assert_eq!(by("test").status, status::FAILED, "test is blocked by the failed compile");
let all = store.list(&JobSelector::All).unwrap();
let test = all.iter().find(|r| r.job_id == by("test").job_id).unwrap();
assert!(test.detail_json.contains("blocked_by"), "test row says it was blocked");
assert!(test.detail_json.contains("compile"), "blocked by compile");
let compile = all.iter().find(|r| r.job_id == by("compile").job_id).unwrap();
assert!(compile.detail_json.contains("compile boom"), "compile carries its error");
let root = all.iter().find(|r| r.job_id == out.root_id).unwrap();
assert_eq!(root.status, status::FAILED, "root fails when a step fails");
nornir_testmatrix::functional_status(
"build-pipeline-subjobs",
"failed_step_blocks_dependents",
!out.ok && by("test").status == status::FAILED && root.status == status::FAILED,
"compile fails → test blocked → root failed",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn single_step_is_a_one_node_dag_additive() {
let dir = tmpdir("single");
let store = JobStore::open(&dir).unwrap();
let spec = PipelineSpec {
name: "single".into(),
steps: vec![PipelineStep::new("build", "cargo build", &[])],
};
let dag = spec.to_plan_dag();
assert_eq!(dag.node_count(), 1);
assert_eq!(dag.dep_edges(), 0, "a lone step has no needs edges");
assert_eq!(dag.critical_path_len(), 0, "one node = zero waves of dependency");
let out = spec.run_on_ledger(&store, "ws", |_| Ok(())).unwrap();
assert!(out.ok);
let all = store.list(&JobSelector::All).unwrap();
assert_eq!(all.len(), 2, "root + one sub-job");
assert!(all.iter().all(|r| r.status == status::DONE));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_info_stream_filters_per_step_id_to_each_subjob() {
let dir = tmpdir("binfo");
let store = JobStore::open(&dir).unwrap();
let spec = three_node();
let out = spec.run_on_ledger(&store, "ws", |_| Ok(())).unwrap();
let fetch_job = out.job_id_for("fetch").unwrap().to_string();
let compile_job = out.job_id_for("compile").unwrap().to_string();
assert_ne!(fetch_job, compile_job, "each sub-job has its own id");
let rows = vec![
row(&out.root_id, &fetch_job, BuildInfoKind::Log, "fetching"),
row(&out.root_id, &compile_job, BuildInfoKind::Log, "compiling"),
row(&out.root_id, &compile_job, BuildInfoKind::Metric, "warnings"),
row("other-build", &compile_job, BuildInfoKind::Log, "elsewhere"),
];
let fetch_rows = out.build_info_for(&rows, "fetch");
assert_eq!(fetch_rows.len(), 1, "one row for fetch's sub-job");
assert_eq!(fetch_rows[0].name, "fetching");
let compile_rows = out.build_info_for(&rows, "compile");
assert_eq!(compile_rows.len(), 2, "compile's own two rows, not the other build's");
assert!(compile_rows.iter().all(|r| r.build_id == out.root_id));
assert!(out.build_info_for(&rows, "nope").is_empty());
nornir_testmatrix::functional_status(
"build-pipeline-subjobs",
"build_info_filtered_per_step",
fetch_rows.len() == 1 && compile_rows.len() == 2,
"slice-2 stream filtered by sub-job step_id",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cyclic_pipeline_is_rejected() {
let dir = tmpdir("cycle");
let store = JobStore::open(&dir).unwrap();
let spec = PipelineSpec {
name: "loop".into(),
steps: vec![
PipelineStep::new("a", "a", &["b"]),
PipelineStep::new("b", "b", &["a"]),
],
};
assert!(spec.validate().is_err(), "a cycle is invalid");
assert!(spec.run_on_ledger(&store, "ws", |_| Ok(())).is_err(), "run refuses a cyclic spec");
let bad = PipelineSpec { name: "x".into(), steps: vec![PipelineStep::new("a", "a", &["ghost"])] };
assert!(bad.validate().is_err(), "dangling needs is invalid");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn diamond_dag_reuses_funnel_core_critical_path() {
let spec = PipelineSpec {
name: "diamond".into(),
steps: vec![
PipelineStep::new("fetch", "fetch", &[]),
PipelineStep::new("build_x", "build x", &["fetch"]),
PipelineStep::new("build_y", "build y", &["fetch"]),
PipelineStep::new("link", "link", &["build_x", "build_y"]),
],
};
let dag = spec.to_plan_dag();
assert_eq!(dag.dep_edges(), 4);
assert_eq!(dag.critical_path_len(), 2, "fetch → build_* → link = 2 waves");
let fetch = dag.nodes.iter().find(|n| n.id == "fetch").unwrap();
assert_eq!(fetch.fanout, 2, "two steps need fetch");
let s = out_state(&spec);
assert_eq!(s["dep_edges"], 4);
}
fn out_state(spec: &PipelineSpec) -> serde_json::Value {
let dir = tmpdir("state");
let store = JobStore::open(&dir).unwrap();
let out = spec.run_on_ledger(&store, "ws", |_| Ok(())).unwrap();
let s = out.state_json();
std::fs::remove_dir_all(&dir).ok();
s
}
}