feat: dagrunner, initial version

This commit is contained in:
swaits committed 2025-10-07 16:31:03 -07:00
commit 7412287ee4
122 files changed
+23101

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
//! Task builder with type-state pattern for dependency tracking.
//!
//! TaskBuilder uses compile-time type checking to ensure dependencies are wired correctly.
//! The type parameter tracks whether dependencies have been specified yet.
use crate::deps::DepsTuple;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::{NodeId, TaskHandle};
/// Marker trait to identify the unit type at compile time.
///
/// This is used to enforce that tasks with non-unit inputs MUST call `.depends_on()`,
/// while tasks with unit inputs can be used directly.
pub trait IsUnitType {}
/// Only the unit type implements IsUnitType
impl IsUnitType for () {}
/// Node builder that tracks dependency completion via type state.
///
/// A `TaskBuilder<Tk, Deps>` is returned from [`DagRunner::add_task`] and uses the type-state
/// pattern to ensure dependencies are wired correctly at compile time:
/// - `TaskBuilder<Tk, Pending>`: No dependencies specified yet
/// - `TaskBuilder<Tk, Tk::Input>`: Dependencies complete; acts as a [`TaskHandle<Tk::Output>`]
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// // Using tuple struct for simple constants
/// struct Constant(i32);
///
/// #[task]
/// impl Constant {
/// async fn run(&mut self) -> i32 { self.0 }
/// }
///
/// // Task with state constructed via ::new()
/// struct Multiplier { factor: i32 }
///
/// impl Multiplier {
/// fn new(factor: i32) -> Self { Self { factor } }
/// }
///
/// #[task]
/// impl Multiplier {
/// async fn run(&mut self, x: &i32) -> i32 { x * self.factor }
/// }
///
/// # async {
/// let mut dag = DagRunner::new();
///
/// // Construct with initial value (tuple struct)
/// let a = dag.add_task(Constant(10));
/// // a is TaskBuilder<_, Pending> since no dependencies needed (Input = ())
///
/// // Construct with ::new() pattern
/// let b = dag.add_task(Multiplier::new(2));
/// // b is TaskBuilder<_, Pending> until we call depends_on()
///
/// let b = b.depends_on(&a);
/// // Now b is a TaskHandle<i32>
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
/// assert_eq!(dag.get(&b).unwrap(), 20);
/// # };
/// ```
pub struct TaskBuilder<'a, Tk: Task, Deps> {
pub(crate) id: NodeId,
pub(crate) dag: &'a DagRunner,
pub(crate) _phantom: std::marker::PhantomData<(Tk, Deps)>,
}
impl<'a, Tk: Task, Deps> TaskBuilder<'a, Tk, Deps> {
/// Provide all dependencies exactly once as a tuple.
///
/// The dependencies must match the task's `Input` type exactly:
/// - `Input = ()`: Do **not** call `depends_on`
/// - `Input = T`: Pass `&TaskHandle` or `(&TaskHandle,)`
/// - `Input = (A, B, ...)`: Pass `(&TaskHandle<A>, &TaskHandle<B>, ...)`
///
/// The order of dependencies in the tuple must match the order in `Input`.
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// // Tuple struct
/// struct Value(i32);
///
/// #[task]
/// impl Value {
/// async fn run(&mut self) -> i32 { self.0 }
/// }
///
/// // Tuple struct with multiplier
/// struct Scale(i32);
///
/// #[task]
/// impl Scale {
/// async fn run(&mut self, x: &i32) -> i32 { x * self.0 }
/// }
///
/// // Unit struct
/// struct Add;
///
/// #[task]
/// impl Add {
/// async fn run(&mut self, a: &i32, b: &i32) -> i32 { a + b }
/// }
///
/// # async {
/// let dag = DagRunner::new();
///
/// let x = dag.add_task(Value(2));
/// let y = dag.add_task(Value(3));
///
/// // Single dependency
/// let double = dag.add_task(Scale(2)).depends_on(&x);
///
/// // Multiple dependencies: tuple form
/// let sum = dag.add_task(Add).depends_on((&x, &y));
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
/// # };
/// ```
pub fn depends_on<D>(self, deps: D) -> TaskHandle<Tk::Output>
where
D: DepsTuple<Tk::Input>,
{
// Register dependencies in the DAG
let dep_ids = deps.to_node_ids();
let mut edges = self.dag.edges.lock();
let mut dependents = self.dag.dependents.lock();
for &dep_id in &dep_ids {
// Add edge from this node to dependency
if let Some(node_edges) = edges.get_mut(&self.id) {
node_edges.push(dep_id);
}
// Add this node as dependent of the dependency
if let Some(node_dependents) = dependents.get_mut(&dep_id) {
node_dependents.push(self.id);
}
}
TaskHandle {
id: self.id,
_phantom: std::marker::PhantomData,
}
}
}
#[cfg(test)]
mod tests;
// Note: We cannot safely implement AsRef<TaskHandle<Tk::Output>> for TaskBuilder<Tk, Deps>
// because Handle is Copy and returning a reference to a temporary would be unsound.
// Instead, users should use From/Into conversions or call .into() explicitly.
+114
View File
@@ -0,0 +1,114 @@
//! Unit tests for builder module
use crate::builder::IsUnitType;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::TaskHandle;
struct TestTask {
value: i32,
}
#[crate::task]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
struct TestTaskWithInput;
#[crate::task]
impl TestTaskWithInput {
async fn run(input: &i32) -> i32 {
input * 2
}
}
#[test]
fn test_task_builder_from_conversion() {
// Test line 165 in builder.rs - the From implementation
let dag = DagRunner::new();
let builder = dag.add_task(TestTask { value: 42 });
// Convert builder to TaskHandle
let handle: TaskHandle<i32> = builder.into();
// Check that the ID is preserved
assert_eq!(handle.id.0, 0); // First task should have ID 0
}
#[test]
fn test_is_unit_type_trait() {
// Test that IsUnitType is correctly implemented for ()
fn assert_is_unit_type<T: IsUnitType>() {}
// This should compile - () implements IsUnitType
assert_is_unit_type::<()>();
// We can't test the negative case (non-unit types don't implement it)
// because that would be a compile error
}
#[test]
fn test_task_builder_depends_on_returns_handle() {
let dag = DagRunner::new();
let source = dag.add_task(TestTask { value: 10 });
let dependent = dag.add_task(TestTaskWithInput).depends_on(&source);
// depends_on should return a TaskHandle
let _handle: TaskHandle<i32> = dependent;
}
#[test]
fn test_task_builder_chain() {
let dag = DagRunner::new();
// Test chaining multiple tasks
let t1 = dag.add_task(TestTask { value: 1 });
let t2 = dag.add_task(TestTaskWithInput).depends_on(&t1);
let t3 = dag.add_task(TestTaskWithInput).depends_on(t2);
// All should return TaskHandles
let _: TaskHandle<i32> = t1.into();
let _: TaskHandle<i32> = t2;
let _: TaskHandle<i32> = t3;
}
#[test]
fn test_multiple_dependencies() {
struct AddTask;
#[crate::task]
impl AddTask {
async fn run(a: &i32, b: &i32) -> i32 {
a + b
}
}
let dag = DagRunner::new();
let a = dag.add_task(TestTask { value: 10 });
let b = dag.add_task(TestTask { value: 20 });
// Test multiple dependencies
let sum = dag.add_task(AddTask).depends_on((&a, &b));
// Should return a TaskHandle
let _: TaskHandle<i32> = sum;
}
#[test]
fn test_task_builder_stores_correct_id() {
let dag = DagRunner::new();
let builder1 = dag.add_task(TestTask { value: 1 });
let builder2 = dag.add_task(TestTask { value: 2 });
let builder3 = dag.add_task(TestTask { value: 3 });
// Check that builders have sequential IDs
assert_eq!(builder1.id.0, 0);
assert_eq!(builder2.id.0, 1);
assert_eq!(builder3.id.0, 2);
}
+114
View File
@@ -0,0 +1,114 @@
//! Dependency tuple trait and implementations.
//!
//! This module defines the DepsTuple trait that allows specifying dependencies in
//! various forms (handles, builders, tuples). It uses macro-generated implementations
//! for different tuple sizes.
//!
//! This trait is internal and not meant for external implementation. Users should only
//! use the provided implementations.
use crate::builder::TaskBuilder;
use crate::task::Task;
use crate::types::{NodeId, TaskHandle};
pub(crate) trait DepsTuple<Input> {
fn to_node_ids(&self) -> Vec<NodeId>;
}
// Implementation for unit (no dependencies)
impl DepsTuple<()> for () {
fn to_node_ids(&self) -> Vec<NodeId> {
Vec::new()
}
}
// Implementation for single dependency (all forms)
impl<T> DepsTuple<T> for &TaskHandle<T> {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.id]
}
}
impl<T> DepsTuple<T> for TaskHandle<T> {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.id]
}
}
impl<T> DepsTuple<T> for (&TaskHandle<T>,) {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.0.id]
}
}
impl<T> DepsTuple<T> for (TaskHandle<T>,) {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.0.id]
}
}
// Support for &TaskBuilder
impl<'a, Tk: Task, Deps> DepsTuple<Tk::Output> for &TaskBuilder<'a, Tk, Deps> {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.id]
}
}
impl<'a, Tk: Task, Deps> DepsTuple<Tk::Output> for (&TaskBuilder<'a, Tk, Deps>,) {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.0.id]
}
}
/// Macro to implement DepsTuple for different tuple sizes.
///
/// This macro exists because Rust lacks variadic generics - we need separate implementations
/// for each tuple size.
///
/// For each tuple size, generates two implementations:
/// - `(&TaskHandle<A>, &TaskHandle<B>, ...)` → `DepsTuple<(A, B, ...)>`
/// - `(&TaskBuilder<TkA, _>, &TaskBuilder<TkB, _>, ...)` → `DepsTuple<(TkA::Output, TkB::Output, ...)>`
///
/// This allows flexible dependency specification:
/// ```ignore
/// // Using handles
/// task.depends_on((&handle_a, &handle_b))
///
/// // Using builders directly
/// task.depends_on((&dag.add_task(TaskA), &dag.add_task(TaskB)))
/// ```
macro_rules! impl_deps_tuple {
($($T:ident : $Tk:ident : $D:ident),+) => {
// For &TaskHandle tuples
impl<$($T),+> DepsTuple<($($T,)+)> for ($(&TaskHandle<$T>,)+) {
#[allow(non_snake_case)]
fn to_node_ids(&self) -> Vec<NodeId> {
let ($($T,)+) = self;
vec![$($T.id,)+]
}
}
// For &TaskBuilder tuples
impl<'a, $($Tk: Task, $D),+> DepsTuple<($($Tk::Output,)+)> for ($(&TaskBuilder<'a, $Tk, $D>,)+) {
#[allow(non_snake_case)]
fn to_node_ids(&self) -> Vec<NodeId> {
let ($($T,)+) = self;
vec![$($T.id,)+]
}
}
};
}
// Generate DepsTuple implementations for tuples of size 2-8.
// Each invocation generates 2 implementations (TaskHandle refs and TaskBuilder refs).
// Supporting up to 8 elements covers the vast majority of use cases.
impl_deps_tuple!(A:TkA:DA, B:TkB:DB);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC, D:TkD:DD);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC, D:TkD:DD, E:TkE:DE);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC, D:TkD:DD, E:TkE:DE, F:TkF:DF);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC, D:TkD:DD, E:TkE:DE, F:TkF:DF, G:TkG:DG);
impl_deps_tuple!(A:TkA:DA, B:TkB:DB, C:TkC:DC, D:TkD:DD, E:TkE:DE, F:TkF:DF, G:TkG:DG, H:TkH:DH);
#[cfg(test)]
mod tests;
+198
View File
@@ -0,0 +1,198 @@
//! Unit tests for deps module
use crate::deps::DepsTuple;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::{NodeId, TaskHandle};
// Test task for dependency testing
struct TestTask {
value: i32,
}
#[crate::task]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
struct TestTaskWithInput;
#[crate::task]
impl TestTaskWithInput {
#[allow(dead_code)]
async fn run(input: &i32) -> i32 {
input * 2
}
}
#[test]
fn test_deps_tuple_unit() {
// Test lines 20-22 in deps.rs - unit type implementation
let unit = ();
let node_ids = unit.to_node_ids();
assert!(node_ids.is_empty());
}
#[test]
fn test_deps_tuple_single_handle_ref() {
// Test lines 26-30 in deps.rs - &TaskHandle<T>
let handle: TaskHandle<i32> = TaskHandle {
id: NodeId(42),
_phantom: std::marker::PhantomData,
};
let node_ids = handle.to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(42));
}
#[test]
fn test_deps_tuple_single_handle_owned() {
// Test lines 32-36 in deps.rs - TaskHandle<T>
let handle: TaskHandle<String> = TaskHandle {
id: NodeId(99),
_phantom: std::marker::PhantomData,
};
let handle_copy = handle; // Copy for use
let node_ids = handle_copy.to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(99));
}
#[test]
fn test_deps_tuple_single_handle_tuple_ref() {
// Test lines 38-42 in deps.rs - (&TaskHandle<T>,)
let handle: TaskHandle<bool> = TaskHandle {
id: NodeId(7),
_phantom: std::marker::PhantomData,
};
let node_ids = (&handle,).to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(7));
}
#[test]
fn test_deps_tuple_single_handle_tuple_owned() {
// Test lines 44-48 in deps.rs - (TaskHandle<T>,)
let handle: TaskHandle<Vec<u8>> = TaskHandle {
id: NodeId(13),
_phantom: std::marker::PhantomData,
};
let handle_copy = handle; // Copy for use
let node_ids = (handle_copy,).to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(13));
}
#[test]
fn test_deps_tuple_task_builder_ref() {
// Test lines 51-55 in deps.rs - &TaskBuilder
let dag = DagRunner::new();
let builder = dag.add_task(TestTask { value: 42 });
// We need to test the DepsTuple implementation directly
// The builder has an id field we can check
let node_ids = (&builder).to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], builder.id);
}
#[test]
fn test_deps_tuple_task_builder_tuple() {
// Test lines 57-61 in deps.rs - (&TaskBuilder,)
let dag = DagRunner::new();
let builder = dag.add_task(TestTask { value: 100 });
let node_ids = (&builder,).to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], builder.id);
}
#[test]
fn test_deps_tuple_multiple_handles() {
// Test macro-generated implementations for multiple handles
let handle1: TaskHandle<i32> = TaskHandle {
id: NodeId(1),
_phantom: std::marker::PhantomData,
};
let handle2: TaskHandle<String> = TaskHandle {
id: NodeId(2),
_phantom: std::marker::PhantomData,
};
// Test 2-tuple
let node_ids = (&handle1, &handle2).to_node_ids();
assert_eq!(node_ids.len(), 2);
assert_eq!(node_ids[0], NodeId(1));
assert_eq!(node_ids[1], NodeId(2));
// Test 3-tuple
let handle3: TaskHandle<bool> = TaskHandle {
id: NodeId(3),
_phantom: std::marker::PhantomData,
};
let node_ids = (&handle1, &handle2, &handle3).to_node_ids();
assert_eq!(node_ids.len(), 3);
assert_eq!(node_ids[0], NodeId(1));
assert_eq!(node_ids[1], NodeId(2));
assert_eq!(node_ids[2], NodeId(3));
}
#[test]
fn test_deps_tuple_multiple_builders() {
// Test macro-generated implementations for multiple builders
let dag = DagRunner::new();
let builder1 = dag.add_task(TestTask { value: 10 });
let builder2 = dag.add_task(TestTask { value: 20 });
let builder3 = dag.add_task(TestTask { value: 30 });
// Test 2-tuple of builders
let node_ids = (&builder1, &builder2).to_node_ids();
assert_eq!(node_ids.len(), 2);
assert_eq!(node_ids[0], builder1.id);
assert_eq!(node_ids[1], builder2.id);
// Test 3-tuple of builders
let node_ids = (&builder1, &builder2, &builder3).to_node_ids();
assert_eq!(node_ids.len(), 3);
assert_eq!(node_ids[0], builder1.id);
assert_eq!(node_ids[1], builder2.id);
assert_eq!(node_ids[2], builder3.id);
}
#[test]
fn test_deps_tuple_large_tuples() {
// Test that larger tuples work (up to 16 elements)
let mut handles = Vec::new();
for i in 0..8 {
handles.push(TaskHandle::<i32> {
id: NodeId(i),
_phantom: std::marker::PhantomData,
});
}
// Test 4-tuple
let node_ids = (&handles[0], &handles[1], &handles[2], &handles[3]).to_node_ids();
assert_eq!(node_ids.len(), 4);
for (i, node_id) in node_ids.iter().enumerate().take(4) {
assert_eq!(*node_id, NodeId(i));
}
// Test 8-tuple
let node_ids = (
&handles[0],
&handles[1],
&handles[2],
&handles[3],
&handles[4],
&handles[5],
&handles[6],
&handles[7],
)
.to_node_ids();
assert_eq!(node_ids.len(), 8);
for (i, node_id) in node_ids.iter().enumerate().take(8) {
assert_eq!(*node_id, NodeId(i));
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Error types for DAG operations.
//!
//! This module defines the error types that can occur during DAG construction and execution.
/// Errors that can occur during DAG construction and execution
#[derive(Debug, Clone, PartialEq)]
pub enum DagError {
/// A cycle was detected in the DAG
CycleDetected {
nodes: Vec<usize>,
description: String,
},
/// Invalid dependency: task does not exist
InvalidDependency { task_id: usize },
/// Type mismatch in task dependencies
TypeMismatch {
expected: &'static str,
found: &'static str,
},
/// Task panicked during execution
TaskPanicked {
task_id: usize,
panic_message: String,
},
/// Result not found for task
ResultNotFound { task_id: usize },
}
impl std::fmt::Display for DagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DagError::CycleDetected { nodes, description } => {
write!(
f,
"Cycle detected in DAG: {}\n\
Nodes involved: {:?}\n\
\n\
A DAG cannot have cycles. Review your dependency graph to identify \
and remove circular dependencies.",
description, nodes
)
}
DagError::InvalidDependency { task_id } => {
write!(
f,
"Invalid dependency: task #{} does not exist.\n\
\n\
Ensure all task handles reference tasks that have been added to the DAG.",
task_id
)
}
DagError::TypeMismatch { expected, found } => {
write!(
f,
"Type mismatch in task dependencies.\n\
Expected: {}\n\
Found: {}\n\
\n\
Verify that dependency types match the task's Input type exactly.",
expected, found
)
}
DagError::TaskPanicked {
task_id,
panic_message,
} => {
write!(
f,
"Task #{} panicked during execution: {}\n\
\n\
A task panicked, indicating a bug. The entire DAG execution is aborted.",
task_id, panic_message
)
}
DagError::ResultNotFound { task_id } => {
write!(
f,
"Result not found for task #{}.\n\
\n\
Call dag.run() before accessing results with get().",
task_id
)
}
}
}
}
impl std::error::Error for DagError {}
/// Result type for DAG operations
pub type DagResult<T> = Result<T, DagError>;
#[cfg(test)]
mod tests;
+129
View File
@@ -0,0 +1,129 @@
//! Unit tests for error module
use crate::error::{DagError, DagResult};
#[test]
fn test_dag_error_display_invalid_dependency() {
// Test lines 47-54 in error.rs
let err = DagError::InvalidDependency { task_id: 42 };
let display = format!("{}", err);
assert!(display.contains("Invalid dependency"));
assert!(display.contains("task #42"));
assert!(display.contains("does not exist"));
assert!(display.contains("Ensure all task handles"));
}
#[test]
fn test_dag_error_display_type_mismatch() {
// Test lines 56-65 in error.rs
let err = DagError::TypeMismatch {
expected: "i32",
found: "String",
};
let display = format!("{}", err);
assert!(display.contains("Type mismatch"));
assert!(display.contains("Expected: i32"));
assert!(display.contains("Found: String"));
assert!(display.contains("Verify that dependency types"));
}
#[test]
fn test_dag_error_display_task_panicked() {
// Test lines 67-77 in error.rs
let err = DagError::TaskPanicked {
task_id: 99,
panic_message: "assertion failed".to_string(),
};
let display = format!("{}", err);
assert!(display.contains("Task #99 panicked"));
assert!(display.contains("assertion failed"));
assert!(display.contains("indicating a bug"));
assert!(display.contains("entire DAG execution is aborted"));
}
#[test]
fn test_dag_error_display_cycle_detected() {
let err = DagError::CycleDetected {
nodes: vec![1, 2, 3, 1],
description: "A->B->C->A".to_string(),
};
let display = format!("{}", err);
assert!(display.contains("Cycle detected"));
assert!(display.contains("A->B->C->A"));
assert!(display.contains("[1, 2, 3, 1]"));
assert!(display.contains("cannot have cycles"));
}
#[test]
fn test_dag_error_display_result_not_found() {
let err = DagError::ResultNotFound { task_id: 7 };
let display = format!("{}", err);
assert!(display.contains("Result not found"));
assert!(display.contains("task #7"));
assert!(display.contains("Call dag.run()"));
}
#[test]
fn test_dag_error_std_error_impl() {
// Test that DagError implements std::error::Error
let err = DagError::InvalidDependency { task_id: 1 };
let err_ref: &dyn std::error::Error = &err;
// Should be able to call Error trait methods
let _ = err_ref.to_string();
assert!(err_ref.source().is_none()); // DagError doesn't chain errors
}
#[test]
fn test_dag_error_equality() {
let err1 = DagError::InvalidDependency { task_id: 42 };
let err2 = DagError::InvalidDependency { task_id: 42 };
let err3 = DagError::InvalidDependency { task_id: 99 };
assert_eq!(err1, err2);
assert_ne!(err1, err3);
}
#[test]
fn test_dag_error_clone() {
let err = DagError::TaskPanicked {
task_id: 10,
panic_message: "test panic".to_string(),
};
let cloned = err.clone();
assert_eq!(err, cloned);
}
#[test]
fn test_dag_error_debug() {
let err = DagError::TypeMismatch {
expected: "i32",
found: "bool",
};
let debug_str = format!("{:?}", err);
assert!(debug_str.contains("TypeMismatch"));
assert!(debug_str.contains("expected"));
assert!(debug_str.contains("found"));
}
#[test]
fn test_dag_result_type_alias() {
// Test that DagResult works as expected
fn returns_dag_result() -> DagResult<i32> {
Ok(42)
}
fn returns_dag_error() -> DagResult<String> {
Err(DagError::ResultNotFound { task_id: 1 })
}
assert!(returns_dag_result().is_ok());
assert!(returns_dag_error().is_err());
}
+291
View File
@@ -0,0 +1,291 @@
//! Input extraction for task dependencies via oneshot channels.
//!
//! This module handles extracting typed inputs from task dependencies via oneshot channels.
//!
//! # How it works
//!
//! When a task executes, it needs to extract its inputs from type-erased oneshot receivers.
//! Outputs are wrapped in Arc for efficient fanout (cheap Arc clones instead of data clones).
//! This module provides the ExtractInput trait with implementations for:
//! - `()` for tasks with no dependencies
//! - Common types (`i32`, `String`, etc.) for tasks with a single dependency
//! - `(A, B, ...)` tuples for tasks with multiple dependencies (awaited concurrently)
//!
//! # Why macros?
//!
//! Rust lacks variadic generics, so we use macros to generate implementations for:
//! - Common primitive/standard types (one impl per type)
//! - Tuples of different sizes (one impl per size, up to 8 elements)
//!
//! This is standard practice in Rust for working with tuples of different arities.
use std::any::Any;
use std::sync::Arc;
use futures::channel::oneshot;
/// Helper trait for async input extraction from oneshot channels.
///
/// Type erasure occurs only at the ExecutableNode trait boundary - by the time
/// we're in ExtractInput, we know the concrete type and can safely downcast.
pub(crate) trait ExtractInput: Sized + Clone {
fn extract_from_channels(
receivers: Vec<Box<dyn Any + Send>>,
) -> impl std::future::Future<Output = Result<Self, String>> + Send;
}
// Unit type - no dependencies
impl ExtractInput for () {
async fn extract_from_channels(_receivers: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
Ok(())
}
}
// Implementations for common primitive and standard library types.
// These allow tasks to receive these types as single inputs without wrapping in tuples.
// Receives Arc<T> from channel, clones the inner value for task consumption.
// For custom types, implement ExtractInput following the same pattern.
macro_rules! impl_extract_single {
($($t:ty),+) => {
$(
impl ExtractInput for $t {
async fn extract_from_channels(mut receivers: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<$t>>>()
.map_err(|_| format!("Type mismatch: expected Arc<{}>", std::any::type_name::<$t>()))?;
let arc_value = rx.await.map_err(|_| "Channel closed before receiving value".to_string())?;
Ok((*arc_value).clone())
}
}
)+
};
}
impl_extract_single!(
i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, char, String
);
// Implement ExtractInput for Result types to support error handling patterns
impl<T, E> ExtractInput for Result<T, E>
where
T: 'static + Clone + Send + Sync,
E: 'static + Clone + Send + Sync,
{
async fn extract_from_channels(
mut receivers: Vec<Box<dyn Any + Send>>,
) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers
.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<Result<T, E>>>>()
.map_err(|_| {
format!(
"Type mismatch: expected Arc<Result<{}, {}>>",
std::any::type_name::<T>(),
std::any::type_name::<E>()
)
})?;
let arc_value = rx
.await
.map_err(|_| "Channel closed before receiving value".to_string())?;
Ok((*arc_value).clone())
}
}
// Implement ExtractInput for Option types
impl<T> ExtractInput for Option<T>
where
T: 'static + Clone + Send + Sync,
{
async fn extract_from_channels(
mut receivers: Vec<Box<dyn Any + Send>>,
) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers
.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<Option<T>>>>()
.map_err(|_| {
format!(
"Type mismatch: expected Arc<Option<{}>>",
std::any::type_name::<T>()
)
})?;
let arc_value = rx
.await
.map_err(|_| "Channel closed before receiving value".to_string())?;
Ok((*arc_value).clone())
}
}
// Implement ExtractInput for Vec types
impl<T> ExtractInput for Vec<T>
where
T: 'static + Clone + Send + Sync,
{
async fn extract_from_channels(
mut receivers: Vec<Box<dyn Any + Send>>,
) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers
.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<Vec<T>>>>()
.map_err(|_| {
format!(
"Type mismatch: expected Arc<Vec<{}>>",
std::any::type_name::<T>()
)
})?;
let arc_value = rx
.await
.map_err(|_| "Channel closed before receiving value".to_string())?;
Ok((*arc_value).clone())
}
}
// Implement ExtractInput for HashMap types
impl<K, V> ExtractInput for std::collections::HashMap<K, V>
where
K: 'static + Clone + Send + Sync + Eq + std::hash::Hash,
V: 'static + Clone + Send + Sync,
{
async fn extract_from_channels(
mut receivers: Vec<Box<dyn Any + Send>>,
) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers
.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<std::collections::HashMap<K, V>>>>()
.map_err(|_| {
format!(
"Type mismatch: expected Arc<HashMap<{}, {}>>",
std::any::type_name::<K>(),
std::any::type_name::<V>()
)
})?;
let arc_value = rx
.await
.map_err(|_| "Channel closed before receiving value".to_string())?;
Ok((*arc_value).clone())
}
}
// Implement ExtractInput for Arc types - SPECIAL CASE: just clone the Arc, don't unwrap!
// This allows zero-copy data sharing for users who wrap their data in Arc.
impl<T> ExtractInput for Arc<T>
where
T: 'static + Clone + Send + Sync,
{
async fn extract_from_channels(
mut receivers: Vec<Box<dyn Any + Send>>,
) -> Result<Self, String> {
if receivers.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", receivers.len()));
}
let rx = *receivers
.pop()
.unwrap()
.downcast::<oneshot::Receiver<Arc<Arc<T>>>>()
.map_err(|_| {
format!(
"Type mismatch: expected Arc<Arc<{}>>",
std::any::type_name::<T>()
)
})?;
let arc_arc_value = rx
.await
.map_err(|_| "Channel closed before receiving value".to_string())?;
// Return the inner Arc (clone is cheap - just refcount increment)
Ok(Arc::clone(&*arc_arc_value))
}
}
/// Macro to implement ExtractInput for tuple types.
///
/// Generates an implementation that extracts values from multiple dependency channels
/// and combines them into a tuple. All channels are awaited concurrently using join_all.
///
/// We need separate implementations for each tuple size (2-8) because Rust doesn't support
/// variadic generics. This is standard practice when working with tuples in Rust.
///
/// # Compile-time Safety
///
/// The number of dependencies is guaranteed to match the tuple size at compile time through:
/// 1. The `DepsTuple<Tk::Input>` trait bound on `depends_on()` ensures type/arity matching
/// 2. The `IsUnitType` constraint on `From<TaskBuilder>` prevents using tasks without calling `depends_on()`
///
/// Therefore, no runtime validation is needed - if it compiles, the dependency count is correct.
macro_rules! impl_extract_tuple {
($($T:ident : $idx:tt),+) => {
impl<$($T: 'static + Clone + Send + Sync),+> ExtractInput for ($($T,)+) {
async fn extract_from_channels(receivers: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
// Verify we have the correct number of receivers
let expected_count = [$($idx),+].len();
if receivers.len() != expected_count {
return Err(format!("Expected {} dependencies, got {}", expected_count, receivers.len()));
}
// Convert Vec to iterator so we can consume it
let mut iter = receivers.into_iter();
// Extract each receiver by consuming from the iterator
// Receivers now contain Arc-wrapped values for efficient fanout
// Dereference Box after downcasting to get the receiver itself
// Use type names as variable names to avoid numeric suffix issues
$(
#[allow(non_snake_case)]
let $T = *iter.next()
.ok_or_else(|| format!("Missing receiver at index {}", $idx))?
.downcast::<oneshot::Receiver<Arc<$T>>>()
.map_err(|_| format!("Type mismatch at index {}: expected Arc<{}>",
$idx, std::any::type_name::<$T>()))?;
)+
// Await all channels concurrently - this is a key optimization!
// We can start receiving as soon as ANY dependency completes, not waiting
// for them sequentially.
let arc_results = futures::join!(
$(
async move {
$T.await.map_err(|_| format!("Channel {} closed", $idx))
}
),+
);
// Clone inner values from Arc (cheap for small types, necessary for ownership)
Ok(($(
(*arc_results.$idx?).clone()
,)+))
}
}
};
}
// Generate ExtractInput implementations for tuples of size 2-8.
// Each invocation creates an impl for a specific tuple size.
// Supporting up to 8 elements covers the vast majority of use cases.
impl_extract_tuple!(A:0, B:1);
impl_extract_tuple!(A:0, B:1, C:2);
impl_extract_tuple!(A:0, B:1, C:2, D:3);
impl_extract_tuple!(A:0, B:1, C:2, D:3, E:4);
impl_extract_tuple!(A:0, B:1, C:2, D:3, E:4, F:5);
impl_extract_tuple!(A:0, B:1, C:2, D:3, E:4, F:5, G:6);
impl_extract_tuple!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7);
#[cfg(test)]
mod tests;
+294
View File
@@ -0,0 +1,294 @@
//! Unit tests for extract module
#![allow(dead_code)] // Test helpers generate unused code via macro
use crate::extract::ExtractInput;
use crate::task::Task;
use futures::channel::oneshot;
use std::any::Any;
use std::sync::Arc;
// Test task for error scenarios
#[allow(dead_code)]
struct TestTask {
value: i32,
}
#[crate::task]
#[allow(dead_code)]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
#[allow(dead_code)]
struct TestTaskWithInput;
#[crate::task]
#[allow(dead_code)]
impl TestTaskWithInput {
async fn run(input: &i32) -> i32 {
input * 2
}
}
#[tokio::test]
async fn test_extract_result_type_single_dependency_error() {
// Test Result<T,E> with wrong dependency count
let deps = vec![
Box::new(oneshot::channel::<Result<i32, String>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Result<i32, String>>().1) as Box<dyn Any + Send>,
]; // Two dependencies when expecting 1
let result = Result::<i32, String>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 2"));
}
#[tokio::test]
async fn test_extract_result_type_success() {
// Test Result<T,E> successful extraction
let (tx, rx) = oneshot::channel::<Arc<Result<i32, String>>>();
// Send value in background
tokio::spawn(async move {
let _ = tx.send(Arc::new(Ok(42)));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Result::<i32, String>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), Ok(42));
}
#[tokio::test]
async fn test_extract_option_type_single_dependency_error() {
// Test Option<T> with wrong dependency count
let deps = vec![
Box::new(oneshot::channel::<Option<String>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Option<String>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Option<String>>().1) as Box<dyn Any + Send>,
]; // Three dependencies when expecting 1
let result = Option::<String>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 3"));
}
#[tokio::test]
async fn test_extract_option_type_success() {
// Test Option<T> successful extraction
let (tx, rx) = oneshot::channel::<Arc<Option<i32>>>();
// Send value in background
tokio::spawn(async move {
let _ = tx.send(Arc::new(Some(99)));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Option::<i32>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(99));
}
#[tokio::test]
async fn test_extract_vec_type_single_dependency_error() {
// Test Vec<T> with wrong dependency count
let deps = vec![]; // Zero dependencies when expecting 1
let result = Vec::<u8>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 0"));
}
#[tokio::test]
async fn test_extract_vec_type_success() {
// Test Vec<T> successful extraction
let (tx, rx) = oneshot::channel::<Arc<Vec<i32>>>();
// Send value in background
tokio::spawn(async move {
let _ = tx.send(Arc::new(vec![1, 2, 3]));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Vec::<i32>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), vec![1, 2, 3]);
}
#[tokio::test]
async fn test_extract_primitive_single_dependency_error() {
// Test primitive types with wrong dependency count
let deps = vec![
Box::new(oneshot::channel::<i32>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<i32>().1) as Box<dyn Any + Send>,
]; // Two dependencies when expecting 1
let result = i32::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 2"));
// Test with String
let deps = vec![
Box::new(oneshot::channel::<String>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<String>().1) as Box<dyn Any + Send>,
];
let result = String::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 2"));
// Test with f64
let deps = vec![
Box::new(oneshot::channel::<f64>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<f64>().1) as Box<dyn Any + Send>,
];
let result = f64::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 2"));
}
#[tokio::test]
async fn test_extract_unit_type() {
// Test that unit type always succeeds regardless of dependencies
let deps = vec![];
let result = <()>::extract_from_channels(deps).await;
assert!(result.is_ok());
// Even with dependencies (ignored), unit type succeeds
let deps = vec![
Box::new(oneshot::channel::<i32>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<i32>().1) as Box<dyn Any + Send>,
];
let result = <()>::extract_from_channels(deps).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_extract_tuple_basic() {
// Test tuple extraction with channels
let (tx1, rx1) = oneshot::channel::<Arc<i32>>();
let (tx2, rx2) = oneshot::channel::<Arc<i32>>();
// Send values in background
tokio::spawn(async move {
let _ = tx1.send(Arc::new(42));
let _ = tx2.send(Arc::new(100));
});
let deps = vec![
Box::new(rx1) as Box<dyn Any + Send>,
Box::new(rx2) as Box<dyn Any + Send>,
];
// Extract a tuple (i32, i32)
let result = <(i32, i32)>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), (42, 100));
}
#[tokio::test]
async fn test_extract_channel_closed() {
// Test error when channel is closed
let (tx, rx) = oneshot::channel::<Arc<i32>>();
// Explicitly drop tx to close the channel
drop(tx);
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = i32::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Channel closed"));
}
#[tokio::test]
async fn test_extract_type_mismatch() {
// Test type mismatch during downcast
let (tx, rx) = oneshot::channel::<String>();
// Send a String
tokio::spawn(async move {
let _ = tx.send("hello".to_string());
});
// Try to extract as wrong type - put wrong receiver type in deps
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = i32::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_extract_all_primitive_types() {
// Test that all primitive types in impl_extract_single! work correctly
macro_rules! test_type {
($t:ty, $val:expr) => {
let (tx, rx) = oneshot::channel::<Arc<$t>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new($val));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = <$t>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), $val);
};
}
test_type!(i8, 42i8);
test_type!(i16, 42i16);
test_type!(i32, 42i32);
test_type!(i64, 42i64);
test_type!(String, "test".to_string());
}
#[tokio::test]
async fn test_extract_larger_tuples() {
// Test extraction for larger tuple sizes
let (tx1, rx1) = oneshot::channel::<Arc<i32>>();
let (tx2, rx2) = oneshot::channel::<Arc<i32>>();
let (tx3, rx3) = oneshot::channel::<Arc<i32>>();
let (tx4, rx4) = oneshot::channel::<Arc<i32>>();
let (tx5, rx5) = oneshot::channel::<Arc<i32>>();
// Send values in background
tokio::spawn(async move {
let _ = tx1.send(Arc::new(0));
let _ = tx2.send(Arc::new(1));
let _ = tx3.send(Arc::new(2));
let _ = tx4.send(Arc::new(3));
let _ = tx5.send(Arc::new(4));
});
let deps = vec![
Box::new(rx1) as Box<dyn Any + Send>,
Box::new(rx2) as Box<dyn Any + Send>,
Box::new(rx3) as Box<dyn Any + Send>,
Box::new(rx4) as Box<dyn Any + Send>,
Box::new(rx5) as Box<dyn Any + Send>,
];
// Extract a 5-element tuple
let result = <(i32, i32, i32, i32, i32)>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), (0, 1, 2, 3, 4));
}
#[tokio::test]
async fn test_extract_arc_type() {
// Test Arc<T> extraction
let (tx, rx) = oneshot::channel::<Arc<Arc<String>>>();
let test_arc = Arc::new("test".to_string());
let test_arc_clone = test_arc.clone();
tokio::spawn(async move {
let _ = tx.send(Arc::new(test_arc));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Arc::<String>::extract_from_channels(deps).await;
assert!(result.is_ok());
assert_eq!(*result.unwrap(), *test_arc_clone);
}
+683
View File
@@ -0,0 +1,683 @@
//! Async DAG Task Runner
//!
//! A minimal, type-safe, runtime-agnostic DAG (Directed Acyclic Graph) executor with
//! compile-time dependency validation and optimal parallel execution.
//!
//! # Features
//!
//! - **Compile-time type safety**: Dependencies are validated at compile time through the type
//! system. The public API is fully type-safe with no runtime type errors. Internal execution
//! uses type erasure for heterogeneous task storage, but this is never exposed to users.
//! - **Runtime-agnostic**: Works with any async runtime (Tokio, async-std, smol, Embassy, etc.)
//! - **Type-state pattern**: The API guides you with compile-time errors if you wire dependencies
//! incorrectly
//! - **Optimal execution**: Topological scheduling with maximum safe parallelism
//! - **Zero-cost abstractions**: Leverages generics and monomorphization for minimal overhead
//! - **Error handling**: Result-based error handling with [`DagResult<T>`] for cycle detection
//! and validation
//!
//! # Quick Start
//!
//! ```no_run
//! use dagx::{task, DagRunner, Task};
//!
//! // Source task with read-only state (tuple struct)
//! struct Value(i32);
//!
//! #[task]
//! impl Value {
//! async fn run(&self) -> i32 { self.0 } // Read-only: use &self
//! }
//!
//! // Stateless task (unit struct)
//! struct Add;
//!
//! #[task]
//! impl Add {
//! async fn run(a: &i32, b: &i32) -> i32 { a + b } // No self needed!
//! }
//!
//! # async {
//! let dag = DagRunner::new();
//!
//! // Add source tasks
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3));
//!
//! // Add task with dependencies
//! let sum = dag.add_task(Add).depends_on((&x, &y));
//!
//! // Execute and retrieve results
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//! assert_eq!(dag.get(sum).unwrap(), 5);
//! # };
//! ```
//!
//! # Implementation Notes
//!
//! ## Inline Execution Fast-Path
//!
//! dagx automatically optimizes execution for sequential workloads using an inline fast-path.
//! When a layer contains only a single task (common in deep chains and linear pipelines),
//! that task executes inline rather than being spawned. This eliminates spawning overhead,
//! context switching, and channel creation.
//!
//! **Panic handling**: To maintain consistent behavior between inline and spawned execution,
//! panics in inline tasks are caught using `FutureExt::catch_unwind()` and converted to
//! `DagError::TaskPanicked`. This matches the behavior of async runtimes like Tokio, async-std,
//! and smol, which catch panics in spawned tasks and convert them to errors.
//!
//! **What this means for you**:
//! - Tasks behave identically whether executed inline or spawned
//! - Panics become errors regardless of execution path
//! - Sequential workloads see 10-100x performance improvements
//! - The optimization is completely transparent - no code changes needed
//!
//! **When inline execution happens**:
//! - Single-task layers (e.g., long sequential chains)
//! - Linear pipelines (A→B→C→D)
//! - Any layer where `layer.len() == 1` after topological ordering
//!
//! ## Dependency Limits
//!
//! dagx supports up to 8 dependencies per task. If you need more than 8 dependencies:
//! 1. Group related inputs into a struct
//! 2. Use intermediate aggregation tasks
//! 3. Consider if 8+ dependencies indicates a design issue
//!
//! ## Task Output Limitations
//!
//! **IMPORTANT**: Tasks **cannot return bare tuples** as output types. This is a technical
//! limitation of the current implementation. If you need to return multiple values, use one
//! of these workarounds:
//!
//! ### Workaround 1: Wrap in Result (Recommended for Fallible Operations)
//!
//! ```
//! use dagx::{task, Task};
//!
//! struct ProcessData;
//! #[task]
//! impl ProcessData {
//! async fn run(input: &String) -> Result<(String, i32, bool), String> {
//! // Wrap the tuple in Result - this works!
//! Ok(("Alice".to_string(), 30, true))
//! }
//! }
//! ```
//!
//! ### Workaround 2: Use a Struct (Recommended for Most Cases)
//!
//! ```
//! use dagx::{task, Task};
//!
//! // Define a struct to hold your multiple values
//! struct UserData {
//! name: String,
//! age: i32,
//! active: bool,
//! }
//!
//! struct ProcessDataStruct;
//! #[task]
//! impl ProcessDataStruct {
//! async fn run(input: &String) -> UserData {
//! // Return the struct - clean and self-documenting
//! UserData {
//! name: "Alice".to_string(),
//! age: 30,
//! active: true,
//! }
//! }
//! }
//! ```
//!
//! ### Why Use Structs Over `Result<(...)>`?
//!
//! While both workarounds solve the technical limitation, **structs are the better choice**
//! for most cases:
//!
//! - **Self-documenting**: `user.name` is clearer than `user.0`
//! - **Refactorable**: Easy to add/remove fields without breaking all dependencies
//! - **Type-safe**: Named fields prevent field order mistakes
//! - **Semantic clarity**: `Result` should indicate fallibility, not just wrap data
//!
//! Use `Result<(...), E>` only when your operation can genuinely fail and you need to
//! return multiple values on success
//!
//! # Core Concepts
//!
//! ## Task
//!
//! A [`Task`] is a unit of async work with typed inputs and outputs. Use the `#[task]` macro.
//!
//! ### Task Patterns
//!
//! dagx supports three task patterns based on state requirements:
//!
//! **1. Stateless** - No state (unit struct, no `self` parameter):
//!
//! ```
//! use dagx::{task, Task};
//!
//! struct Add;
//!
//! #[task]
//! impl Add {
//! async fn run(a: &i32, b: &i32) -> i32 { a + b }
//! }
//! ```
//!
//! **2. Read-only state** - Immutable access (use `&self`):
//!
//! ```
//! use dagx::{task, Task};
//!
//! struct Scale(i32);
//!
//! #[task]
//! impl Scale {
//! async fn run(&self, input: &i32) -> i32 {
//! input * self.0 // Read-only access
//! }
//! }
//! ```
//!
//! **3. Mutable state** - State modification (use `&mut self`):
//!
//! ```
//! use dagx::{task, Task};
//!
//! struct Counter(i32);
//!
//! #[task]
//! impl Counter {
//! async fn run(&mut self, input: &i32) -> i32 {
//! self.0 += input; // Modifies state
//! self.0
//! }
//! }
//! ```
//!
//! ## DagRunner
//!
//! The [`DagRunner`] orchestrates task execution. Add tasks with [`DagRunner::add_task`],
//! wire dependencies with [`TaskBuilder::depends_on`], then run everything with [`DagRunner::run`].
//!
//! ## TaskHandle
//!
//! A [`TaskHandle<T>`] is a typed, opaque reference to a task's output. Use it to:
//! - Wire dependencies between tasks
//! - Retrieve results after execution with [`DagRunner::get`]
//!
//! ## Dependency Patterns
//!
//! dagx supports three dependency patterns:
//!
//! ### No Dependencies (Source Tasks)
//!
//! Tasks with no dependencies don't call `depends_on()`:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! # struct Value(i32);
//! # #[task]
//! # impl Value {
//! # async fn run(&self) -> i32 { self.0 }
//! # }
//! # async {
//! # let dag = DagRunner::new();
//! let source = dag.add_task(Value(42));
//! # };
//! ```
//!
//! ### Single Dependency
//!
//! Tasks with a single dependency receive a reference to that value:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! # struct Value(i32);
//! # #[task]
//! # impl Value {
//! # async fn run(&self) -> i32 { self.0 }
//! # }
//! struct Double;
//! #[task]
//! impl Double {
//! async fn run(input: &i32) -> i32 { input * 2 }
//! }
//! # async {
//! # let dag = DagRunner::new();
//! # let source = dag.add_task(Value(42));
//! let doubled = dag.add_task(Double).depends_on(&source);
//! # };
//! ```
//!
//! ### Multiple Dependencies
//!
//! Tasks with multiple dependencies receive separate reference parameters (order matters!):
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! # struct Value(i32);
//! # #[task]
//! # impl Value {
//! # async fn run(&self) -> i32 { self.0 }
//! # }
//! struct Add;
//! #[task]
//! impl Add {
//! async fn run(a: &i32, b: &i32) -> i32 { a + b }
//! }
//! # async {
//! # let dag = DagRunner::new();
//! # let x = dag.add_task(Value(2));
//! # let y = dag.add_task(Value(3));
//! let sum = dag.add_task(Add).depends_on((&x, &y));
//! # };
//! ```
//!
//! # Examples
//!
//! ## Fan-out Pattern (1 → n)
//!
//! One task produces a value consumed by multiple downstream tasks:
//!
//! ```no_run
//! use dagx::{task, DagRunner, Task};
//!
//! // Source task (tuple struct)
//! struct Value(i32);
//!
//! #[task]
//! impl Value {
//! async fn run(&self) -> i32 { self.0 }
//! }
//!
//! // Stateful task (tuple struct)
//! struct Add(i32);
//!
//! #[task]
//! impl Add {
//! async fn run(&self, input: &i32) -> i32 { input + self.0 }
//! }
//!
//! // Stateful task (tuple struct)
//! struct Scale(i32);
//!
//! #[task]
//! impl Scale {
//! async fn run(&self, input: &i32) -> i32 { input * self.0 }
//! }
//!
//! # async {
//! let dag = DagRunner::new();
//!
//! let base = dag.add_task(Value(10));
//! let plus1 = dag.add_task(Add(1)).depends_on(&base);
//! let times2 = dag.add_task(Scale(2)).depends_on(&base);
//!
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//!
//! assert_eq!(dag.get(plus1).unwrap(), 11);
//! assert_eq!(dag.get(times2).unwrap(), 20);
//! # };
//! ```
//!
//! ## Fan-in Pattern (m → 1)
//!
//! Multiple tasks produce values consumed by a single downstream task:
//!
//! ```no_run
//! use dagx::{task, DagRunner, Task};
//!
//! // Source task for String (tuple struct)
//! struct Name(String);
//!
//! #[task]
//! impl Name {
//! async fn run(&self) -> String { self.0.clone() }
//! }
//!
//! // Source task for i32 (tuple struct)
//! struct Age(i32);
//!
//! #[task]
//! impl Age {
//! async fn run(&self) -> i32 { self.0 }
//! }
//!
//! // Source task for bool (tuple struct)
//! struct Active(bool);
//!
//! #[task]
//! impl Active {
//! async fn run(&self) -> bool { self.0 }
//! }
//!
//! // Stateless formatter (unit struct)
//! struct FormatUser;
//!
//! #[task]
//! impl FormatUser {
//! async fn run(n: &String, a: &i32, f: &bool) -> String {
//! format!("User: {n}, Age: {a}, Active: {f}")
//! }
//! }
//!
//! # async {
//! let dag = DagRunner::new();
//!
//! let name = dag.add_task(Name("Alice".to_string()));
//! let age = dag.add_task(Age(30));
//! let active = dag.add_task(Active(true));
//! let result = dag.add_task(FormatUser).depends_on((&name, &age, &active));
//!
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//!
//! assert_eq!(dag.get(result).unwrap(), "User: Alice, Age: 30, Active: true");
//! # };
//! ```
//!
//! ## Many-to-Many Pattern (m ↔ n)
//!
//! Complex DAGs with multiple layers and dependencies:
//!
//! ```no_run
//! use dagx::{task, DagRunner, Task};
//!
//! // Source task (tuple struct)
//! struct Value(i32);
//!
//! #[task]
//! impl Value {
//! async fn run(&self) -> i32 { self.0 }
//! }
//!
//! // Stateless addition (unit struct)
//! struct Add;
//!
//! #[task]
//! impl Add {
//! async fn run(a: &i32, b: &i32) -> i32 { a + b }
//! }
//!
//! // Stateless multiplication (unit struct)
//! struct Multiply;
//!
//! #[task]
//! impl Multiply {
//! async fn run(a: &i32, b: &i32) -> i32 { a * b }
//! }
//!
//! # async {
//! let dag = DagRunner::new();
//!
//! // Layer 1: Sources
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3));
//! let z = dag.add_task(Value(5));
//!
//! // Layer 2: Intermediate computations
//! let sum_xy = dag.add_task(Add).depends_on((&x, &y)); // 2 + 3 = 5
//! let prod_yz = dag.add_task(Multiply).depends_on((&y, &z)); // 3 * 5 = 15
//!
//! // Layer 3: Final result
//! let total = dag.add_task(Add).depends_on((&sum_xy, &prod_yz)); // 5 + 15 = 20
//!
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//!
//! assert_eq!(dag.get(total).unwrap(), 20);
//! # };
//! ```
//!
//! # Runtime Agnostic
//!
//! dagx works with any async runtime. The library has been tested with:
//! - **Tokio** - Most popular async runtime
//! - **async-std** - Alternative async runtime
//! - **smol** - Lightweight async runtime
//!
//! Examples with different runtimes:
//!
//! ```ignore
//! // With Tokio
//! #[tokio::main]
//! async fn main() {
//! let dag = DagRunner::new();
//! // ... build and run DAG
//! }
//!
//! // With async-std
//! #[async_std::main]
//! async fn main() {
//! let dag = DagRunner::new();
//! // ... build and run DAG
//! }
//!
//! // With smol
//! fn main() {
//! smol::block_on(async {
//! let dag = DagRunner::new();
//! // ... build and run DAG
//! });
//! }
//! ```
//!
//! # Error Handling
//!
//! dagx uses [`DagResult<T>`] (an alias for `Result<T, DagError>`) for operations that
//! can fail:
//!
//! - [`DagRunner::run`] returns `DagResult<()>` and can fail if a cycle is detected
//! - [`DagRunner::get`] returns `DagResult<T>` and can fail if the task hasn't executed or
//! the handle is invalid
//!
//! You can handle errors explicitly or use `.unwrap()` for simple cases:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! # struct Value(i32);
//! # #[task]
//! # impl Value {
//! # async fn run(&self) -> i32 { self.0 }
//! # }
//! # async {
//! # let dag = DagRunner::new();
//! # let node = dag.add_task(Value(42));
//! // Simple approach with .unwrap()
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//! let result = dag.get(node).unwrap();
//!
//! // Or handle errors explicitly
//! match dag.run(|fut| { tokio::spawn(fut); }).await {
//! Ok(_) => println!("DAG executed successfully"),
//! Err(e) => eprintln!("DAG execution failed: {}", e),
//! }
//! # };
//! ```
//!
//! # Documentation Accuracy
//!
//! This library maintains documentation accuracy through:
//!
//! 1. **Doctests**: All code examples are tested via `cargo test --doc`
//! 2. **Examples**: All files in `examples/` are tested via `cargo build --examples`
//! 3. **Type signatures**: Documentation reflects actual API return types
//! 4. **Error handling**: All examples show proper `DagResult<T>` handling
//! 5. **Safety claims**: All safety guarantees are verified by tests
//!
//! Last audited: 2025-10-06 for v0.1.0
//!
//! Changes that require documentation updates:
//! - API signature changes (`run()`, `get()`, etc.)
//! - New error types or error handling changes
//! - Safety guarantee changes
//! - Example code updates
//!
//! # API Design Principles
//!
//! dagx follows these API design principles:
//!
//! 1. **Type Safety**: Dependencies validated at compile time via type-state pattern
//! 2. **Builder Pattern**: Fluent interface with `add_task().depends_on()`
//! 3. **Error Handling**: All fallible operations return `DagResult<T>`
//! 4. **Minimal Surface**: Small, focused API with 4 core types and 1 trait
//! 5. **Zero Cost**: Leverages generics for monomorphization
//! 6. **Consistency**:
//! - All types are PascalCase
//! - All methods are snake_case
//! - Mutable operations take `&mut self`
//! - Builder methods consume `self`
//! - Results are always `DagResult<T>`
//!
//! # Performance Characteristics
//!
//! dagx is designed for minimal overhead and optimal parallel execution.
//!
//! ## Benchmarks
//!
//! Run benchmarks with:
//! ```bash
//! cargo bench
//! ```
//!
//! View the detailed HTML reports:
//! ```bash
//! # macOS
//! open target/criterion/report/index.html
//!
//! # Linux
//! xdg-open target/criterion/report/index.html
//!
//! # Windows
//! start target/criterion/report/index.html
//!
//! # Or manually open target/criterion/report/index.html in your browser
//! ```
//!
//! Typical performance on modern hardware (AMD 7840U - Zen 4 laptop CPU):
//! - **DAG creation**: ~20ns empty DAG, ~96ns per task added
//! - **Execution overhead**: ~119ns per task (framework coordination only)
//! - **Total overhead** (construction + execution): ~223ns per task
//! - **Scaling**: Linear overhead with task count - 100 tasks in ~123µs, 1000 tasks in ~1.15ms
//!
//! For real-world workloads where tasks perform meaningful work (I/O, computation), the framework
//! overhead is typically well under 1% of total execution time.
//!
//! ## Performance Tips
//!
//! ### Automatic Arc Wrapping (No Manual Arc Needed!)
//!
//! **Task outputs are automatically wrapped in `Arc<T>` internally** for efficient fan-out patterns.
//! You output `T`, the framework handles the Arc wrapping:
//!
//! ```
//! use dagx::{task, DagRunner, Task};
//!
//! // ✅ CORRECT: Just output Vec<String>, framework wraps in Arc internally
//! struct FetchData;
//! #[task]
//! impl FetchData {
//! async fn run() -> Vec<String> {
//! vec!["data".to_string(); 10_000]
//! }
//! }
//!
//! // Downstream tasks receive &Vec<String> as normal
//! // Behind the scenes: Arc<Vec<String>> is cloned cheaply, then inner Vec extracted
//! struct ProcessData;
//! #[task]
//! impl ProcessData {
//! async fn run(data: &Vec<String>) -> usize {
//! data.len()
//! }
//! }
//!
//! # async {
//! let dag = DagRunner::new();
//! let data = dag.add_task(FetchData);
//!
//! // All three tasks get efficient Arc-wrapped sharing automatically
//! let task1 = dag.add_task(ProcessData).depends_on(&data);
//! let task2 = dag.add_task(ProcessData).depends_on(&data);
//! let task3 = dag.add_task(ProcessData).depends_on(&data);
//!
//! dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
//! # };
//! ```
//!
//! **How it works:**
//! - Your task outputs `T` (e.g., `Vec<String>`)
//! - Framework wraps it in `Arc<T>` internally
//! - For fan-out (1→N), Arc is cloned N times (just atomic pointer increments - O(1))
//! - Each downstream task receives `&T` after extracting from Arc
//! - When you call `dag.get()`, the Arc is cloned and inner value extracted
//!
//! **Performance characteristics:**
//! - **Heap types** (Vec, String, HashMap): Arc overhead is negligible (~few ns)
//! - **Copy types** (i32, usize): Small Arc overhead but usually still negligible
//! - See `cargo bench` for actual measurements on your hardware
//!
//! **Advanced - Zero-copy optimization:**
//! If you want true zero-copy sharing (no extraction), output `Arc<T>` explicitly:
//! ```
//! # use dagx::{task, Task};
//! # use std::sync::Arc;
//! struct ZeroCopyData;
//! #[task]
//! impl ZeroCopyData {
//! // Output Arc<T> directly
//! async fn run() -> Arc<Vec<String>> {
//! Arc::new(vec!["data".to_string(); 10_000])
//! }
//! }
//!
//! struct ZeroCopyProcess;
//! #[task]
//! impl ZeroCopyProcess {
//! // Receive &Arc<T> - just clones the Arc pointer, no data extraction
//! async fn run(data: &Arc<Vec<String>>) -> usize {
//! data.len()
//! }
//! }
//! ```
//! This becomes `Arc<Arc<T>>` internally, but ExtractInput unwraps one layer automatically.
//! Use this when you have many dependents AND want to avoid the clone() of inner data
//!
//! ### Other Tips
//!
//! 1. **Minimize task count**: Combine small operations into larger tasks
//! 2. **Use appropriate granularity**: Don't create tasks for trivial work (< 1µs)
//! 3. **Parallel execution**: dagx automatically maximizes parallelism
//!
//! Run `cargo bench` to see comprehensive benchmarks including basic operations, scaling
//! characteristics, common patterns (fan-out, diamond), and realistic workloads.
//!
#![allow(private_bounds, private_interfaces)]
// Module declarations
mod builder;
mod deps;
mod error;
mod extract;
mod node;
mod runner;
mod task;
mod types;
// Public re-exports
pub use builder::TaskBuilder;
pub use error::{DagError, DagResult};
pub use runner::DagRunner;
pub use task::Task;
pub use types::{Pending, TaskHandle};
// Re-export the procedural macro
pub use dagx_macros::task;
// Internal testing support - not part of the public API
#[doc(hidden)]
pub use task::task_fn;
+148
View File
@@ -0,0 +1,148 @@
//! Internal node types for DAG execution.
//!
//! Provides type erasure to store tasks with different types in a single collection.
//! The public API remains fully type-safe; type erasure is purely an internal implementation detail.
//!
//! - **TypedNode\<T\>**: Stores a task with full type information
//! - **ExecutableNode**: Trait for executing type-erased tasks
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use futures::channel::oneshot;
use crate::error::DagError;
use crate::extract::ExtractInput;
use crate::task::Task;
use crate::types::NodeId;
/// Type-erased channel endpoints (senders or receivers)
type ErasedChannels = Vec<Box<dyn Any + Send>>;
/// Type-erased channel pair (senders, receivers)
type ChannelPair = (ErasedChannels, ErasedChannels);
/// Type-erased async execution result (Arc-wrapped for efficient fanout)
type ExecuteFuture =
Pin<Box<dyn Future<Output = Result<Arc<dyn Any + Send + Sync>, DagError>> + Send>>;
/// Internal trait for executing heterogeneous tasks.
///
/// This trait provides type erasure necessary for storing different task types in a single
/// collection. While the public API (Task, Handle, Node) is fully type-safe, internally we
/// need dynamic dispatch to execute tasks with different input/output types.
///
/// This is the ONLY place where type erasure occurs - channels are created with full type
/// information and only erased at this trait boundary.
///
/// This is an implementation detail and not part of the public API.
pub(crate) trait ExecutableNode: Send {
/// Create type-erased output channels for this node's dependents.
///
/// Returns (senders, receivers) where:
/// - senders: Type-erased senders that this node will use to send outputs
/// - receivers: Type-erased receivers that dependents will use to receive inputs
fn create_output_channels(&self, num_dependents: usize) -> ChannelPair;
/// Execute the task with type-erased input receivers and output senders.
///
/// The TypedNode implementation knows the concrete types and can safely downcast.
/// Consumes the node and returns the output value.
fn execute_with_channels(
self: Box<Self>,
receivers: ErasedChannels,
senders: ErasedChannels,
) -> ExecuteFuture;
}
/// Fully-typed node storage for a single task.
///
/// Stores a task of type T. Implements ExecutableNode for type erasure,
/// allowing heterogeneous tasks to be stored in a single Vec.
///
/// # Ownership Model
///
/// The task is owned directly (no Mutex needed) and consumed during execution.
/// Each task executes exactly once, taking ownership and producing an output.
///
/// # Oneshot Channels
///
/// Outputs are communicated via oneshot channels for inter-task communication.
/// The output is also returned from execute_with_channels for central storage.
pub(crate) struct TypedNode<T: Task> {
pub(crate) id: NodeId,
pub(crate) task: T,
}
impl<T: Task + 'static> TypedNode<T> {
pub(crate) fn new(id: NodeId, task: T) -> Self {
Self { id, task }
}
}
impl<T: Task + 'static> ExecutableNode for TypedNode<T>
where
T::Input: 'static + Clone + ExtractInput,
T::Output: 'static + Clone,
{
fn create_output_channels(&self, num_dependents: usize) -> ChannelPair {
let mut senders = Vec::new();
let mut receivers = Vec::new();
for _ in 0..num_dependents {
// Create channels that pass Arc-wrapped outputs (efficient for fanout)
let (tx, rx) = oneshot::channel::<Arc<T::Output>>();
senders.push(Box::new(tx) as Box<dyn Any + Send>);
receivers.push(Box::new(rx) as Box<dyn Any + Send>);
}
(senders, receivers)
}
fn execute_with_channels(
self: Box<Self>,
receivers: ErasedChannels,
senders: ErasedChannels,
) -> ExecuteFuture {
Box::pin(async move {
// Extract typed input from type-erased receivers
// TypedNode<T> knows T::Input, so it can safely downcast
let input = match T::Input::extract_from_channels(receivers).await {
Ok(input) => input,
Err(_msg) => {
// Failed to extract input - likely channel closed or type mismatch
return Err(DagError::InvalidDependency { task_id: self.id.0 });
}
};
// Execute task (consumes self.task)
let output = self.task.run(input).await;
// Arc Wrapping Strategy:
// Wrap output in Arc ONCE for efficient fan-out sharing.
// For 1:N dependencies, this enables O(1) sharing (Arc clone = atomic increment)
// instead of O(N) cloning (N deep copies of output data).
// Downstream tasks receive &T after ExtractInput extracts from Arc.
let arc_output = Arc::new(output);
// Send Arc to all dependents via their channels
// TypedNode<T> knows T::Output, so downcast is safe
for sender_any in senders {
if let Ok(sender) = sender_any.downcast::<oneshot::Sender<Arc<T::Output>>>() {
// Clone Arc (just atomic refcount increment, NOT data clone)
// This is the key performance optimization for fan-out patterns
// Ignore send errors - dependent might have been cancelled
let _ = sender.send(Arc::clone(&arc_output));
}
}
// Return Arc-wrapped output (for storage by runner)
Ok(arc_output as Arc<dyn Any + Send + Sync>)
})
}
}
#[cfg(test)]
mod tests;
+176
View File
@@ -0,0 +1,176 @@
//! Unit tests for node module
use crate::node::{ExecutableNode, TypedNode};
use crate::task::Task;
use crate::types::NodeId;
// Simple test task
struct TestTask {
value: i32,
}
#[crate::task]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
#[test]
fn test_create_output_channels() {
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create channels for 3 dependents
let (senders, receivers) = node.create_output_channels(3);
assert_eq!(senders.len(), 3);
assert_eq!(receivers.len(), 3);
}
#[test]
fn test_create_output_channels_zero_dependents() {
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create channels for 0 dependents (sink node)
let (senders, receivers) = node.create_output_channels(0);
assert_eq!(senders.len(), 0);
assert_eq!(receivers.len(), 0);
}
struct TaskWithDependency;
#[crate::task]
impl TaskWithDependency {
async fn run(input: &i32) -> i32 {
input * 2
}
}
#[tokio::test]
async fn test_execute_with_channels_missing_input() {
// Test when task expects input but doesn't receive it
let node = Box::new(TypedNode::new(NodeId(0), TaskWithDependency));
// TaskWithDependency expects an i32 input, but we provide no receivers
let receivers = vec![];
let senders = vec![];
let result = node.execute_with_channels(receivers, senders).await;
assert!(result.is_err());
if let Err(crate::error::DagError::InvalidDependency { task_id }) = result {
assert_eq!(task_id, 0);
} else {
panic!("Expected InvalidDependency error, got: {:?}", result);
}
}
#[tokio::test]
async fn test_execute_with_channels_success() {
// Test successful execution with channels
let dependency_node = Box::new(TypedNode::new(NodeId(0), TestTask { value: 21 }));
let dependent_node = Box::new(TypedNode::new(NodeId(1), TaskWithDependency));
// Create a channel from dependency to dependent (Arc-wrapped)
let (tx, rx) = futures::channel::oneshot::channel::<std::sync::Arc<i32>>();
// Execute dependency node with sender
let dep_senders = vec![Box::new(tx) as Box<dyn std::any::Any + Send>];
let dep_receivers = vec![];
// Execute in background
let dep_handle = tokio::spawn(async move {
dependency_node
.execute_with_channels(dep_receivers, dep_senders)
.await
});
// Execute dependent node with receiver
let receivers = vec![Box::new(rx) as Box<dyn std::any::Any + Send>];
let senders = vec![];
let result = dependent_node
.execute_with_channels(receivers, senders)
.await;
// Wait for dependency to complete
let _ = dep_handle.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_with_channels_sink_node() {
// Test that sink nodes (no dependents) return their output
let node = Box::new(TypedNode::new(NodeId(0), TestTask { value: 42 }));
// No senders means this is a sink node
let receivers = vec![];
let senders = vec![];
let result = node.execute_with_channels(receivers, senders).await;
assert!(result.is_ok());
// Check output was returned (Arc-wrapped)
let output = result.unwrap();
let arc_value = output.downcast::<i32>().unwrap();
assert_eq!(*arc_value, 42);
}
#[tokio::test]
async fn test_execute_with_channels_non_sink_node() {
// Test that non-sink nodes send output via channels
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create output channels for one dependent
let (senders, mut receivers) = node.create_output_channels(1);
// Execute with senders (non-sink node)
let node = Box::new(node);
let exec_receivers = vec![];
let result = node.execute_with_channels(exec_receivers, senders).await;
assert!(result.is_ok());
// Verify output was sent through channel (Arc-wrapped)
let rx = receivers
.pop()
.unwrap()
.downcast::<futures::channel::oneshot::Receiver<std::sync::Arc<i32>>>()
.unwrap();
let received_arc = rx.await;
assert!(received_arc.is_ok());
assert_eq!(*received_arc.unwrap(), 42);
// Also verify output was returned (Arc-wrapped)
let output = result.unwrap();
let arc_value = output.downcast::<i32>().unwrap();
assert_eq!(*arc_value, 42);
}
#[tokio::test]
async fn test_execute_with_multiple_dependents() {
// Test that output is sent to multiple dependents
let node = TypedNode::new(NodeId(0), TestTask { value: 100 });
// Create output channels for three dependents
let (senders, receivers) = node.create_output_channels(3);
// Execute node
let node = Box::new(node);
let exec_receivers = vec![];
let result = node.execute_with_channels(exec_receivers, senders).await;
assert!(result.is_ok());
// Verify all three dependents receive the output (Arc-wrapped)
for receiver in receivers {
let rx = receiver
.downcast::<futures::channel::oneshot::Receiver<std::sync::Arc<i32>>>()
.unwrap();
let received_arc = rx.await;
assert!(received_arc.is_ok());
assert_eq!(*received_arc.unwrap(), 100);
}
}
+637
View File
@@ -0,0 +1,637 @@
//! DAG runner for task orchestration and execution.
//!
//! Provides DagRunner for building and executing directed acyclic graphs of async tasks
//! with compile-time type-safe dependencies.
//!
//! Uses Mutex for interior mutability to enable builder pattern (`&self` instead of `&mut self`).
use std::collections::{HashMap, HashSet, VecDeque};
use std::panic::AssertUnwindSafe;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use futures::channel::mpsc;
use futures::future::BoxFuture;
use futures::{FutureExt, StreamExt};
use parking_lot::Mutex;
use crate::builder::TaskBuilder;
use crate::error::{DagError, DagResult};
use crate::extract::ExtractInput;
use crate::node::{ExecutableNode, TypedNode};
use crate::task::Task;
use crate::types::{NodeId, Pending, TaskHandle};
// Guard to ensure run_lock is released even on early return or panic
struct RunGuard<'a> {
lock: &'a AtomicBool,
}
impl<'a> Drop for RunGuard<'a> {
fn drop(&mut self) {
self.lock.store(false, Ordering::SeqCst);
}
}
/// Build and execute a typed DAG of tasks.
///
/// A `DagRunner` is the main orchestrator for building and executing a directed acyclic graph
/// of async tasks with compile-time type-safe dependencies.
///
/// # Workflow
///
/// 1. Create a new DAG with [`DagRunner::new`]
/// 2. Add tasks with [`DagRunner::add_task`] to get [`TaskBuilder`] builders
/// 3. Wire dependencies with [`TaskBuilder::depends_on`]
/// 4. Execute all tasks with [`DagRunner::run`]
/// 5. Optionally retrieve outputs with [`DagRunner::get`]
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// // Task with state constructed via ::new()
/// struct LoadValue { value: i32 }
///
/// impl LoadValue {
/// fn new(value: i32) -> Self { Self { value } }
/// }
///
/// #[task]
/// impl LoadValue {
/// async fn run(&mut self) -> i32 { self.value }
/// }
///
/// // Unit struct - no fields needed
/// struct Add;
///
/// #[task]
/// impl Add {
/// async fn run(&mut self, a: &i32, b: &i32) -> i32 { a + b }
/// }
///
/// # async {
/// let dag = DagRunner::new();
///
/// // Construct instances using ::new() pattern
/// let x = dag.add_task(LoadValue::new(2));
/// let y = dag.add_task(LoadValue::new(3));
/// let sum = dag.add_task(Add).depends_on((&x, &y));
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
///
/// assert_eq!(dag.get(sum).unwrap(), 5);
/// # };
/// ```
///
/// Uses Mutex for interior mutability to enable the builder pattern (`&self` not `&mut self`).
/// This allows fluent chaining of `add_task()` calls.
///
/// Nodes use Option to allow taking ownership during execution.
/// Outputs are Arc-wrapped and stored separately for retrieval via get().
/// Arc enables efficient sharing during fanout without cloning data.
pub struct DagRunner {
pub(crate) nodes: Mutex<Vec<Option<Box<dyn ExecutableNode>>>>,
pub(crate) outputs: Mutex<HashMap<NodeId, std::sync::Arc<dyn std::any::Any + Send + Sync>>>,
pub(crate) edges: Mutex<HashMap<NodeId, Vec<NodeId>>>, // node -> dependencies
pub(crate) dependents: Mutex<HashMap<NodeId, Vec<NodeId>>>, // node -> tasks that depend on it
pub(crate) next_id: Mutex<usize>,
pub(crate) run_lock: AtomicBool, // Ensures only one run() at a time
}
impl Default for DagRunner {
fn default() -> Self {
Self::new()
}
}
impl DagRunner {
/// Create a new empty DAG.
///
/// # Examples
///
/// ```
/// use dagx::DagRunner;
///
/// let dag = DagRunner::new();
/// ```
pub fn new() -> Self {
Self {
nodes: Mutex::new(Vec::new()),
outputs: Mutex::new(HashMap::new()),
edges: Mutex::new(HashMap::new()),
dependents: Mutex::new(HashMap::new()),
next_id: Mutex::new(0),
run_lock: AtomicBool::new(false),
}
}
pub(crate) fn alloc_id(&self) -> NodeId {
let mut next_id = self.next_id.lock();
let id = NodeId(*next_id);
*next_id += 1;
id
}
/// Add a task instance to the DAG, returning a node builder for wiring dependencies.
///
/// The returned [`TaskBuilder<Tk, Pending>`](TaskBuilder) can be used to:
/// - Specify dependencies via [`TaskBuilder::depends_on`]
/// - Used directly as a [`TaskHandle`] to the task's output
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// // Task with state - shows you construct with specific value
/// struct LoadValue {
/// initial: i32,
/// }
///
/// impl LoadValue {
/// fn new(initial: i32) -> Self {
/// Self { initial }
/// }
/// }
///
/// #[task]
/// impl LoadValue {
/// async fn run(&mut self) -> i32 { self.initial }
/// }
///
/// // Task with configuration - shows you can parameterize behavior
/// struct AddOffset {
/// offset: i32,
/// }
///
/// impl AddOffset {
/// fn new(offset: i32) -> Self {
/// Self { offset }
/// }
/// }
///
/// #[task]
/// impl AddOffset {
/// async fn run(&mut self, x: &i32) -> i32 { x + self.offset }
/// }
///
/// # async {
/// let dag = DagRunner::new();
///
/// // Construct task with initial value of 10
/// let base = dag.add_task(LoadValue::new(10));
///
/// // Construct task with offset of 1
/// let inc = dag.add_task(AddOffset::new(1)).depends_on(&base);
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
/// assert_eq!(dag.get(&inc).unwrap(), 11);
/// # };
/// ```
pub fn add_task<Tk>(&self, task: Tk) -> TaskBuilder<'_, Tk, Pending>
where
Tk: Task + 'static,
Tk::Input: 'static + Clone + ExtractInput,
Tk::Output: 'static + Clone,
{
let id = self.alloc_id();
let node = TypedNode::new(id, task);
self.nodes.lock().push(Some(Box::new(node)));
self.edges.lock().insert(id, Vec::new());
self.dependents.lock().insert(id, Vec::new());
TaskBuilder {
id,
dag: self,
_phantom: std::marker::PhantomData,
}
}
/// Run the entire DAG to completion using the provided spawner.
///
/// This method:
/// - Executes tasks in topological order (respecting dependencies)
/// - Runs ready tasks with maximum parallelism (executor-limited)
/// - Executes each task at most once
/// - Waits for **all sinks** (tasks with no dependents) to complete
/// - Is runtime-agnostic via the spawner function
///
/// # Parameters
///
/// - `spawner`: A function that spawns futures on the async runtime. Examples:
/// - Tokio: `|fut| { tokio::spawn(fut); }`
/// - Smol: `|fut| { smol::spawn(fut).detach(); }`
/// - Async-std: `|fut| { async_std::task::spawn(fut); }`
///
/// # Errors
///
/// Returns `DagError::CycleDetected` if the DAG contains a cycle.
///
/// # Examples
///
/// ```
/// # use dagx::{task, DagRunner, Task};
/// // Tuple struct
/// struct Value(i32);
///
/// #[task]
/// impl Value {
/// async fn run(&mut self) -> i32 { self.0 }
/// }
///
/// // Unit struct
/// struct Add;
///
/// #[task]
/// impl Add {
/// async fn run(&mut self, a: &i32, b: &i32) -> i32 { a + b }
/// }
///
/// # async {
/// let dag = DagRunner::new();
///
/// let a = dag.add_task(Value(1));
/// let b = dag.add_task(Value(2));
/// let sum = dag.add_task(Add).depends_on((&a, &b));
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap(); // Executes all tasks
/// # };
/// ```
///
/// # Implementation Note
///
/// Tasks communicate via oneshot channels created fresh during run().
/// This eliminates Mutex contention on outputs and enables true streaming execution.
/// Type erasure occurs only at the ExecutableNode trait boundary - channels are created
/// with full type information and type-erased just before passing to execute_with_channels.
pub async fn run<S>(&self, spawner: S) -> DagResult<()>
where
S: Fn(BoxFuture<'static, ()>),
{
// Acquire run lock to prevent concurrent executions
// Use atomic compare_exchange to check if already running
if self
.run_lock
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(DagError::CycleDetected {
nodes: vec![],
description: "DAG is already running - concurrent execution not supported"
.to_string(),
});
}
// Guard ensures lock is released on drop (even on early return or panic)
let _run_guard = RunGuard {
lock: &self.run_lock,
};
// Build topological layers
let layers = self.compute_layers()?;
let edges = self.edges.lock().clone();
// Create all channels upfront
// Map: (producer_id, consumer_id) -> receiver index
// We store receivers in a Vec per consumer to maintain order
let mut consumer_receivers: HashMap<NodeId, Vec<Box<dyn std::any::Any + Send>>> =
HashMap::new();
let mut producer_senders: HashMap<NodeId, Vec<Box<dyn std::any::Any + Send>>> =
HashMap::new();
// Create channels for each producer-consumer relationship
// We need to lock nodes temporarily to call create_output_channels
{
let nodes_lock = self.nodes.lock();
for (consumer_id, producer_ids) in &edges {
for &producer_id in producer_ids {
// Get the producer node (it's still in the vector at this point)
if let Some(Some(producer_node)) = nodes_lock.get(producer_id.0) {
// Ask the producer to create ONE channel for this consumer
let (mut senders, mut receivers) = producer_node.create_output_channels(1);
// Store the sender for the producer
producer_senders
.entry(producer_id)
.or_default()
.push(senders.pop().unwrap());
// Store the receiver for the consumer (order matters!)
consumer_receivers
.entry(*consumer_id)
.or_default()
.push(receivers.pop().unwrap());
}
}
}
}
// Create a channel to collect Arc-wrapped outputs from all tasks
let (output_tx, mut output_rx) = mpsc::unbounded::<(
NodeId,
Result<std::sync::Arc<dyn std::any::Any + Send + Sync>, DagError>,
)>();
// Execute layer by layer
for layer in layers {
// ============================================================================
// PERFORMANCE OPTIMIZATION: Inline execution for single-task layers
// ============================================================================
//
// When a layer contains exactly one task (common in deep chains, linear
// pipelines), we execute it inline rather than spawning it. This provides
// 10-100x performance improvements for sequential workloads by eliminating:
// - Task spawning overhead
// - Channel creation/destruction for layer coordination
// - Context switching to/from the runtime
//
// CRITICAL: Panic handling is required to maintain behavioral consistency.
// All async runtimes (Tokio, async-std, smol, embassy-rs) catch panics in
// spawned tasks and convert them to errors. We must do the same for inline
// execution to ensure tasks behave identically regardless of execution path.
//
// Without panic catching:
// - Spawned task panics → caught by runtime → becomes error (expected)
// - Inline task panics → bubbles up → crashes program (surprising!)
//
// With panic catching (current implementation):
// - Spawned task panics → caught by runtime → becomes error
// - Inline task panics → caught by us → becomes error (consistent!)
//
// This ensures the optimization is transparent to users.
// ============================================================================
if layer.len() == 1 {
let node_id = layer[0];
let out_tx = output_tx.clone();
// Take ownership of the node
let node = {
let mut nodes_lock = self.nodes.lock();
nodes_lock[node_id.0].take()
};
if let Some(node) = node {
// Take the receivers for this consumer
let receivers = consumer_receivers.remove(&node_id).unwrap_or_default();
// Take the senders for this producer
let senders = producer_senders.remove(&node_id).unwrap_or_default();
// Execute inline with panic handling.
//
// FutureExt::catch_unwind() ensures panics are caught and converted to
// DagError::TaskPanicked, matching the behavior of async runtimes when
// they spawn tasks. This guarantees consistent error handling whether
// a task executes inline (single-task layer) or spawned (multi-task layer).
let result = AssertUnwindSafe(node.execute_with_channels(receivers, senders))
.catch_unwind()
.await
.unwrap_or_else(|panic_payload| {
// Convert panic to error
Err(DagError::TaskPanicked {
task_id: node_id.0,
panic_message: if let Some(s) = panic_payload.downcast_ref::<&str>()
{
s.to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
},
})
});
// Send the output (ignore send errors - receiver may be dropped)
let _ = out_tx.unbounded_send((node_id, result));
}
} else {
// Slow path: Multiple tasks require spawning and coordination
// Create a channel to track task completion for this layer
let (tx, mut rx) = mpsc::channel::<()>(layer.len());
// Spawn each task in this layer
for &node_id in &layer {
let mut task_tx = tx.clone();
let out_tx = output_tx.clone();
// Take ownership of the node
let node = {
let mut nodes_lock = self.nodes.lock();
nodes_lock[node_id.0].take()
};
if let Some(node) = node {
// Take the receivers for this consumer
let receivers = consumer_receivers.remove(&node_id).unwrap_or_default();
// Take the senders for this producer
let senders = producer_senders.remove(&node_id).unwrap_or_default();
// Create a 'static future by taking ownership
let inner_future = async move {
let task_future = node.execute_with_channels(receivers, senders);
let result = task_future.await;
// Send the output (ignore send errors - receiver may be dropped)
let _ = out_tx.unbounded_send((node_id, result));
// Signal completion (ignore send errors - receiver may be dropped)
let _ = task_tx.try_send(());
};
// Spawn the task using the provided spawner
spawner(Box::pin(inner_future));
}
}
// Drop the original sender so the channel closes when all tasks complete
drop(tx);
// Wait for all tasks in this layer to complete
while rx.next().await.is_some() {}
}
}
// Drop the output sender so the channel closes
drop(output_tx);
// Collect all outputs and check for errors
let mut collected = Vec::new();
let mut first_error = None;
while let Some((node_id, result)) = output_rx.next().await {
match result {
Ok(output) => collected.push((node_id, output)),
Err(e) if first_error.is_none() => first_error = Some(e),
Err(_) => {} // Ignore subsequent errors
}
}
// Insert all successful outputs (avoiding holding lock across await)
let mut outputs = self.outputs.lock();
for (node_id, output) in collected {
outputs.insert(node_id, output);
}
drop(outputs);
// Return first error if any
if let Some(err) = first_error {
return Err(err);
}
Ok(())
}
/// Retrieve a task's output after [`DagRunner::run`].
///
/// # Behavior
///
/// All task outputs are stored after execution and can be retrieved via get().
///
/// # Errors
///
/// Returns `DagError::ResultNotFound` if:
/// - The task hasn't been executed yet
/// - The handle is invalid
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// struct Configuration {
/// setting: i32,
/// }
///
/// impl Configuration {
/// fn new(setting: i32) -> Self {
/// Self { setting }
/// }
/// }
///
/// #[task]
/// impl Configuration {
/// async fn run(&mut self) -> i32 { self.setting }
/// }
///
/// # async {
/// let dag = DagRunner::new();
///
/// // Construct task with specific setting value
/// let task = dag.add_task(Configuration::new(42));
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
///
/// assert_eq!(dag.get(task).unwrap(), 42);
/// # };
/// ```
pub fn get<T: 'static + Clone + Send + Sync, H>(&self, handle: H) -> DagResult<T>
where
H: Into<TaskHandle<T>>,
{
let handle: TaskHandle<T> = handle.into();
let outputs = self.outputs.lock();
let arc_output = outputs.get(&handle.id).ok_or(DagError::ResultNotFound {
task_id: handle.id.0,
})?;
// Downcast Arc<dyn Any> to Arc<T>, then clone the inner value
// The Arc itself is stored for efficient sharing, but get() clones the data
Arc::clone(arc_output)
.downcast::<T>()
.map(|arc| (*arc).clone())
.map_err(|_| DagError::TypeMismatch {
expected: std::any::type_name::<T>(),
found: "unknown",
})
}
fn compute_layers(&self) -> DagResult<Vec<Vec<NodeId>>> {
let mut in_degree: HashMap<NodeId, usize> = HashMap::new();
let mut layers = Vec::new();
// Calculate in-degrees: for each node, count how many dependencies it has
let edges = self.edges.lock();
let total_nodes = edges.len();
for (&node, deps) in edges.iter() {
let degree = deps.len();
in_degree.insert(node, degree);
}
drop(edges); // Release lock early
// Find all nodes with in-degree 0 (sources - nodes with no dependencies)
let mut queue: VecDeque<NodeId> = in_degree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(&node, _)| node)
.collect();
let mut visited = HashSet::new();
while !queue.is_empty() {
let mut current_layer = Vec::new();
let layer_size = queue.len();
for _ in 0..layer_size {
if let Some(node) = queue.pop_front() {
if visited.contains(&node) {
continue;
}
current_layer.push(node);
visited.insert(node);
// For each node that depends on the current node
let dependents = self.dependents.lock();
if let Some(deps) = dependents.get(&node) {
for &dependent in deps {
if let Some(degree) = in_degree.get_mut(&dependent) {
*degree -= 1;
if *degree == 0 {
queue.push_back(dependent);
}
}
}
}
// Lock released here
}
}
if !current_layer.is_empty() {
current_layer.sort(); // Deterministic ordering
layers.push(current_layer);
}
}
// Cycle detection: if we haven't visited all nodes, there's a cycle
if visited.len() != total_nodes {
let unvisited: Vec<_> = in_degree
.iter()
.filter(|(node, _)| !visited.contains(node))
.map(|(node, _)| node.0)
.collect();
let description = format!(
"{} node(s) could not be processed: {:?}",
unvisited.len(),
unvisited
);
return Err(DagError::CycleDetected {
nodes: unvisited,
description,
});
}
Ok(layers)
}
}
// Note: We cannot implement Default for DagRunner anymore since new() returns Arc<Self>.
// Users should call DagRunner::new() directly.
#[cfg(test)]
mod tests;
+218
View File
@@ -0,0 +1,218 @@
//! Unit tests for runner module
use crate::error::DagError;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::TaskHandle;
struct TestTask {
value: i32,
}
#[crate::task]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
#[test]
fn test_dag_runner_new() {
// Test that DagRunner::new() creates a new instance
let dag = DagRunner::new();
// Initial state should be empty
assert_eq!(dag.nodes.lock().len(), 0);
assert_eq!(dag.edges.lock().len(), 0);
assert_eq!(dag.dependents.lock().len(), 0);
// Adding a task works
dag.add_task(TestTask { value: 42 });
assert_eq!(dag.nodes.lock().len(), 1);
}
#[test]
fn test_dag_runner_default() {
// Test lines 105-106 in runner.rs - Default implementation
let dag = DagRunner::default();
// Should behave the same as new()
assert_eq!(dag.nodes.lock().len(), 0);
assert_eq!(dag.edges.lock().len(), 0);
assert_eq!(dag.dependents.lock().len(), 0);
}
#[tokio::test]
async fn test_get_wrong_type() {
// Test getting with wrong type - this should return TypeMismatch
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 42 });
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Try to get with wrong type - downcast will fail
let fake_handle: TaskHandle<String> = TaskHandle {
id: handle.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(fake_handle);
assert!(result.is_err());
// When downcast fails, we get TypeMismatch
match result.unwrap_err() {
DagError::TypeMismatch { expected, .. } => {
assert_eq!(expected, std::any::type_name::<String>());
}
_ => panic!("Expected TypeMismatch error"),
}
}
#[tokio::test]
async fn test_get_result_not_found() {
// Test getting a result before running the DAG
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 42 });
let handle_id = handle.id.0; // Save ID before moving handle
let result = dag.get(handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::ResultNotFound { task_id } => {
assert_eq!(task_id, handle_id);
}
_ => panic!("Expected ResultNotFound error"),
}
}
#[tokio::test]
async fn test_concurrent_run_protection() {
// Test that run_lock prevents concurrent runs
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
let dag = DagRunner::new();
// Add a task that takes some time
struct SlowTask;
#[crate::task]
impl SlowTask {
async fn run(&self) -> i32 {
sleep(Duration::from_millis(100)).await;
42
}
}
dag.add_task(SlowTask);
// Wrap in Arc for sharing between tasks
let dag = Arc::new(dag);
let dag1 = Arc::clone(&dag);
let dag2 = Arc::clone(&dag);
// Start two runs concurrently
let handle1 = tokio::spawn(async move {
dag1.run(|fut| {
tokio::spawn(fut);
})
.await
});
let handle2 = tokio::spawn(async move {
// Give the first run a head start
sleep(Duration::from_millis(10)).await;
dag2.run(|fut| {
tokio::spawn(fut);
})
.await
});
// First should succeed, second should fail (concurrent execution not supported)
let result1 = handle1.await.unwrap();
let result2 = handle2.await.unwrap();
assert!(result1.is_ok());
assert!(result2.is_err()); // Concurrent run() returns error
}
#[test]
fn test_add_task_increments_id() {
let dag = DagRunner::new();
let handle1 = dag.add_task(TestTask { value: 1 });
let handle2 = dag.add_task(TestTask { value: 2 });
let handle3 = dag.add_task(TestTask { value: 3 });
// Node IDs should be sequential
assert_eq!(handle1.id.0, 0);
assert_eq!(handle2.id.0, 1);
assert_eq!(handle3.id.0, 2);
// Check that nodes were actually added
assert_eq!(dag.nodes.lock().len(), 3);
}
#[tokio::test]
async fn test_multiple_get_calls() {
// Test that get() can be called multiple times for the same handle
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 100 });
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Multiple get calls should all return the same value
let result1 = dag.get(&handle).unwrap();
let result2 = dag.get(&handle).unwrap();
let result3 = dag.get(&handle).unwrap();
assert_eq!(result1, 100);
assert_eq!(result2, 100);
assert_eq!(result3, 100);
}
struct TaskWithDep;
#[crate::task]
impl TaskWithDep {
#[allow(dead_code)]
async fn run(input: &i32) -> i32 {
input * 2
}
}
#[tokio::test]
async fn test_invalid_node_id_in_get() {
// Test getting with an invalid node ID
let dag = DagRunner::new();
// Create a handle with an ID that doesn't exist
let invalid_handle: TaskHandle<i32> = TaskHandle {
id: crate::types::NodeId(999),
_phantom: std::marker::PhantomData,
};
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
let result = dag.get(invalid_handle);
assert!(result.is_err());
// With centralized output storage, we get ResultNotFound for invalid node IDs
match result.unwrap_err() {
DagError::ResultNotFound { task_id } => {
assert_eq!(task_id, 999);
}
_ => panic!("Expected ResultNotFound error"),
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Task trait and implementations.
//!
//! This module defines the core Task trait that all DAG nodes must implement,
//! along with convenience functions for creating tasks from closures.
use std::future::Future;
/// A unit of async work with typed inputs and outputs.
///
/// Use the [`task`](crate::task) macro to implement this trait. The macro automatically
/// derives `Input` and `Output` types from your `run()` method signature.
///
/// # Task Patterns
///
/// dagx supports three main task patterns:
///
/// ## 1. Stateless Tasks (Pure Functions)
///
/// Tasks with no internal state - just transform inputs to outputs:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Add;
///
/// #[task]
/// impl Add {
/// async fn run(a: &i32, b: &i32) -> i32 {
/// a + b
/// }
/// }
/// ```
///
/// ## 2. Stateful Tasks with Shared State (`&self`)
///
/// Tasks that read from internal state but don't modify it:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Multiplier {
/// factor: i32,
/// }
///
/// #[task]
/// impl Multiplier {
/// async fn run(&self, input: &i32) -> i32 {
/// input * self.factor
/// }
/// }
/// ```
///
/// ## 3. Stateful Tasks with Mutable State (`&mut self`)
///
/// Tasks that modify internal state during execution:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Counter {
/// count: i32,
/// }
///
/// #[task]
/// impl Counter {
/// async fn run(&mut self, increment: &i32) -> i32 {
/// self.count += increment;
/// self.count
/// }
/// }
/// ```
///
/// # Input Patterns
///
/// - `()`: No dependencies (source task)
/// - `&T`: Single dependency
/// - `&A, &B, &C, ...`: Multiple dependencies (up to 8, order matters)
///
/// # The Underlying Trait
///
/// The `#[task]` macro generates an implementation of this trait:
///
/// # Output Wrapping
///
/// **Task outputs are automatically wrapped in `Arc<T>` internally** for efficient fan-out.
/// You define `Output = T`, but the framework wraps it in `Arc<T>` before sending to channels.
/// This enables O(1) sharing for 1:N dependencies - Arc is cloned (cheap), not your data.
///
/// Downstream tasks receive `&T` after ExtractInput extracts from the Arc. This is transparent
/// to your task code - you work with `T`, the framework handles Arc wrapping/unwrapping.
pub trait Task: Send {
type Input: Send;
type Output: Send + Sync; // Sync required for Arc-wrapping and cross-thread sharing
fn run(self, input: Self::Input) -> impl Future<Output = Self::Output> + Send;
}
/// Convenience function to create a task from a closure.
///
/// **INTERNAL USE ONLY**: This function is for testing purposes only and should
/// not be used in production code. Use the `#[task]` macro instead.
#[doc(hidden)]
pub fn task_fn<I, O, Fut, F>(f: F) -> impl Task<Input = I, Output = O>
where
F: FnMut(I) -> Fut + Send + 'static,
Fut: Future<Output = O> + Send + 'static,
I: Send + 'static,
O: Send + Sync + 'static,
{
struct TaskFn<I, O, Fut, F>
where
F: FnMut(I) -> Fut + Send,
Fut: Future<Output = O> + Send,
I: Send,
O: Send,
{
f: F,
_phantom: std::marker::PhantomData<fn(I) -> O>,
}
impl<I, O, Fut, F> Task for TaskFn<I, O, Fut, F>
where
F: FnMut(I) -> Fut + Send,
Fut: Future<Output = O> + Send,
I: Send,
O: Send + Sync,
{
type Input = I;
type Output = O;
async fn run(mut self, input: I) -> O {
(self.f)(input).await
}
}
TaskFn {
f,
_phantom: std::marker::PhantomData,
}
}
#[cfg(test)]
mod tests;
+155
View File
@@ -0,0 +1,155 @@
//! Unit tests for task module
use crate::task::{task_fn, Task};
use std::future::Future;
use std::pin::Pin;
// Test the task_fn function and TaskFn struct
#[tokio::test]
async fn test_task_fn_creation_and_execution() {
// Test line 111 in task.rs - Task implementation for TaskFn
// Create a simple task function
let task = task_fn(|input: i32| async move { input * 2 });
// Test the Task trait implementation
let result = task.run(42).await;
assert_eq!(result, 84);
}
#[tokio::test]
async fn test_task_fn_with_unit_input() {
// Test task_fn with unit input
let task = task_fn(|_: ()| async { 100 });
let result = task.run(()).await;
assert_eq!(result, 100);
}
#[tokio::test]
async fn test_task_fn_with_tuple_input() {
// Test task_fn with tuple input
let task = task_fn(|(a, b): (i32, i32)| async move { a + b });
let result = task.run((10, 20)).await;
assert_eq!(result, 30);
}
#[tokio::test]
async fn test_task_fn_with_string_input() {
// Test task_fn with String input
let task = task_fn(|input: String| {
let len = input.len();
async move { len }
});
let test_string = String::from("hello");
let result = task.run(test_string).await;
assert_eq!(result, 5);
}
#[tokio::test]
async fn test_task_fn_with_complex_output() {
// Test task_fn with complex output type
let task = task_fn(|input: i32| async move { vec![input, input * 2, input * 3] });
let result = task.run(5).await;
assert_eq!(result, vec![5, 10, 15]);
}
#[tokio::test]
async fn test_task_fn_stateful_closure() {
// Test that task_fn can capture state
let multiplier = 3;
let task = task_fn(move |input: i32| async move { input * multiplier });
let result = task.run(7).await;
assert_eq!(result, 21);
}
// Test custom Task implementation
struct CustomTask {
state: i32,
}
impl Task for CustomTask {
type Input = i32;
type Output = i32;
#[allow(refining_impl_trait)]
fn run(mut self, input: Self::Input) -> Pin<Box<dyn Future<Output = Self::Output> + Send>> {
Box::pin(async move {
self.state += input;
self.state
})
}
}
#[tokio::test]
async fn test_custom_task_implementation() {
let task = CustomTask { state: 10 };
let result1 = task.run(5).await;
assert_eq!(result1, 15);
// Can't call run again since task was consumed
// This demonstrates the ownership model
}
// Test that Task trait bounds are correct
fn assert_task_bounds<T: Task>() {
// This function just checks that T implements Task with the right bounds
fn assert_send<T: Send>() {}
#[allow(dead_code)]
fn assert_send_future<F: Future + Send>(_: &F) {}
// These should compile if bounds are correct
assert_send::<T>();
assert_send::<T::Input>();
assert_send::<T::Output>();
}
#[test]
fn test_task_trait_bounds() {
// Verify that our test tasks meet the Task trait bounds
assert_task_bounds::<CustomTask>();
// We can't directly test TaskFn because it's a private type and needs a concrete closure type,
// but the fact that task_fn works in the tests above proves it's correct
}
#[tokio::test]
async fn test_task_fn_single_call() {
// Test that task_fn works (can only call once due to ownership)
let task = task_fn(|x: i32| async move { x + 1 });
assert_eq!(task.run(1).await, 2);
}
#[tokio::test]
async fn test_task_fn_with_result_type() {
// Test task_fn returning Result
let task = task_fn(|x: i32| async move {
if x > 0 {
Ok(x * 2)
} else {
Err("negative input")
}
});
assert_eq!(task.run(5).await, Ok(10));
}
#[tokio::test]
async fn test_task_fn_with_option_type() {
// Test task_fn returning Option
let task = task_fn(|x: i32| async move {
if x > 0 {
Some(x * 2)
} else {
None
}
});
assert_eq!(task.run(5).await, Some(10));
}
+97
View File
@@ -0,0 +1,97 @@
//! Core type definitions for DAG nodes and handles.
//!
//! This module defines the fundamental types used throughout the DAG system,
//! including node identifiers, task handles, and type-state markers.
use crate::builder::{IsUnitType, TaskBuilder};
use crate::task::Task;
/// Opaque node identifier (internal use)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub(crate) struct NodeId(pub usize);
/// Opaque, typed token for a node's output.
///
/// A `TaskHandle<T>` provides compile-time type-safe access to a task's output.
/// You can:
/// 1. Pass it to [`crate::TaskBuilder::depends_on`] to wire up dependencies
/// 2. Use it with [`crate::DagRunner::get`] to retrieve the output after [`crate::DagRunner::run`]
///
/// Handles are cheap to clone and copy.
///
/// # Examples
///
/// ```no_run
/// # use dagx::{task, DagRunner, Task};
/// # struct LoadValue { value: i32 }
/// # impl LoadValue { pub fn new(v: i32) -> Self { Self { value: v } } }
/// # #[task]
/// # impl LoadValue {
/// # async fn run(&mut self) -> i32 { self.value }
/// # }
/// # async {
/// let dag = DagRunner::new();
/// let node = dag.add_task(LoadValue::new(42));
///
/// dag.run(|fut| { tokio::spawn(fut); }).await.unwrap();
///
/// assert_eq!(dag.get(node).unwrap(), 42);
/// # };
/// ```
pub struct TaskHandle<T> {
pub(crate) id: NodeId,
pub(crate) _phantom: std::marker::PhantomData<fn() -> T>,
}
impl<T> Clone for TaskHandle<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for TaskHandle<T> {}
/// Type-level marker for empty dependency list.
///
/// This type appears in the type signature of [`crate::TaskBuilder<Tk, Pending>`](crate::TaskBuilder) before
/// dependencies are specified with [`crate::TaskBuilder::depends_on`].
///
/// You don't interact with this type directly; it's part of the type-state
/// pattern that ensures dependencies are wired correctly at compile time.
pub struct Pending;
// TaskBuilder can be converted to a TaskHandle ONLY if Input is () (unit type)
// This enforces at compile time that tasks with non-unit inputs MUST call .depends_on()
impl<'a, Tk: Task, Deps> From<TaskBuilder<'a, Tk, Deps>> for TaskHandle<Tk::Output>
where
Tk::Input: IsUnitType,
{
fn from(node: TaskBuilder<'a, Tk, Deps>) -> Self {
TaskHandle {
id: node.id,
_phantom: std::marker::PhantomData,
}
}
}
impl<'a, Tk: Task, Deps> From<&TaskBuilder<'a, Tk, Deps>> for TaskHandle<Tk::Output>
where
Tk::Input: IsUnitType,
{
fn from(node: &TaskBuilder<'a, Tk, Deps>) -> Self {
TaskHandle {
id: node.id,
_phantom: std::marker::PhantomData,
}
}
}
// TaskHandle can be converted from &TaskHandle (for .get() calls)
impl<T> From<&TaskHandle<T>> for TaskHandle<T> {
fn from(handle: &TaskHandle<T>) -> Self {
*handle
}
}
#[cfg(test)]
mod tests;
+98
View File
@@ -0,0 +1,98 @@
//! Unit tests for types module
use crate::types::{NodeId, TaskHandle};
use std::marker::PhantomData;
#[test]
fn test_node_id_operations() {
let id1 = NodeId(1);
let id2 = NodeId(2);
let id3 = NodeId(1);
// Test equality
assert_eq!(id1, id3);
assert_ne!(id1, id2);
// Test ordering
assert!(id1 < id2);
assert!(id2 > id1);
// Test Debug
let debug_str = format!("{:?}", id1);
assert!(debug_str.contains("NodeId"));
assert!(debug_str.contains("1"));
// Test Clone
let cloned = id1;
assert_eq!(id1, cloned);
// Test Copy
let copied = id1;
assert_eq!(id1, copied);
// Test Hash (compile-time check that it implements Hash)
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(id1);
set.insert(id2);
assert_eq!(set.len(), 2);
}
#[test]
fn test_task_handle_clone() {
let handle: TaskHandle<i32> = TaskHandle {
id: NodeId(42),
_phantom: PhantomData,
};
// Test Clone implementation (lines 44-46 in types.rs)
let cloned = handle;
assert_eq!(handle.id.0, cloned.id.0);
// Test that clone is actually a copy (same memory semantics)
let handle2 = handle;
let handle3 = handle2;
assert_eq!(handle3.id.0, 42);
}
#[test]
fn test_task_handle_copy() {
let handle: TaskHandle<String> = TaskHandle {
id: NodeId(99),
_phantom: PhantomData,
};
// Test that TaskHandle implements Copy
let copied = handle;
let copied2 = handle; // This wouldn't compile if not Copy
assert_eq!(copied.id.0, 99);
assert_eq!(copied2.id.0, 99);
}
#[test]
fn test_task_handle_with_different_types() {
// Test that TaskHandle works with various types
let int_handle: TaskHandle<i32> = TaskHandle {
id: NodeId(1),
_phantom: PhantomData,
};
let string_handle: TaskHandle<String> = TaskHandle {
id: NodeId(2),
_phantom: PhantomData,
};
let vec_handle: TaskHandle<Vec<u8>> = TaskHandle {
id: NodeId(3),
_phantom: PhantomData,
};
// Clone and copy should work for all
let _ = int_handle;
let _ = string_handle;
let _ = vec_handle;
let _ = int_handle;
let _ = string_handle;
let _ = vec_handle;
}