feat: dagrunner, initial version
This commit is contained in:
122 files changed
+23101
No files matched your search
+162
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user