1use 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
20struct CompletionEvent {
22 task_id: TaskId,
23 outcome: TaskOutcome,
24}
25
26#[derive(Debug, Clone)]
28pub enum DagEvent {
29 TaskStarted(TaskId),
30 TaskCompleted(TaskId, TaskOutcome),
31 TaskSkipped(TaskId),
32 TaskCancelled(TaskId),
33}
34
35pub trait DagMonitor: Send {
37 fn on_event(&mut self, event: &DagEvent);
38}
39
40#[derive(Debug)]
42pub struct DagReport {
43 pub completed: HashSet<TaskId>,
45 pub skipped: HashSet<TaskId>,
47 pub failed: HashMap<TaskId, String>,
49 pub cancelled: HashSet<TaskId>,
51}
52
53impl DagReport {
54 pub fn is_success(&self) -> bool {
56 self.failed.is_empty()
57 }
58
59 pub fn total(&self) -> usize {
61 self.completed.len() + self.skipped.len() + self.failed.len() + self.cancelled.len()
62 }
63}
64
65pub 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 pub fn run(mut self) -> Result<DagReport> {
81 self.graph.validate()?;
82
83 let (tx, rx) = mpsc::channel::<CompletionEvent>();
84
85 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 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(); 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 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 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 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 if !self.can_dispatch_group(&task_id, &group_active) {
138 deferred.push_back(task_id);
139 continue;
140 }
141
142 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 let task = match self.graph.take_task(&task_id) {
155 Some(t) => t,
156 None => continue, };
158
159 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 ready.extend(retry_ready);
181
182 if in_flight.is_empty() && ready.is_empty() && deferred.is_empty() {
184 break;
185 }
186
187 if in_flight.is_empty() && ready.is_empty() && !deferred.is_empty() {
189 return Err(DagError::General(format!(
191 "deadlock: {} deferred tasks but nothing in flight",
192 deferred.len()
193 )));
194 }
195
196 let event = rx.recv().map_err(|_| DagError::ChannelClosed)?;
198 let CompletionEvent { task_id, outcome } = event;
199
200 in_flight.remove(&task_id);
201
202 self.pool.release(None);
204
205 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 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 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 fn check_cache(&self, task_id: &TaskId) -> Option<Box<dyn std::any::Any + Send>> {
274 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 if let Some(ref cache) = self.global_cache {
282 return cache.check(task_id, &self.context);
283 }
284 None
285 }
286
287 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 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 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 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 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 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 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 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 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); }
601
602 #[test]
603 fn test_stage_builder_per_host_pipelining() {
604 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); 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 assert!(report.completed.contains(&TaskId::from("build:good")));
672 assert!(report.completed.contains(&TaskId::from("deploy:good")));
673 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 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 #[test]
730 fn test_parallelism_proven_by_overlapping_execution() {
731 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 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 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 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 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 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 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 peak.fetch_max(current, Ordering::SeqCst);
893
894 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 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 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 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 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 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 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 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}