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 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 if !self.can_dispatch_group(&task_id, &group_active) {
135 deferred.push_back(task_id);
136 continue;
137 }
138
139 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 let task = match self.graph.take_task(&task_id) {
152 Some(t) => t,
153 None => continue, };
155
156 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 ready.extend(retry_ready);
178
179 if in_flight.is_empty() && ready.is_empty() && deferred.is_empty() {
181 break;
182 }
183
184 if in_flight.is_empty() && ready.is_empty() && !deferred.is_empty() {
186 return Err(DagError::General(format!(
188 "deadlock: {} deferred tasks but nothing in flight",
189 deferred.len()
190 )));
191 }
192
193 let event = rx.recv().map_err(|_| DagError::ChannelClosed)?;
195 let CompletionEvent { task_id, outcome } = event;
196
197 in_flight.remove(&task_id);
198
199 self.pool.release(None);
201
202 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 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 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 fn check_cache(&self, task_id: &TaskId) -> Option<Box<dyn std::any::Any + Send>> {
261 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 if let Some(ref cache) = self.global_cache {
269 return cache.check(task_id, &self.context);
270 }
271 None
272 }
273
274 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 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 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 pending_deps.remove(&id);
330
331 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 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 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 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 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 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 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); }
581
582 #[test]
583 fn test_stage_builder_per_host_pipelining() {
584 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); 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 assert!(report.completed.contains(&TaskId::from("build:good")));
652 assert!(report.completed.contains(&TaskId::from("deploy:good")));
653 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 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 #[test]
710 fn test_parallelism_proven_by_overlapping_execution() {
711 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 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 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 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 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 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 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 peak.fetch_max(current, Ordering::SeqCst);
873
874 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 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 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 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 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 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 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 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}