Skip to main content

consortium/dag/
executor.rs

1//! DAG executor — schedules and runs tasks with maximum parallelism.
2//!
3//! The executor is a single-threaded scheduler that dispatches tasks to
4//! worker threads. It blocks on an mpsc channel waiting for completions,
5//! then immediately dispatches any newly-ready tasks. This gives maximum
6//! parallelism without busy-waiting: a task starts the instant its
7//! dependencies are satisfied and a pool slot is available.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::sync::mpsc;
11use std::thread;
12
13use crate::dag::cache::CacheStrategy;
14use crate::dag::context::DagContext;
15use crate::dag::error::{DagError, Result};
16use crate::dag::graph::DagGraph;
17use crate::dag::pool::WorkerPool;
18use crate::dag::types::{ErrorPolicy, TaskId, TaskOutcome};
19
20/// Completion message from a worker thread.
21struct CompletionEvent {
22    task_id: TaskId,
23    outcome: TaskOutcome,
24}
25
26/// Events emitted during DAG execution for monitoring.
27#[derive(Debug, Clone)]
28pub enum DagEvent {
29    TaskStarted(TaskId),
30    TaskCompleted(TaskId, TaskOutcome),
31    TaskSkipped(TaskId),
32    TaskCancelled(TaskId),
33}
34
35/// Callback trait for monitoring DAG execution progress.
36pub trait DagMonitor: Send {
37    fn on_event(&mut self, event: &DagEvent);
38}
39
40/// Report from a DAG execution run.
41#[derive(Debug)]
42pub struct DagReport {
43    /// Tasks that completed successfully.
44    pub completed: HashSet<TaskId>,
45    /// Tasks that were skipped (cache hit).
46    pub skipped: HashSet<TaskId>,
47    /// Tasks that failed, with error messages.
48    pub failed: HashMap<TaskId, String>,
49    /// Tasks cancelled because a dependency failed.
50    pub cancelled: HashSet<TaskId>,
51}
52
53impl DagReport {
54    /// Whether the entire DAG succeeded (no failures).
55    pub fn is_success(&self) -> bool {
56        self.failed.is_empty()
57    }
58
59    /// Total number of tasks processed (completed + skipped + failed + cancelled).
60    pub fn total(&self) -> usize {
61        self.completed.len() + self.skipped.len() + self.failed.len() + self.cancelled.len()
62    }
63}
64
65/// The DAG executor. Constructed via `DagBuilder` or `StageBuilder`.
66pub struct DagExecutor {
67    pub(crate) graph: DagGraph,
68    pub(crate) context: DagContext,
69    pub(crate) error_policy: ErrorPolicy,
70    pub(crate) pool: Box<dyn WorkerPool>,
71    pub(crate) global_cache: Option<Box<dyn CacheStrategy>>,
72    pub(crate) monitor: Option<Box<dyn DagMonitor>>,
73}
74
75impl DagExecutor {
76    /// Execute the DAG to completion.
77    ///
78    /// Blocks until all tasks finish, are cancelled, or an error policy
79    /// aborts execution.
80    pub fn run(mut self) -> Result<DagReport> {
81        self.graph.validate()?;
82
83        let (tx, rx) = mpsc::channel::<CompletionEvent>();
84
85        // Track unmet dependency count per task
86        let mut pending_deps: HashMap<TaskId, usize> = HashMap::new();
87        let all_task_ids: Vec<TaskId> = self.graph.tasks.keys().cloned().collect();
88        for id in &all_task_ids {
89            let dep_count = self
90                .graph
91                .dependencies
92                .get(id)
93                .map(|d| d.len())
94                .unwrap_or(0);
95            pending_deps.insert(id.clone(), dep_count);
96        }
97
98        // Concurrency group tracking: group_name -> active count
99        let mut group_active: HashMap<String, usize> = HashMap::new();
100
101        let mut ready: VecDeque<TaskId> = VecDeque::new();
102        let mut deferred: VecDeque<TaskId> = VecDeque::new(); // tasks waiting for pool/group slots
103        let mut in_flight: HashSet<TaskId> = HashSet::new();
104
105        let mut report = DagReport {
106            completed: HashSet::new(),
107            skipped: HashSet::new(),
108            failed: HashMap::new(),
109            cancelled: HashSet::new(),
110        };
111
112        // Seed with root tasks
113        for id in &all_task_ids {
114            if pending_deps.get(id).copied().unwrap_or(0) == 0 {
115                ready.push_back(id.clone());
116            }
117        }
118
119        loop {
120            // 1. Try to dispatch ready tasks
121            let mut retry_ready: VecDeque<TaskId> = VecDeque::new();
122
123            while let Some(task_id) = ready.pop_front() {
124                if report.cancelled.contains(&task_id) {
125                    continue;
126                }
127                // Check cache (per-task or global)
128                if let Some(cached) = self.check_cache(&task_id) {
129                    self.context.set_output(task_id.clone(), cached);
130                    self.notify(DagEvent::TaskSkipped(task_id.clone()));
131                    report.skipped.insert(task_id.clone());
132                    self.mark_completed(&task_id, &mut pending_deps, &mut retry_ready);
133                    continue;
134                }
135
136                // Check concurrency group limit
137                if !self.can_dispatch_group(&task_id, &group_active) {
138                    deferred.push_back(task_id);
139                    continue;
140                }
141
142                // Check worker pool
143                let resource_tag = self
144                    .graph
145                    .tasks
146                    .get(&task_id)
147                    .and_then(|t| t.resource_tag().map(|s| s.to_string()));
148                if !self.pool.acquire(resource_tag.as_deref()) {
149                    deferred.push_back(task_id);
150                    continue;
151                }
152
153                // Dispatch to worker thread
154                let task = match self.graph.take_task(&task_id) {
155                    Some(t) => t,
156                    None => continue, // already taken (shouldn't happen)
157                };
158
159                // Update group tracking
160                if let Some(group) = self.graph.task_groups.get(&task_id) {
161                    *group_active.entry(group.clone()).or_insert(0) += 1;
162                }
163
164                let ctx = self.context.clone();
165                let tx = tx.clone();
166                let id = task_id.clone();
167                in_flight.insert(task_id.clone());
168                self.notify(DagEvent::TaskStarted(task_id));
169
170                thread::spawn(move || {
171                    let outcome = task.execute(&ctx);
172                    let _ = tx.send(CompletionEvent {
173                        task_id: id,
174                        outcome,
175                    });
176                });
177            }
178
179            // Move retry_ready items back to ready
180            ready.extend(retry_ready);
181
182            // 2. If nothing in flight and nothing ready/deferred, we're done
183            if in_flight.is_empty() && ready.is_empty() && deferred.is_empty() {
184                break;
185            }
186
187            // 3. If nothing in flight but tasks are deferred, that's a deadlock
188            if in_flight.is_empty() && ready.is_empty() && !deferred.is_empty() {
189                // This can happen if pool limits are too tight
190                return Err(DagError::General(format!(
191                    "deadlock: {} deferred tasks but nothing in flight",
192                    deferred.len()
193                )));
194            }
195
196            // 4. Wait for a completion event
197            let event = rx.recv().map_err(|_| DagError::ChannelClosed)?;
198            let CompletionEvent { task_id, outcome } = event;
199
200            in_flight.remove(&task_id);
201
202            // Release pool slot
203            self.pool.release(None);
204
205            // Release concurrency group slot
206            if let Some(group) = self.graph.task_groups.get(&task_id) {
207                if let Some(count) = group_active.get_mut(group) {
208                    *count = count.saturating_sub(1);
209                }
210            }
211
212            let is_success = outcome.is_success();
213            let fail_msg = match &outcome {
214                TaskOutcome::Failed(msg) => Some(msg.clone()),
215                _ => None,
216            };
217
218            self.notify(DagEvent::TaskCompleted(task_id.clone(), outcome));
219
220            if is_success {
221                report.completed.insert(task_id.clone());
222                self.mark_completed(&task_id, &mut pending_deps, &mut ready);
223
224                // Re-check deferred tasks (pool slot freed)
225                let deferred_tasks: Vec<_> = deferred.drain(..).collect();
226                for dt in deferred_tasks {
227                    ready.push_back(dt);
228                }
229            } else if let Some(msg) = fail_msg {
230                match &self.error_policy {
231                    ErrorPolicy::FailFast => {
232                        report.failed.insert(task_id.clone(), msg);
233                        // Wait for in-flight tasks to finish, then return
234                        drop(tx);
235                        for event in rx {
236                            in_flight.remove(&event.task_id);
237                            report.cancelled.insert(event.task_id);
238                        }
239                        return Ok(report);
240                    }
241                    ErrorPolicy::ContinueIndependent => {
242                        report.failed.insert(task_id.clone(), msg);
243                        self.cancel_dependents(
244                            &task_id,
245                            &mut pending_deps,
246                            &mut report.cancelled,
247                            &mut ready,
248                        );
249                        let deferred_tasks: Vec<_> = deferred.drain(..).collect();
250                        for dt in deferred_tasks {
251                            if !report.cancelled.contains(&dt) {
252                                ready.push_back(dt);
253                            }
254                        }
255                    }
256                    ErrorPolicy::Retry { .. } => {
257                        report.failed.insert(task_id.clone(), msg);
258                        self.cancel_dependents(
259                            &task_id,
260                            &mut pending_deps,
261                            &mut report.cancelled,
262                            &mut ready,
263                        );
264                    }
265                }
266            }
267        }
268
269        Ok(report)
270    }
271
272    /// Check cache for a task (per-task cache or global cache).
273    fn check_cache(&self, task_id: &TaskId) -> Option<Box<dyn std::any::Any + Send>> {
274        // Per-task cache takes priority
275        if let Some(cache) = self.graph.task_cache.get(task_id) {
276            if let Some(output) = cache.check(task_id, &self.context) {
277                return Some(output);
278            }
279        }
280        // Fall back to global cache
281        if let Some(ref cache) = self.global_cache {
282            return cache.check(task_id, &self.context);
283        }
284        None
285    }
286
287    /// Check if a task can be dispatched given its concurrency group limit.
288    fn can_dispatch_group(&self, task_id: &TaskId, group_active: &HashMap<String, usize>) -> bool {
289        if let Some(group_name) = self.graph.task_groups.get(task_id) {
290            if let Some(limit) = self.graph.concurrency_groups.get(group_name) {
291                if let Some(max) = limit.max_concurrent {
292                    let active = group_active.get(group_name).copied().unwrap_or(0);
293                    return active < max;
294                }
295            }
296        }
297        true
298    }
299
300    /// Mark a task as completed and update dependency counts.
301    /// Newly-ready tasks are pushed into the ready queue.
302    fn mark_completed(
303        &self,
304        task_id: &TaskId,
305        pending_deps: &mut HashMap<TaskId, usize>,
306        ready: &mut VecDeque<TaskId>,
307    ) {
308        if let Some(dependents) = self.graph.dependents.get(task_id) {
309            for dep_id in dependents {
310                if let Some(count) = pending_deps.get_mut(dep_id) {
311                    *count = count.saturating_sub(1);
312                    if *count == 0 {
313                        ready.push_back(dep_id.clone());
314                    }
315                }
316            }
317        }
318    }
319
320    /// Cancel all transitive dependents of a failed task.
321    fn cancel_dependents(
322        &mut self,
323        task_id: &TaskId,
324        pending_deps: &mut HashMap<TaskId, usize>,
325        cancelled: &mut HashSet<TaskId>,
326        ready: &mut VecDeque<TaskId>,
327    ) {
328        let mut to_cancel = VecDeque::from([task_id.clone()]);
329        let mut settled = Vec::new();
330        while let Some(parent) = to_cancel.pop_front() {
331            settled.push(parent.clone());
332            let dependents: Vec<_> = self
333                .graph
334                .dependents
335                .get(&parent)
336                .into_iter()
337                .flatten()
338                .cloned()
339                .collect();
340            for dependent in dependents {
341                if self
342                    .graph
343                    .completion_dependencies
344                    .contains(&(dependent.clone(), parent.clone()))
345                    || cancelled.contains(&dependent)
346                {
347                    continue;
348                }
349                cancelled.insert(dependent.clone());
350                self.notify(DagEvent::TaskCancelled(dependent.clone()));
351                pending_deps.remove(&dependent);
352                to_cancel.push_back(dependent);
353            }
354        }
355        for terminal in settled {
356            self.mark_completed(&terminal, pending_deps, ready);
357        }
358    }
359
360    fn notify(&mut self, event: DagEvent) {
361        if let Some(ref mut monitor) = self.monitor {
362            monitor.on_event(&event);
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::dag::builder::{DagBuilder, StageBuilder};
371    use crate::dag::pool::FixedPool;
372    use crate::dag::types::{FnTask, ShellTask, TaskOutcome};
373    use std::sync::atomic::{AtomicUsize, Ordering};
374    use std::sync::{Arc, Mutex};
375    use std::time::{Duration, Instant};
376
377    #[test]
378    fn test_single_task_success() {
379        let mut dag = DagBuilder::new();
380        dag.add_task("a", FnTask::new("task a", |_| TaskOutcome::Success));
381        let report = dag.build().unwrap().run().unwrap();
382        assert!(report.is_success());
383        assert!(report.completed.contains(&TaskId::from("a")));
384    }
385
386    #[test]
387    fn test_single_task_failure() {
388        let mut dag = DagBuilder::new();
389        dag.add_task(
390            "a",
391            FnTask::new("task a", |_| TaskOutcome::Failed("boom".into())),
392        );
393        dag.error_policy(ErrorPolicy::FailFast);
394        let report = dag.build().unwrap().run().unwrap();
395        assert!(!report.is_success());
396        assert!(report.failed.contains_key(&TaskId::from("a")));
397    }
398
399    #[test]
400    fn test_dependency_ordering() {
401        let order = Arc::new(Mutex::new(Vec::new()));
402        let o1 = order.clone();
403        let o2 = order.clone();
404        let o3 = order.clone();
405
406        let mut dag = DagBuilder::new();
407        dag.add_task(
408            "a",
409            FnTask::new("a", move |_| {
410                o1.lock().unwrap().push("a");
411                TaskOutcome::Success
412            }),
413        );
414        dag.add_task(
415            "b",
416            FnTask::new("b", move |_| {
417                o2.lock().unwrap().push("b");
418                TaskOutcome::Success
419            }),
420        );
421        dag.add_task(
422            "c",
423            FnTask::new("c", move |_| {
424                o3.lock().unwrap().push("c");
425                TaskOutcome::Success
426            }),
427        );
428        dag.add_dep("b", "a");
429        dag.add_dep("c", "b");
430        // Force sequential with fanout 1
431        dag.pool(FixedPool::new(1));
432
433        let report = dag.build().unwrap().run().unwrap();
434        assert!(report.is_success());
435
436        let executed = order.lock().unwrap();
437        assert_eq!(*executed, vec!["a", "b", "c"]);
438    }
439
440    #[test]
441    fn test_dependency_ordering_with_high_parallelism() {
442        // Verify dependencies are respected even with unlimited parallelism.
443        // Task B reads A's output — if B runs before A, it will fail.
444        let mut dag = DagBuilder::new();
445        dag.add_task(
446            "a",
447            FnTask::new("a", |ctx| {
448                std::thread::sleep(Duration::from_millis(20));
449                ctx.set_output(TaskId::from("a"), "from_a".to_string());
450                TaskOutcome::Success
451            }),
452        );
453        dag.add_task(
454            "b",
455            FnTask::new("b", |ctx| {
456                // This MUST see A's output — if deps are broken, this fails
457                let val: Option<String> = ctx.get_output(&TaskId::from("a"));
458                match val {
459                    Some(v) if v == "from_a" => {
460                        ctx.set_output(TaskId::from("b"), "from_b".to_string());
461                        TaskOutcome::Success
462                    }
463                    Some(v) => TaskOutcome::Failed(format!("unexpected value from a: {}", v)),
464                    None => {
465                        TaskOutcome::Failed("dependency a has no output — ran out of order!".into())
466                    }
467                }
468            }),
469        );
470        dag.add_task(
471            "c",
472            FnTask::new("c", |ctx| {
473                let val: Option<String> = ctx.get_output(&TaskId::from("b"));
474                match val {
475                    Some(v) if v == "from_b" => TaskOutcome::Success,
476                    _ => {
477                        TaskOutcome::Failed("dependency b has no output — ran out of order!".into())
478                    }
479                }
480            }),
481        );
482        dag.add_dep("b", "a");
483        dag.add_dep("c", "b");
484        // High parallelism — deps must still be respected
485        dag.pool(FixedPool::new(100));
486
487        let report = dag.build().unwrap().run().unwrap();
488        assert!(
489            report.is_success(),
490            "dependencies violated with high parallelism: failed={:?}",
491            report.failed
492        );
493    }
494
495    #[test]
496    fn test_parallel_independent_tasks() {
497        let counter = Arc::new(AtomicUsize::new(0));
498        let start = Instant::now();
499
500        let mut dag = DagBuilder::new();
501        for i in 0..5 {
502            let c = counter.clone();
503            dag.add_task(
504                format!("t{}", i),
505                FnTask::new(format!("task {}", i), move |_| {
506                    std::thread::sleep(Duration::from_millis(50));
507                    c.fetch_add(1, Ordering::SeqCst);
508                    TaskOutcome::Success
509                }),
510            );
511        }
512
513        let report = dag.build().unwrap().run().unwrap();
514        let elapsed = start.elapsed();
515
516        assert!(report.is_success());
517        assert_eq!(counter.load(Ordering::SeqCst), 5);
518        // Functional check: if tasks ran sequentially, it would take >=250ms (5 × 50ms).
519        // With parallelism it should be ~50-100ms. Use a generous bound to avoid CI flakiness.
520        assert!(
521            elapsed < Duration::from_secs(2),
522            "tasks took {:?}, likely not running in parallel (sequential would be >=250ms)",
523            elapsed
524        );
525    }
526
527    #[test]
528    fn test_continue_independent_on_failure() {
529        let mut dag = DagBuilder::new();
530        dag.add_task("good", FnTask::new("good", |_| TaskOutcome::Success));
531        dag.add_task(
532            "bad",
533            FnTask::new("bad", |_| TaskOutcome::Failed("fail".into())),
534        );
535        dag.add_task(
536            "depends_on_bad",
537            FnTask::new("after bad", |_| TaskOutcome::Success),
538        );
539        dag.add_dep("depends_on_bad", "bad");
540        dag.error_policy(ErrorPolicy::ContinueIndependent);
541
542        let report = dag.build().unwrap().run().unwrap();
543        assert!(!report.is_success());
544        assert!(report.completed.contains(&TaskId::from("good")));
545        assert!(report.failed.contains_key(&TaskId::from("bad")));
546        assert!(report.cancelled.contains(&TaskId::from("depends_on_bad")));
547    }
548
549    #[test]
550    fn test_output_passing_between_tasks() {
551        let mut dag = DagBuilder::new();
552        dag.add_task(
553            "producer",
554            FnTask::new("produce", |ctx| {
555                ctx.set_output(TaskId::from("producer"), "hello from producer".to_string());
556                TaskOutcome::Success
557            }),
558        );
559        dag.add_task(
560            "consumer",
561            FnTask::new("consume", |ctx| {
562                let val: String = ctx.get_output(&TaskId::from("producer")).unwrap();
563                assert_eq!(val, "hello from producer");
564                TaskOutcome::Success
565            }),
566        );
567        dag.add_dep("consumer", "producer");
568
569        let report = dag.build().unwrap().run().unwrap();
570        assert!(report.is_success());
571    }
572
573    #[test]
574    fn test_stage_builder_basic() {
575        let report = StageBuilder::new()
576            .resources(vec!["h1".into(), "h2".into(), "h3".into()])
577            .stage("build", None, |host| {
578                let h = host.to_string();
579                Box::new(FnTask::new(format!("build {}", h), move |ctx| {
580                    ctx.set_output(TaskId(format!("build:{}", h)), format!("built-{}", h));
581                    TaskOutcome::Success
582                }))
583            })
584            .stage("deploy", None, |host| {
585                let h = host.to_string();
586                Box::new(FnTask::new(format!("deploy {}", h), move |ctx| {
587                    // Should be able to read build output
588                    let built: String = ctx.get_output(&TaskId(format!("build:{}", h))).unwrap();
589                    assert!(built.starts_with("built-"));
590                    TaskOutcome::Success
591                }))
592            })
593            .build()
594            .unwrap()
595            .run()
596            .unwrap();
597
598        assert!(report.is_success());
599        assert_eq!(report.completed.len(), 6); // 3 hosts × 2 stages
600    }
601
602    #[test]
603    fn test_stage_builder_per_host_pipelining() {
604        // Verify that host1 can be in "deploy" while host2 is still in "build"
605        let start = Instant::now();
606
607        let report = StageBuilder::new()
608            .resources(vec!["fast".into(), "slow".into()])
609            .stage("build", None, |host| {
610                let h = host.to_string();
611                Box::new(FnTask::new(format!("build {}", h), move |_| {
612                    let delay = if h == "slow" { 100 } else { 10 };
613                    std::thread::sleep(Duration::from_millis(delay));
614                    TaskOutcome::Success
615                }))
616            })
617            .stage("deploy", None, |host| {
618                let h = host.to_string();
619                Box::new(FnTask::new(format!("deploy {}", h), move |_| {
620                    std::thread::sleep(Duration::from_millis(10));
621                    TaskOutcome::Success
622                }))
623            })
624            .build()
625            .unwrap()
626            .run()
627            .unwrap();
628
629        let elapsed = start.elapsed();
630        assert!(report.is_success());
631        assert_eq!(report.completed.len(), 4); // 2 hosts × 2 stages
632                                               // Pipelining means fast.deploy starts while slow is still building.
633                                               // Without pipelining (global barrier), total = build_all(100ms) + deploy_all(10ms) = 110ms+
634                                               // With pipelining, total ≈ max(slow_build + slow_deploy, fast_build + fast_deploy) = 110ms
635                                               // Use generous bound to avoid CI flakiness.
636        assert!(
637            elapsed < Duration::from_secs(2),
638            "pipelining took {:?}, expected well under 2s",
639            elapsed
640        );
641    }
642
643    #[test]
644    fn test_stage_builder_failure_cancels_later_stages() {
645        let report = StageBuilder::new()
646            .resources(vec!["good".into(), "bad".into()])
647            .stage("build", None, |host| {
648                let h = host.to_string();
649                Box::new(FnTask::new(format!("build {}", h), move |_| {
650                    if h == "bad" {
651                        TaskOutcome::Failed("build failed".into())
652                    } else {
653                        TaskOutcome::Success
654                    }
655                }))
656            })
657            .stage("deploy", None, |host| {
658                let h = host.to_string();
659                Box::new(FnTask::new(format!("deploy {}", h), move |_| {
660                    TaskOutcome::Success
661                }))
662            })
663            .error_policy(ErrorPolicy::ContinueIndependent)
664            .build()
665            .unwrap()
666            .run()
667            .unwrap();
668
669        assert!(!report.is_success());
670        // good: build + deploy both succeed
671        assert!(report.completed.contains(&TaskId::from("build:good")));
672        assert!(report.completed.contains(&TaskId::from("deploy:good")));
673        // bad: build fails, deploy cancelled
674        assert!(report.failed.contains_key(&TaskId::from("build:bad")));
675        assert!(report.cancelled.contains(&TaskId::from("deploy:bad")));
676    }
677
678    #[test]
679    fn test_concurrency_group_limit() {
680        // Use context to pass the counters through, avoiding 'static closure issues
681        let ctx = DagContext::new();
682        let max_concurrent = Arc::new(AtomicUsize::new(0));
683        let current = Arc::new(AtomicUsize::new(0));
684        ctx.set_state("max_concurrent", max_concurrent.clone());
685        ctx.set_state("current", current.clone());
686
687        let report = StageBuilder::new()
688            .resources((0..10).map(|i| format!("n{}", i)).collect())
689            .stage("work", Some(2), |host| {
690                let h = host.to_string();
691                Box::new(FnTask::new(format!("work {}", h), |ctx| {
692                    let cur: Arc<AtomicUsize> = ctx.get_state("current").unwrap();
693                    let mc: Arc<AtomicUsize> = ctx.get_state("max_concurrent").unwrap();
694                    let c = cur.fetch_add(1, Ordering::SeqCst) + 1;
695                    loop {
696                        let prev = mc.load(Ordering::SeqCst);
697                        if c <= prev
698                            || mc
699                                .compare_exchange(prev, c, Ordering::SeqCst, Ordering::SeqCst)
700                                .is_ok()
701                        {
702                            break;
703                        }
704                    }
705                    std::thread::sleep(Duration::from_millis(30));
706                    cur.fetch_sub(1, Ordering::SeqCst);
707                    TaskOutcome::Success
708                }))
709            })
710            .context(ctx)
711            .build()
712            .unwrap()
713            .run()
714            .unwrap();
715
716        assert!(report.is_success());
717        assert_eq!(report.completed.len(), 10);
718        assert!(
719            max_concurrent.load(Ordering::SeqCst) <= 2,
720            "max concurrent was {}, expected <= 2",
721            max_concurrent.load(Ordering::SeqCst)
722        );
723    }
724
725    // ─── Functional proof tests ────────────────────────────────────────
726    // These tests prove that parallelism, pipelining, and concurrency
727    // limits are working via observable state (not timing assertions).
728
729    #[test]
730    fn test_parallelism_proven_by_overlapping_execution() {
731        // PROOF: If tasks run in parallel, their execution windows overlap.
732        // Each task records its (start_time, end_time). If any two tasks
733        // have overlapping windows, parallelism is proven.
734        use std::time::SystemTime;
735
736        let ctx = DagContext::new();
737        let timestamps: Arc<Mutex<Vec<(String, u128, u128)>>> = Arc::new(Mutex::new(Vec::new()));
738        ctx.set_state("timestamps", timestamps.clone());
739
740        let mut dag = DagBuilder::new();
741        for i in 0..5 {
742            dag.add_task(
743                format!("t{}", i),
744                FnTask::new(format!("task {}", i), move |ctx| {
745                    let ts: Arc<Mutex<Vec<(String, u128, u128)>>> =
746                        ctx.get_state("timestamps").unwrap();
747                    let start = SystemTime::now()
748                        .duration_since(SystemTime::UNIX_EPOCH)
749                        .unwrap()
750                        .as_millis();
751                    std::thread::sleep(Duration::from_millis(50));
752                    let end = SystemTime::now()
753                        .duration_since(SystemTime::UNIX_EPOCH)
754                        .unwrap()
755                        .as_millis();
756                    ts.lock().unwrap().push((format!("t{}", i), start, end));
757                    TaskOutcome::Success
758                }),
759            );
760        }
761        // No dependencies — all should run in parallel
762        dag.context(ctx);
763
764        let report = dag.build().unwrap().run().unwrap();
765        assert!(report.is_success());
766
767        let ts = timestamps.lock().unwrap();
768        assert_eq!(ts.len(), 5);
769
770        // Check for overlapping execution windows
771        let mut found_overlap = false;
772        for i in 0..ts.len() {
773            for j in (i + 1)..ts.len() {
774                let (_, s1, e1) = &ts[i];
775                let (_, s2, e2) = &ts[j];
776                // Overlap: one starts before the other ends
777                if s1 < e2 && s2 < e1 {
778                    found_overlap = true;
779                    break;
780                }
781            }
782            if found_overlap {
783                break;
784            }
785        }
786        assert!(
787            found_overlap,
788            "no overlapping execution windows found — tasks may not be running in parallel. timestamps: {:?}",
789            *ts
790        );
791    }
792
793    #[test]
794    fn test_pipelining_proven_by_stage_overlap() {
795        // PROOF: With per-host pipelining, fast_host's stage 2 should START
796        // before slow_host's stage 1 ENDS. We record timestamps per task
797        // and check that deploy:fast starts before build:slow ends.
798        use std::time::SystemTime;
799
800        let ctx = DagContext::new();
801        let timestamps: Arc<Mutex<HashMap<String, (u128, u128)>>> =
802            Arc::new(Mutex::new(HashMap::new()));
803        ctx.set_state("timestamps", timestamps.clone());
804
805        let report = StageBuilder::new()
806            .resources(vec!["fast".into(), "slow".into()])
807            .stage("build", None, |host| {
808                let h = host.to_string();
809                Box::new(FnTask::new(format!("build {}", h), move |ctx| {
810                    let ts: Arc<Mutex<HashMap<String, (u128, u128)>>> =
811                        ctx.get_state("timestamps").unwrap();
812                    let start = SystemTime::now()
813                        .duration_since(SystemTime::UNIX_EPOCH)
814                        .unwrap()
815                        .as_millis();
816                    let delay = if h == "slow" { 200 } else { 10 };
817                    std::thread::sleep(Duration::from_millis(delay));
818                    let end = SystemTime::now()
819                        .duration_since(SystemTime::UNIX_EPOCH)
820                        .unwrap()
821                        .as_millis();
822                    ts.lock()
823                        .unwrap()
824                        .insert(format!("build:{}", h), (start, end));
825                    TaskOutcome::Success
826                }))
827            })
828            .stage("deploy", None, |host| {
829                let h = host.to_string();
830                Box::new(FnTask::new(format!("deploy {}", h), move |ctx| {
831                    let ts: Arc<Mutex<HashMap<String, (u128, u128)>>> =
832                        ctx.get_state("timestamps").unwrap();
833                    let start = SystemTime::now()
834                        .duration_since(SystemTime::UNIX_EPOCH)
835                        .unwrap()
836                        .as_millis();
837                    std::thread::sleep(Duration::from_millis(10));
838                    let end = SystemTime::now()
839                        .duration_since(SystemTime::UNIX_EPOCH)
840                        .unwrap()
841                        .as_millis();
842                    ts.lock()
843                        .unwrap()
844                        .insert(format!("deploy:{}", h), (start, end));
845                    TaskOutcome::Success
846                }))
847            })
848            .context(ctx)
849            .build()
850            .unwrap()
851            .run()
852            .unwrap();
853
854        assert!(report.is_success());
855
856        let ts = timestamps.lock().unwrap();
857        let build_slow = ts.get("build:slow").expect("build:slow missing");
858        let deploy_fast = ts.get("deploy:fast").expect("deploy:fast missing");
859
860        // Pipelining proof: deploy:fast should START before build:slow ENDS
861        assert!(
862            deploy_fast.0 < build_slow.1,
863            "pipelining not working: deploy:fast started at {} but build:slow ended at {} \
864             (deploy should start BEFORE build:slow finishes)",
865            deploy_fast.0,
866            build_slow.1
867        );
868    }
869
870    #[test]
871    fn test_concurrency_limit_proven_by_max_concurrent() {
872        // PROOF: With concurrency limit=2 and 10 tasks, the maximum number
873        // of simultaneously executing tasks must never exceed 2.
874        // We track this with an atomic counter that increments on start
875        // and decrements on end, recording the peak.
876        let ctx = DagContext::new();
877        let peak = Arc::new(AtomicUsize::new(0));
878        let active = Arc::new(AtomicUsize::new(0));
879        ctx.set_state("peak", peak.clone());
880        ctx.set_state("active", active.clone());
881
882        let report = StageBuilder::new()
883            .resources((0..10).map(|i| format!("n{}", i)).collect())
884            .stage("work", Some(2), |host| {
885                let h = host.to_string();
886                Box::new(FnTask::new(format!("work {}", h), |ctx| {
887                    let active: Arc<AtomicUsize> = ctx.get_state("active").unwrap();
888                    let peak: Arc<AtomicUsize> = ctx.get_state("peak").unwrap();
889
890                    let current = active.fetch_add(1, Ordering::SeqCst) + 1;
891                    // Update peak
892                    peak.fetch_max(current, Ordering::SeqCst);
893
894                    // Hold the slot for a bit so concurrency is observable
895                    std::thread::sleep(Duration::from_millis(30));
896
897                    active.fetch_sub(1, Ordering::SeqCst);
898                    TaskOutcome::Success
899                }))
900            })
901            .context(ctx)
902            .build()
903            .unwrap()
904            .run()
905            .unwrap();
906
907        assert!(report.is_success());
908        assert_eq!(report.completed.len(), 10);
909
910        let observed_peak = peak.load(Ordering::SeqCst);
911        assert!(
912            observed_peak <= 2,
913            "concurrency limit violated: peak concurrent tasks was {}, limit is 2",
914            observed_peak
915        );
916        // Also prove tasks DID run concurrently (peak > 1)
917        assert!(
918            observed_peak >= 2,
919            "tasks never ran concurrently: peak was {} (expected 2 with limit=2 and 10 tasks)",
920            observed_peak
921        );
922    }
923
924    #[test]
925    fn test_concurrency_limit_different_groups() {
926        // PROOF: Two concurrency groups with different limits operate independently.
927        // Group "fast" has limit=4, group "slow" has limit=1.
928        // Fast tasks should have peak=4, slow tasks should have peak=1.
929        let ctx = DagContext::new();
930        let fast_peak = Arc::new(AtomicUsize::new(0));
931        let fast_active = Arc::new(AtomicUsize::new(0));
932        let slow_peak = Arc::new(AtomicUsize::new(0));
933        let slow_active = Arc::new(AtomicUsize::new(0));
934        ctx.set_state("fast_peak", fast_peak.clone());
935        ctx.set_state("fast_active", fast_active.clone());
936        ctx.set_state("slow_peak", slow_peak.clone());
937        ctx.set_state("slow_active", slow_active.clone());
938
939        let mut dag = DagBuilder::new();
940        dag.concurrency_group("fast", 4);
941        dag.concurrency_group("slow", 1);
942
943        for i in 0..8 {
944            let id = format!("fast-{}", i);
945            dag.add_task(
946                id.clone(),
947                FnTask::new(format!("fast {}", i), |ctx| {
948                    let active: Arc<AtomicUsize> = ctx.get_state("fast_active").unwrap();
949                    let peak: Arc<AtomicUsize> = ctx.get_state("fast_peak").unwrap();
950                    let c = active.fetch_add(1, Ordering::SeqCst) + 1;
951                    peak.fetch_max(c, Ordering::SeqCst);
952                    std::thread::sleep(Duration::from_millis(30));
953                    active.fetch_sub(1, Ordering::SeqCst);
954                    TaskOutcome::Success
955                }),
956            );
957            dag.assign_group(id, "fast");
958        }
959
960        for i in 0..4 {
961            let id = format!("slow-{}", i);
962            dag.add_task(
963                id.clone(),
964                FnTask::new(format!("slow {}", i), |ctx| {
965                    let active: Arc<AtomicUsize> = ctx.get_state("slow_active").unwrap();
966                    let peak: Arc<AtomicUsize> = ctx.get_state("slow_peak").unwrap();
967                    let c = active.fetch_add(1, Ordering::SeqCst) + 1;
968                    peak.fetch_max(c, Ordering::SeqCst);
969                    std::thread::sleep(Duration::from_millis(20));
970                    active.fetch_sub(1, Ordering::SeqCst);
971                    TaskOutcome::Success
972                }),
973            );
974            dag.assign_group(id, "slow");
975        }
976
977        dag.context(ctx);
978        let report = dag.build().unwrap().run().unwrap();
979
980        assert!(report.is_success());
981        assert_eq!(report.completed.len(), 12);
982
983        let fp = fast_peak.load(Ordering::SeqCst);
984        let sp = slow_peak.load(Ordering::SeqCst);
985
986        assert!(fp <= 4, "fast group exceeded limit: peak={}, limit=4", fp);
987        assert!(sp <= 1, "slow group exceeded limit: peak={}, limit=1", sp);
988        // Prove fast group actually used concurrency
989        assert!(
990            fp >= 2,
991            "fast group didn't use concurrency: peak={}, expected >=2",
992            fp
993        );
994    }
995
996    #[test]
997    fn test_dependencies_prevent_early_execution() {
998        // PROOF: A dependent task NEVER executes before its dependency,
999        // even under high parallelism. We verify this by having the
1000        // dependent task check a flag that the dependency sets.
1001        // If the dependent runs first, the flag won't be set and the task fails.
1002        let ctx = DagContext::new();
1003        let flag = Arc::new(AtomicUsize::new(0));
1004        ctx.set_state("flag", flag.clone());
1005
1006        let mut dag = DagBuilder::new();
1007
1008        // 10 independent "setup" tasks that all set flags
1009        for i in 0..10 {
1010            dag.add_task(
1011                format!("setup-{}", i),
1012                FnTask::new(format!("setup {}", i), move |ctx| {
1013                    let f: Arc<AtomicUsize> = ctx.get_state("flag").unwrap();
1014                    std::thread::sleep(Duration::from_millis(10));
1015                    f.fetch_add(1, Ordering::SeqCst);
1016                    TaskOutcome::Success
1017                }),
1018            );
1019        }
1020
1021        // "check" task depends on ALL setup tasks
1022        dag.add_task(
1023            "check",
1024            FnTask::new("check all flags", move |ctx| {
1025                let f: Arc<AtomicUsize> = ctx.get_state("flag").unwrap();
1026                let count = f.load(Ordering::SeqCst);
1027                if count == 10 {
1028                    TaskOutcome::Success
1029                } else {
1030                    TaskOutcome::Failed(format!(
1031                        "only {}/10 setup tasks completed before check ran!",
1032                        count
1033                    ))
1034                }
1035            }),
1036        );
1037
1038        for i in 0..10 {
1039            dag.add_dep("check", format!("setup-{}", i));
1040        }
1041
1042        // High parallelism — check must still wait for all 10
1043        dag.pool(FixedPool::new(100));
1044        dag.context(ctx);
1045
1046        let report = dag.build().unwrap().run().unwrap();
1047        assert!(
1048            report.is_success(),
1049            "dependency violation: {:?}",
1050            report.failed
1051        );
1052    }
1053
1054    #[test]
1055    fn test_shell_task() {
1056        let mut dag = DagBuilder::new();
1057        dag.add_task(
1058            "echo",
1059            ShellTask {
1060                command: "echo hello".into(),
1061                description: "echo test".into(),
1062                resource: None,
1063            },
1064        );
1065        let report = dag.build().unwrap().run().unwrap();
1066        assert!(report.is_success());
1067    }
1068
1069    #[test]
1070    fn test_shell_task_failure() {
1071        let mut dag = DagBuilder::new();
1072        dag.add_task(
1073            "fail",
1074            ShellTask {
1075                command: "exit 42".into(),
1076                description: "fail test".into(),
1077                resource: None,
1078            },
1079        );
1080        dag.error_policy(ErrorPolicy::FailFast);
1081        let report = dag.build().unwrap().run().unwrap();
1082        assert!(!report.is_success());
1083        assert!(report
1084            .failed
1085            .get(&TaskId::from("fail"))
1086            .unwrap()
1087            .contains("42"));
1088    }
1089    #[test]
1090    fn completion_barrier_waits_for_success_failed_and_cancelled_parents() {
1091        for _ in 0..24 {
1092            let healthy_finished = Arc::new(std::sync::atomic::AtomicBool::new(false));
1093            let mut dag = DagBuilder::new();
1094            dag.add_task(
1095                "failed",
1096                FnTask::new("fail", |_| TaskOutcome::Failed("injected".into())),
1097            );
1098            let finished = healthy_finished.clone();
1099            dag.add_task(
1100                "healthy",
1101                FnTask::new("healthy", move |_| {
1102                    finished.store(true, Ordering::SeqCst);
1103                    TaskOutcome::Success
1104                }),
1105            );
1106            for id in ["cancelled", "cancelled-child"] {
1107                dag.add_task(
1108                    id,
1109                    FnTask::new("must not run", |_| {
1110                        TaskOutcome::Failed("cancelled task dispatched".into())
1111                    }),
1112                );
1113            }
1114            dag.add_dep("cancelled", "failed");
1115            dag.add_dep("cancelled-child", "cancelled");
1116            let finished = healthy_finished.clone();
1117            dag.add_task(
1118                "settled",
1119                FnTask::new("barrier", move |_| {
1120                    if finished.load(Ordering::SeqCst) {
1121                        TaskOutcome::Success
1122                    } else {
1123                        TaskOutcome::Failed("barrier ran before healthy predecessor".into())
1124                    }
1125                }),
1126            );
1127            for id in ["failed", "healthy", "cancelled", "cancelled-child"] {
1128                dag.add_completion_dep("settled", id);
1129            }
1130            dag.add_task(
1131                "after",
1132                FnTask::new("after barrier", |_| TaskOutcome::Success),
1133            );
1134            dag.add_dep("after", "settled");
1135            dag.error_policy(ErrorPolicy::ContinueIndependent);
1136            let report = dag.build().unwrap().run().unwrap();
1137            assert_eq!(report.completed.len(), 3);
1138            assert!(report.completed.contains(&"settled".into()));
1139            assert!(report.completed.contains(&"after".into()));
1140            assert_eq!(report.failed.len(), 1);
1141            assert_eq!(report.cancelled.len(), 2);
1142        }
1143    }
1144
1145    #[test]
1146    fn completion_dependency_does_not_override_another_required_success() {
1147        let mut dag = DagBuilder::new();
1148        for id in ["failure-one", "failure-two"] {
1149            dag.add_task(
1150                id,
1151                FnTask::new("fail", |_| TaskOutcome::Failed("injected".into())),
1152            );
1153        }
1154        dag.add_task(
1155            "mixed",
1156            FnTask::new("must not run", |_| {
1157                TaskOutcome::Failed("success dependency failed".into())
1158            }),
1159        );
1160        dag.add_completion_dep("mixed", "failure-one");
1161        dag.add_dep("mixed", "failure-two");
1162        dag.error_policy(ErrorPolicy::ContinueIndependent);
1163        let report = dag.build().unwrap().run().unwrap();
1164        assert!(report.cancelled.contains(&"mixed".into()));
1165        assert_eq!(report.failed.len(), 2);
1166    }
1167}