Skip to main content

meridian_task_core/
lib.rs

1//! Job graph scheduler: worker threads, dependency tracking and parallel frame execution.
2//!
3//! Systems declare a job's dependencies when adding it to a [`JobGraph`];
4//! [`Scheduler::run`] derives execution order from that graph rather than
5//! the caller hand-sequencing calls, and runs independent branches (see
6//! docs/threading-model.md's shape-of-a-frame example) across worker
7//! threads in parallel. Current implementation is a single shared
8//! ready-queue behind one mutex, not per-worker lock-free deques — see
9//! "Implementation note" on [`Scheduler`] for why that's a deliberate
10//! first step, not the final design.
11
12use std::collections::VecDeque;
13use std::sync::{Condvar, Mutex};
14
15/// Opaque handle to a job within the [`JobGraph`] it was added to. Not
16/// valid across different `JobGraph`s.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct JobId(usize);
19
20type Action = Box<dyn FnOnce() + Send>;
21
22struct JobEntry {
23    name: &'static str,
24    action: Option<Action>,
25    dependencies: Vec<JobId>,
26}
27
28/// A dependency-ordered set of jobs for one frame. Build with
29/// [`add_job`](Self::add_job), then hand to [`Scheduler::run`].
30#[derive(Default)]
31pub struct JobGraph {
32    jobs: Vec<JobEntry>,
33}
34
35impl JobGraph {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn len(&self) -> usize {
41        self.jobs.len()
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.jobs.is_empty()
46    }
47
48    /// The name a job was given when added — for logging/debugging which
49    /// job is running, not used by the scheduler itself.
50    pub fn job_name(&self, id: JobId) -> &'static str {
51        self.jobs[id.0].name
52    }
53
54    /// Adds a job that becomes runnable only once every job in
55    /// `dependencies` has completed. `dependencies` must be [`JobId`]s
56    /// returned earlier from this same graph.
57    pub fn add_job(
58        &mut self,
59        name: &'static str,
60        dependencies: &[JobId],
61        action: impl FnOnce() + Send + 'static,
62    ) -> JobId {
63        let id = JobId(self.jobs.len());
64        self.jobs.push(JobEntry {
65            name,
66            action: Some(Box::new(action)),
67            dependencies: dependencies.to_vec(),
68        });
69        id
70    }
71}
72
73/// Per-run scheduling state, behind one lock: in-degree counts, the
74/// reverse-edge (dependents) list, the queue of runnable-but-not-yet-taken
75/// job indices, and how many jobs are still outstanding.
76struct SchedulerState {
77    indegree: Vec<usize>,
78    dependents: Vec<Vec<usize>>,
79    ready: VecDeque<usize>,
80    remaining: usize,
81}
82
83/// Runs a [`JobGraph`] across worker threads, respecting declared
84/// dependencies.
85///
86/// ## Implementation note
87///
88/// Every worker pulls from one shared `Mutex`-guarded ready-queue rather
89/// than each having its own deque with the classic work-stealing protocol
90/// (steal from a random peer when your own queue is empty). A single
91/// shared queue is correct and easy to verify by test; per-worker
92/// lock-free deques are a real throughput win at high job counts, but
93/// implementing that safely is a separate, riskier piece of work — not
94/// worth taking on before there's a real frame's worth of jobs to profile
95/// against. Swapping the internals later doesn't change this type's API.
96#[derive(Debug)]
97pub struct Scheduler {
98    pub worker_count: usize,
99}
100
101impl Scheduler {
102    pub fn new(worker_count: usize) -> Self {
103        Self {
104            worker_count: worker_count.max(1),
105        }
106    }
107
108    /// Runs every job in `graph` to completion. Blocks the calling thread
109    /// until the whole graph has finished.
110    ///
111    /// # Panics
112    ///
113    /// Panics if `graph` contains a dependency cycle, or a [`JobId`] that
114    /// doesn't belong to it — both are caller bugs, not runtime
115    /// conditions to recover from, and running a cyclic graph would
116    /// otherwise hang forever waiting for an in-degree that never reaches
117    /// zero.
118    pub fn run(&self, mut graph: JobGraph) {
119        let n = graph.jobs.len();
120        if n == 0 {
121            return;
122        }
123
124        let mut indegree = vec![0usize; n];
125        let mut dependents: Vec<Vec<usize>> = (0..n).map(|_| Vec::new()).collect();
126        for (i, job) in graph.jobs.iter().enumerate() {
127            for dep in &job.dependencies {
128                assert!(
129                    dep.0 < n,
130                    "Scheduler::run: JobId({}) doesn't belong to this JobGraph",
131                    dep.0
132                );
133                indegree[i] += 1;
134                dependents[dep.0].push(i);
135            }
136        }
137        assert!(
138            !has_cycle(&indegree, &dependents, n),
139            "Scheduler::run: dependency cycle in JobGraph"
140        );
141
142        let actions: Mutex<Vec<Option<Action>>> =
143            Mutex::new(graph.jobs.iter_mut().map(|j| j.action.take()).collect());
144
145        let ready: VecDeque<usize> = (0..n).filter(|&i| indegree[i] == 0).collect();
146        let state = Mutex::new(SchedulerState {
147            indegree,
148            dependents,
149            ready,
150            remaining: n,
151        });
152        let cvar = Condvar::new();
153
154        std::thread::scope(|scope| {
155            for _ in 0..self.worker_count {
156                scope.spawn(|| worker_loop(&state, &cvar, &actions));
157            }
158        });
159    }
160}
161
162fn worker_loop(
163    state: &Mutex<SchedulerState>,
164    cvar: &Condvar,
165    actions: &Mutex<Vec<Option<Action>>>,
166) {
167    loop {
168        let job_index = {
169            let mut guard = state.lock().unwrap();
170            loop {
171                if let Some(i) = guard.ready.pop_front() {
172                    break Some(i);
173                }
174                if guard.remaining == 0 {
175                    break None;
176                }
177                guard = cvar.wait(guard).unwrap();
178            }
179        };
180
181        let Some(i) = job_index else {
182            break;
183        };
184
185        let action = actions.lock().unwrap()[i].take();
186        if let Some(action) = action {
187            action();
188        }
189
190        let mut guard = state.lock().unwrap();
191        let newly_ready: Vec<usize> = std::mem::take(&mut guard.dependents[i])
192            .into_iter()
193            .filter(|&d| {
194                guard.indegree[d] -= 1;
195                guard.indegree[d] == 0
196            })
197            .collect();
198        guard.ready.extend(newly_ready);
199        guard.remaining -= 1;
200        drop(guard);
201        cvar.notify_all();
202    }
203}
204
205/// Kahn's algorithm dry run: if fewer than `n` nodes are ever reachable
206/// from a zero in-degree, some subset forms a cycle.
207fn has_cycle(indegree: &[usize], dependents: &[Vec<usize>], n: usize) -> bool {
208    let mut indegree = indegree.to_vec();
209    let mut queue: VecDeque<usize> = (0..n).filter(|&i| indegree[i] == 0).collect();
210    let mut visited = 0usize;
211    while let Some(i) = queue.pop_front() {
212        visited += 1;
213        for &d in &dependents[i] {
214            indegree[d] -= 1;
215            if indegree[d] == 0 {
216                queue.push_back(d);
217            }
218        }
219    }
220    visited != n
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::sync::atomic::{AtomicUsize, Ordering};
227    use std::sync::{Arc, Mutex as StdMutex};
228
229    #[test]
230    fn empty_graph_runs_without_blocking() {
231        Scheduler::new(2).run(JobGraph::new());
232    }
233
234    #[test]
235    fn single_job_runs() {
236        let ran = Arc::new(AtomicUsize::new(0));
237        let mut graph = JobGraph::new();
238        let ran2 = ran.clone();
239        graph.add_job("only", &[], move || {
240            ran2.fetch_add(1, Ordering::SeqCst);
241        });
242        Scheduler::new(1).run(graph);
243        assert_eq!(ran.load(Ordering::SeqCst), 1);
244    }
245
246    #[test]
247    fn dependent_job_runs_after_its_dependency() {
248        let order = Arc::new(StdMutex::new(Vec::new()));
249        let mut graph = JobGraph::new();
250
251        let order_a = order.clone();
252        let a = graph.add_job("a", &[], move || order_a.lock().unwrap().push("a"));
253
254        let order_b = order.clone();
255        graph.add_job("b", &[a], move || order_b.lock().unwrap().push("b"));
256
257        Scheduler::new(4).run(graph);
258        assert_eq!(*order.lock().unwrap(), vec!["a", "b"]);
259    }
260
261    #[test]
262    fn independent_jobs_all_run_regardless_of_worker_count() {
263        let count = Arc::new(AtomicUsize::new(0));
264        let mut graph = JobGraph::new();
265        for _ in 0..20 {
266            let c = count.clone();
267            graph.add_job("independent", &[], move || {
268                c.fetch_add(1, Ordering::SeqCst);
269            });
270        }
271        Scheduler::new(4).run(graph);
272        assert_eq!(count.load(Ordering::SeqCst), 20);
273    }
274
275    #[test]
276    fn diamond_dependency_runs_in_valid_order() {
277        // a -> b, a -> c, (b, c) -> d — d must see both b's and c's effects.
278        let order = Arc::new(StdMutex::new(Vec::new()));
279        let mut graph = JobGraph::new();
280
281        let oa = order.clone();
282        let a = graph.add_job("a", &[], move || oa.lock().unwrap().push("a"));
283
284        let ob = order.clone();
285        let b = graph.add_job("b", &[a], move || ob.lock().unwrap().push("b"));
286
287        let oc = order.clone();
288        let c = graph.add_job("c", &[a], move || oc.lock().unwrap().push("c"));
289
290        let od = order.clone();
291        graph.add_job("d", &[b, c], move || od.lock().unwrap().push("d"));
292
293        Scheduler::new(4).run(graph);
294
295        let order = order.lock().unwrap();
296        assert_eq!(order.first(), Some(&"a"));
297        assert_eq!(order.last(), Some(&"d"));
298        assert_eq!(order.len(), 4);
299    }
300
301    #[test]
302    #[should_panic(expected = "dependency cycle")]
303    fn cyclic_graph_panics_instead_of_hanging() {
304        // Build a 2-cycle by hand: add_job only accepts already-issued
305        // JobIds, so a JobGraph built purely through the public API can
306        // never contain a cycle. To exercise the panic path we reach past
307        // the API and forge a cycle directly on the private field — this
308        // is the one test in the suite allowed to do that, specifically
309        // to prove the guard that protects against a malformed graph
310        // (e.g. one deserialized from bad data in the future) works.
311        let mut graph = JobGraph::new();
312        let a = graph.add_job("a", &[], || {});
313        let b = graph.add_job("b", &[a], || {});
314        graph.jobs[a.0].dependencies.push(b);
315
316        Scheduler::new(2).run(graph);
317    }
318}