Skip to main content

harness_loop/orchestrator/
dag.rs

1//! `Dag` — the set of Jobs and their dependency edges, plus the planner's
2//! delta type for dynamic replanning.
3
4use crate::orchestrator::job::{Job, JobId, JobState};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// A directed acyclic graph of Jobs. Edges are implicit in each Job's
9/// `deps`. The orchestrator schedules a Job once all of its deps have
10/// `Succeeded`.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Dag {
13    jobs: HashMap<JobId, Job>,
14}
15
16impl Dag {
17    pub fn new() -> Self {
18        Self {
19            jobs: HashMap::new(),
20        }
21    }
22
23    /// Build from a list of Jobs.
24    pub fn from_jobs(jobs: impl IntoIterator<Item = Job>) -> Self {
25        let mut d = Dag::new();
26        for j in jobs {
27            d.jobs.insert(j.id.clone(), j);
28        }
29        d
30    }
31
32    pub fn add(&mut self, job: Job) {
33        self.jobs.insert(job.id.clone(), job);
34    }
35
36    pub fn get(&self, id: &str) -> Option<&Job> {
37        self.jobs.get(id)
38    }
39    pub fn get_mut(&mut self, id: &str) -> Option<&mut Job> {
40        self.jobs.get_mut(id)
41    }
42    pub fn len(&self) -> usize {
43        self.jobs.len()
44    }
45    pub fn is_empty(&self) -> bool {
46        self.jobs.is_empty()
47    }
48    pub fn ids(&self) -> Vec<JobId> {
49        self.jobs.keys().cloned().collect()
50    }
51    pub fn jobs_mut(&mut self) -> impl Iterator<Item = &mut Job> {
52        self.jobs.values_mut()
53    }
54
55    pub fn jobs(&self) -> impl Iterator<Item = &Job> {
56        self.jobs.values()
57    }
58
59    /// Are all of `job`'s dependencies in a `Succeeded` state?
60    pub fn deps_satisfied(&self, job: &Job) -> bool {
61        job.deps.iter().all(|d| {
62            self.jobs
63                .get(d)
64                .map(|j| j.state == JobState::Succeeded)
65                .unwrap_or(false)
66        })
67    }
68
69    /// Is any dependency of `job` blocked (dead-lettered / cancelled), so
70    /// `job` can never run?
71    pub fn deps_blocked(&self, job: &Job) -> bool {
72        job.deps.iter().any(|d| {
73            self.jobs
74                .get(d)
75                .map(|j| matches!(j.state, JobState::DeadLettered | JobState::Cancelled))
76                .unwrap_or(true) // missing dep ⇒ unsatisfiable
77        })
78    }
79
80    /// Succeeded `(id, result)` pairs — used as context for downstream Jobs
81    /// and for the planner's replan input.
82    pub fn succeeded_results(&self) -> Vec<(JobId, crate::orchestrator::job::JobResult)> {
83        self.jobs
84            .values()
85            .filter(|j| j.state == JobState::Succeeded)
86            .filter_map(|j| j.result.clone().map(|r| (j.id.clone(), r)))
87            .collect()
88    }
89
90    /// Detect a dependency cycle (returns the first cycle's member ids).
91    /// The orchestrator rejects a cyclic DAG up front rather than deadlock.
92    /// `(job, missing_dep)` for every dependency naming a job that is not in
93    /// this graph.
94    ///
95    /// A typo here is not a compile error and does not announce itself: the job
96    /// simply never becomes runnable and is swept up as unreachable, which reads
97    /// as a scheduling mystery rather than as a misspelled id.
98    pub fn unknown_deps(&self) -> Vec<(JobId, JobId)> {
99        self.jobs()
100            .flat_map(|j| {
101                j.deps
102                    .iter()
103                    .filter(|d| !self.jobs.contains_key(*d))
104                    .map(move |d| (j.id.clone(), d.clone()))
105            })
106            .collect()
107    }
108
109    /// `id` plus every job that transitively depends on it.
110    ///
111    /// What a [`Next::Goto`](crate::orchestrator::Next) has to re-run: sending the graph back
112    /// to `revise` while the `review` that rejected it stays `Succeeded` would
113    /// revise once and never look again — the loop would run exactly one lap and
114    /// silently stop being a loop.
115    pub fn downstream_of(&self, id: &str) -> Vec<JobId> {
116        let mut out: Vec<JobId> = vec![id.into()];
117        let mut i = 0;
118        while i < out.len() {
119            let current = out[i].clone();
120            for j in self.jobs() {
121                if j.deps.contains(&current) && !out.contains(&j.id) {
122                    out.push(j.id.clone());
123                }
124            }
125            i += 1;
126        }
127        out
128    }
129
130    pub fn find_cycle(&self) -> Option<Vec<JobId>> {
131        #[derive(Clone, Copy, PartialEq)]
132        enum Mark {
133            White,
134            Gray,
135            Black,
136        }
137        let mut mark: HashMap<&str, Mark> = self
138            .jobs
139            .keys()
140            .map(|k| (k.as_str(), Mark::White))
141            .collect();
142        let mut stack: Vec<&str> = Vec::new();
143
144        fn dfs<'a>(
145            node: &'a str,
146            jobs: &'a HashMap<JobId, Job>,
147            mark: &mut HashMap<&'a str, Mark>,
148            stack: &mut Vec<&'a str>,
149        ) -> Option<Vec<JobId>> {
150            mark.insert(node, Mark::Gray);
151            stack.push(node);
152            if let Some(j) = jobs.get(node) {
153                for dep in &j.deps {
154                    match mark.get(dep.as_str()).copied() {
155                        Some(Mark::Gray) => {
156                            // back edge ⇒ cycle
157                            let from = stack.iter().position(|n| *n == dep.as_str()).unwrap_or(0);
158                            return Some(stack[from..].iter().map(|s| s.to_string()).collect());
159                        }
160                        Some(Mark::White) => {
161                            if let Some(c) = dfs(dep.as_str(), jobs, mark, stack) {
162                                return Some(c);
163                            }
164                        }
165                        _ => {}
166                    }
167                }
168            }
169            stack.pop();
170            mark.insert(node, Mark::Black);
171            None
172        }
173
174        let ids: Vec<&str> = self.jobs.keys().map(|s| s.as_str()).collect();
175        for id in ids {
176            if mark.get(id).copied() == Some(Mark::White)
177                && let Some(c) = dfs(id, &self.jobs, &mut mark, &mut stack)
178            {
179                return Some(c);
180            }
181        }
182        None
183    }
184}
185
186/// What a [`crate::orchestrator::Planner`] returns when asked to (re)plan: either more
187/// Jobs to merge into the DAG, or "the goal is done".
188#[derive(Debug, Clone)]
189pub enum PlanDelta {
190    /// Merge these Jobs into the running DAG (the dynamic-replanning edge).
191    Add(Vec<Job>),
192    /// No more work — the orchestrator can finish once in-flight Jobs drain.
193    Done,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn j(id: &str, deps: &[&str]) -> Job {
201        Job::new(id, format!("do {id}")).with_deps(deps.iter().copied())
202    }
203
204    #[test]
205    fn deps_satisfied_only_when_all_succeeded() {
206        let mut d = Dag::from_jobs([j("a", &[]), j("b", &[]), j("c", &["a", "b"])]);
207        let c = d.get("c").unwrap().clone();
208        assert!(!d.deps_satisfied(&c));
209        d.get_mut("a").unwrap().state = JobState::Succeeded;
210        assert!(!d.deps_satisfied(&c));
211        d.get_mut("b").unwrap().state = JobState::Succeeded;
212        assert!(d.deps_satisfied(&c));
213    }
214
215    #[test]
216    fn dead_dep_blocks_downstream() {
217        let mut d = Dag::from_jobs([j("a", &[]), j("c", &["a"])]);
218        d.get_mut("a").unwrap().state = JobState::DeadLettered;
219        let c = d.get("c").unwrap().clone();
220        assert!(d.deps_blocked(&c));
221    }
222
223    #[test]
224    fn detects_cycle() {
225        let d = Dag::from_jobs([j("a", &["c"]), j("b", &["a"]), j("c", &["b"])]);
226        assert!(d.find_cycle().is_some());
227        let ok = Dag::from_jobs([j("a", &[]), j("b", &["a"]), j("c", &["b"])]);
228        assert!(ok.find_cycle().is_none());
229    }
230}