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