doc: update for next release

This commit is contained in:
TechnoPorg committed 2026-02-19 21:14:03 +01:00
1 parent a13f148778
commit 766555bef4
24 files changed
+224 -878

No files matched your search

+11 -20
View File
@@ -1,7 +1,6 @@
//! 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 std::marker::PhantomData;
@@ -14,10 +13,8 @@ use tracing::debug;
/// 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>`]
/// A `TaskBuilder<Tk, Input>` is returned from [`DagRunner::add_task`] for tasks with non-unit inputs.
/// After calling [`TaskBuilder::depends_on`], it becomes a [`TaskHandle`].
///
/// # Examples
///
@@ -47,13 +44,11 @@ use tracing::debug;
/// # 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 = ())
/// // a is TaskHandle 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()
/// // b is TaskBuilder until we call depends_on()
///
/// let b = b.depends_on(a);
/// // Now b is a TaskHandle<i32>
@@ -81,9 +76,8 @@ where
/// 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>, ...)`
/// - `Input = A`: Pass `TaskHandle<A>`
/// - `Input = (A, B, ...)`: Pass `(TaskHandle<A>, TaskHandle<B>, ...)`
///
/// The order of dependencies in the tuple must match the order in `Input`.
///
@@ -119,14 +113,14 @@ where
/// # async {
/// let mut dag = DagRunner::new();
///
/// let x = dag.add_task(Value(2)).into();
/// 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);
/// let double = dag.add_task(Scale(2)).depends_on(x);
///
/// // Multiple dependencies: tuple form
/// let sum = dag.add_task(Add).depends_on((&x, y));
/// let sum = dag.add_task(Add).depends_on((x, y));
///
///let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
/// # };
@@ -177,8 +171,6 @@ pub struct NodeId(pub u32);
/// 1. Pass it to [`crate::TaskBuilder::depends_on`] to wire up dependencies
/// 2. Use it with [`crate::DagOutput::get`] to retrieve the output after [`crate::DagRunner::run`]
///
/// Handles are cheap to clone and copy.
///
/// # Examples
///
/// ```no_run
@@ -215,9 +207,8 @@ impl<T> Copy for TaskHandle<T> {}
/// Takes a task and converts it to either a TaskBuilder or a TaskHandle,
/// depending on whether it has inputs or not.
///
/// This is useful to enforce at compile time that a task with unit input never has
/// depends_on called, and that it can be used directly as a dependency
/// without converting it manually to a TaskHandle.
/// This is useful to enforce at compile time that a TaskBuilder is never created for task with unit input,
/// and that it can be used directly as a dependency without converting it manually to a TaskHandle.
pub trait TaskWire<Input>: Task<Input> + Sync + 'static
where
Input: Send + Sync + 'static,
+10 -17
View File
@@ -1,11 +1,7 @@
//! 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.
//! various combinations of handles for tuples of different sizes.
use crate::builder::{NodeId, TaskHandle};
@@ -29,9 +25,6 @@ impl<O> DepsTuple<(O,)> for TaskHandle<O> {
/// Macro to implement DepsTuple for different tuple sizes.
///
/// This macro exists because Rust lacks variadic generics - we need separate implementations
/// for each tuple size.
///
/// This allows flexible dependency specification:
/// ```ignore
/// // Using handles
@@ -41,7 +34,7 @@ impl<O> DepsTuple<(O,)> for TaskHandle<O> {
/// task.depends_on((dag.add_task(TaskA), dag.add_task(TaskB)))
/// ```
macro_rules! impl_deps_tuple {
($($T:ident : $O:ident),+) => {
($($O:ident),+) => {
impl<$($O),+> DepsTuple<($($O,)+)> for ($(TaskHandle<$O>,)+)
{
#[allow(non_snake_case)]
@@ -55,14 +48,14 @@ macro_rules! impl_deps_tuple {
// Generate DepsTuple implementations for tuples of size 1-8.
// Supporting up to 8 elements covers the vast majority of use cases.
impl_deps_tuple!(T1:O1);
impl_deps_tuple!(T1:O1, T2:O2);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6, T7:O7);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6, T7:O7, T8:O8);
impl_deps_tuple!(O1);
impl_deps_tuple!(O1, O2);
impl_deps_tuple!(O1, O2, O3);
impl_deps_tuple!(O1, O2, O3, O4);
impl_deps_tuple!(O1, O2, O3, O4, O5);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6, O7);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6, O7, O8);
#[cfg(test)]
mod tests;
+19 -316
View File
@@ -10,15 +10,8 @@
//! - **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.
//! - **Works with ANY type**: Custom types work automatically with the `#[task]` macro—just
//! implement `Send + Sync`, no trait implementations needed! The macro generates
//! type-specific extraction logic for seamless integration.
//! - **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. This is what prevents cycles at compile time.
//! - **Runtime-agnostic**: Works with any async runtime (Tokio, smol, Embassy, etc.)
//! - **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 validation
//!
//! # Quick Start
//!
@@ -46,10 +39,10 @@
//!
//! // Add source tasks
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3)).into(); // Allows y to be reused as a dependency
//! let y = dag.add_task(Value(3));
//!
//! // Add task with dependencies
//! let sum = dag.add_task(Add).depends_on((x, &y));
//! let sum = dag.add_task(Add).depends_on((x, y));
//!
//! // Execute and retrieve results
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
@@ -66,10 +59,9 @@
//! that task executes inline rather than being spawned. This eliminates spawning overhead,
//! and context switching.
//!
//! **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.
//! **Panic handling**: To maintain consistent behavior between runtimes,
//! panics in tasks are caught using `FutureExt::catch_unwind()` and converted to
//! `DagError::TaskPanicked`. This matches the behavior of async runtimes like Tokio.
//!
//! **What this means for you**:
//! - Tasks behave identically whether executed inline or spawned
@@ -78,8 +70,6 @@
//! - 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
@@ -89,71 +79,11 @@
//! 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.
//! A [`Task`] is a unit of async work with typed inputs and outputs. Use the `#[task]` macro to generate an implementation.
//!
//! ### Task Patterns
//!
@@ -220,7 +150,7 @@
//!
//! ### No Dependencies (Source Tasks)
//!
//! Tasks with no dependencies don't call `depends_on()`:
//! Tasks with no dependencies return a [`TaskHandle`] directly and don't call `depends_on()`:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
@@ -258,6 +188,8 @@
//! # };
//! ```
//!
//! To manually implement [`Task`] for a single-dependency type, you must implement [`Task<(T,)>`], not [`Task<T>`].
//!
//! ### Multiple Dependencies
//!
//! Tasks with multiple dependencies receive separate reference parameters (order matters!):
@@ -319,7 +251,7 @@
//! # async {
//! let mut dag = DagRunner::new();
//!
//! let base = dag.add_task(Value(10)).into();
//! 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);
//!
@@ -386,109 +318,10 @@
//! # };
//! ```
//!
//! ## 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 mut dag = DagRunner::new();
//!
//! // Layer 1: Sources
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3)).into();
//! 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
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//!
//! assert_eq!(output.get(total).unwrap(), 20);
//! # };
//! ```
//!
//! ## Custom Types
//!
//! **dagx works with ANY type automatically!** As long as your type is `Send + Sync + 'static`,
//! the `#[task]` macro generates the necessary extraction logic:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! #
//!
//! struct User {
//! name: String,
//! age: u32,
//! }
//!
//! struct CreateUser;
//! #[task]
//! impl CreateUser {
//! async fn run() -> User {
//! User { name: "Alice".to_string(), age: 30 }
//! }
//! }
//!
//! struct FormatUser;
//! #[task]
//! impl FormatUser {
//! async fn run(user: &User) -> String {
//! format!("{} is {} years old", user.name, user.age)
//! }
//! }
//!
//! # async {
//! let mut dag = DagRunner::new();
//!
//! let user = dag.add_task(CreateUser);
//! let formatted = dag.add_task(FormatUser).depends_on(user);
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//!
//! assert_eq!(output.get(formatted).unwrap(), "Alice is 30 years old");
//! # };
//! ```
//!
//! **No trait implementations needed!** Works with nested structs, collections, enums, and any
//! other custom type.
//!
//! # 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:
@@ -498,14 +331,8 @@
//! #[tokio::main]
//! async fn main() {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! }
//!
//! // With async-std
//! #[async_std::main]
//! async fn main() {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! // ... build DAG
//! let result = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await;
//! }
//!
//! // With smol
@@ -513,6 +340,7 @@
//! smol::block_on(async {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! let result = dag.run(|fut| smol::spawn(fut)).await;
//! });
//! }
//! ```
@@ -527,8 +355,6 @@
//! - [`DagOutput::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};
//! #
@@ -552,25 +378,6 @@
//! }
//! # };
//! ```
//!
//! # 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:
@@ -578,48 +385,13 @@
//! 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>`
//! 4. **Minimal Surface**: Small, focused API
//!
//! # 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.
//! See the [repository](https://github.com/swaits/dagx) for up-to-date benchmarks.
//!
//! ## Performance Tips
//!
@@ -653,7 +425,7 @@
//!
//! # async {
//! let mut dag = DagRunner::new();
//! let data = dag.add_task(FetchData).into();
//! let data = dag.add_task(FetchData);
//!
//! // All three tasks get efficient Arc-wrapped sharing automatically
//! let task1 = dag.add_task(ProcessData).depends_on(data);
@@ -667,77 +439,20 @@
//! **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))
//! - For fan-out (1→N), Arc is cloned N times
//! - Each downstream task receives `&T` after extracting from Arc
//! - When you call `output.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.
//!
//! # Optional Tracing Support
//!
//! dagx provides optional observability through the `tracing` crate with **zero runtime overhead
//! when disabled**. The tracing instrumentation is conditionally compiled using feature flags.
//!
//! ## Enabling Tracing
//!
//! Add the `tracing` feature to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! dagx = { version = "0.2", features = ["tracing"] }
//! tracing-subscriber = "0.3"
//! ```
//!
//! Then initialize a tracing subscriber in your application:
//!
//! ```no_run
//! use tracing_subscriber::{fmt, EnvFilter};
//!
//! fmt()
//! .with_env_filter(
//! EnvFilter::try_from_default_env()
//! .unwrap_or_else(|_| EnvFilter::new("dagx=info"))
//! )
//! .init();
//! ```
//! dagx provides optional observability through the `tracing` crate. It can be enabled with the `tracing` feature flag.
//!
//! ## Log Levels
//!
@@ -753,21 +468,9 @@
//! RUST_LOG=dagx=debug cargo run # Task and layer details
//! RUST_LOG=dagx=trace cargo run # All execution details
//! ```
//!
//! ## Zero-Cost Guarantee
//!
//! When the `tracing` feature is disabled (the default), there is **literally 0ns overhead**:
//! - Logging code is removed at compile time via `#[cfg(feature = "tracing")]`
//! - No branches, no function calls, nothing in the compiled binary
//! - The `tracing` crate isn't even linked
//! - Benchmarks verify identical performance with/without the feature
//!
//! This follows the same zero-cost pattern used by Tokio, Hyper, and other performance-critical
//! Rust async libraries.
extern crate self as dagx;
// Module declarations
mod builder;
mod deps;
mod error;
+3 -8
View File
@@ -27,8 +27,6 @@ type ExecuteFuture =
///
/// This is the ONLY place where type erasure occurs - tasks 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 {
/// Execute the task with type-erased inputs and output
///
@@ -43,14 +41,14 @@ pub(crate) trait ExecutableNode: Send {
/// 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.
/// allowing heterogeneous tasks to be stored together.
///
/// # 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.
///
/// The output is also returned from execute_with_deps for central storage.
/// The output is also returned from execute_with_deps to be passed to dependents and returned as an output
pub(crate) struct TypedNode<Input, T>
where
Input: Send + Sync + 'static,
@@ -84,14 +82,11 @@ where
) -> ExecuteFuture {
Box::pin(async move {
// Use the task's run method which has inline extraction logic
// generated by the #[task] macro. This allows ANY type to work without
// requiring ExtractInput trait implementations.
// generated by the #[task] macro.
let output = self.task.run(TaskInput::new(dependencies.iter())).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 extraction from Arc.
let arc_output = Arc::new(output);
+3 -4
View File
@@ -12,13 +12,14 @@ impl DagOutput {
/// # Behavior
///
/// All task outputs are stored after execution and can be retrieved via get().
/// Each task's output can only be taken once, after which it will return `DagError::ResultNotFound`.
/// Each task's output can only be retrieved once, after which it will return `DagError::ResultNotFound`.
///
/// # Errors
///
/// Returns `DagError::ResultNotFound` if:
/// - The task hasn't been executed yet
/// - The handle is invalid
/// - The output has already been retrieved
///
/// # Examples
///
@@ -46,14 +47,12 @@ impl DagOutput {
/// // Construct task with specific setting value
/// let task = dag.add_task(Configuration::new(42));
///
///let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
/// let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
///
/// assert_eq!(output.get(task).unwrap(), 42);
/// # };
/// ```
pub fn get<T: 'static + Send + Sync>(&mut self, handle: TaskHandle<T>) -> DagResult<T> {
let handle: TaskHandle<T> = handle.into();
let arc_output = self
.outputs
.remove(&handle.id)
+17 -53
View File
@@ -2,8 +2,6 @@
//!
//! 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::any::Any;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -23,6 +21,7 @@ use crate::error::{DagError, DagResult};
use crate::node::{ExecutableNode, TypedNode};
use crate::DagOutput;
/// Fast hasher using values as hashes
#[derive(Default, Clone)]
pub(crate) struct PassThroughHasher {
hash: u64,
@@ -82,7 +81,6 @@ pub(crate) type PassThroughHashMap<K, V> = HashMap<K, V, PassThroughHasher>;
/// async fn run(&mut self) -> i32 { self.value }
/// }
///
/// // Unit struct - no fields needed
/// struct Add;
///
/// #[task]
@@ -103,17 +101,12 @@ pub(crate) type PassThroughHashMap<K, V> = HashMap<K, V, PassThroughHasher>;
/// assert_eq!(output.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: Vec<Option<Box<dyn ExecutableNode + Sync>>>,
pub(crate) edges: PassThroughHashMap<NodeId, Vec<NodeId>>, // node -> dependencies
pub(crate) dependents: PassThroughHashMap<NodeId, Vec<NodeId>>, // node -> tasks that depend on it
/// node -> dependencies
pub(crate) edges: PassThroughHashMap<NodeId, Vec<NodeId>>,
/// node -> tasks that depend on it
pub(crate) dependents: PassThroughHashMap<NodeId, Vec<NodeId>>,
}
impl Default for DagRunner {
@@ -143,8 +136,8 @@ impl DagRunner {
/// Add a task instance to the DAG, returning a node builder for wiring dependencies.
///
/// If the task has no dependencies, a [`crate::TaskHandle`] will be returned.
/// If not, the returned [`crate::TaskBuilder`] can be used to
/// specify dependencies via [`crate::TaskBuilder::depends_on`].
/// If not, dependencies should be specified for the returned [`crate::TaskBuilder`]
/// using [`crate::TaskBuilder::depends_on`].
///
/// # Examples
///
@@ -220,11 +213,9 @@ impl DagRunner {
/// 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
@@ -233,13 +224,11 @@ impl DagRunner {
/// and returns a handle to the task. This is the only way to run tasks on separate threads. Examples:
/// - Tokio: `|fut| { tokio::spawn(fut).await.unwrap() }`
/// - Smol: `|fut| { smol::spawn(fut) }`
/// - Async-std: `|fut| { async_std::task::spawn(fut) }`
/// - Single-threaded: `|fut| fut`
/// - For computationally light tasks, concurrency without parallelism can be significantly faster.
/// - Single-threaded on invoking runtime: `|fut| fut`
/// - Can be faster in situations where waiting time dominates
///
/// # Errors
///
/// - Returns `DagError::ConcurrentExecution` if the DAG is already executing
/// - Returns `DagError::TaskPanicked` if any task panics during execution
///
/// # Examples
@@ -298,36 +287,24 @@ impl DagRunner {
HashMap::with_capacity_and_hasher(total_tasks, PassThroughHasher::default());
let mut first_error = None;
// Panic handling is required to maintain behavioral consistency, as
// different async runtimes (Tokio, async-std, smol, embassy-rs) handle panics in
// spawned tasks differently.
//
// This ensures tasks behave in the same way whether executed inline or on the spawner
// across every runtime.
for layer in layers {
#[cfg(feature = "tracing")]
{
debug!(task_count = layer.len(), "executing layer");
}
// ============================================================================
// PERFORMANCE OPTIMIZATION: Inline execution for single-task 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
// - 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];
@@ -348,11 +325,6 @@ impl DagRunner {
.collect();
// 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_deps(dependencies))
.catch_unwind()
.await
@@ -461,7 +433,7 @@ impl DagRunner {
}
}
// Return first error if any
// Return first error if any, aborting execution after this layer
if let Some(err) = first_error {
#[cfg(feature = "tracing")]
error!(?err, "DAG execution failed");
@@ -475,7 +447,6 @@ impl DagRunner {
Ok(DagOutput::new(outputs))
}
#[inline]
fn compute_layers(&self) -> DagResult<Vec<Vec<NodeId>>> {
#[cfg(feature = "tracing")]
debug!("computing topological layers");
@@ -541,10 +512,6 @@ impl DagRunner {
//
// This eliminates the need for runtime cycle detection, providing both
// safety guarantees and performance benefits (no validation overhead).
// Compiler optimization barrier: ensures visited set is fully populated.
// This assertion compiles to nothing in release builds but guides the optimizer's
// understanding of the HashSet state, improving subsequent code generation.
debug_assert!(!visited.is_empty() || layers.is_empty());
#[cfg(feature = "tracing")]
@@ -554,8 +521,5 @@ impl DagRunner {
}
}
// Note: We cannot implement Default for DagRunner anymore since new() returns Arc<Self>.
// Users should call DagRunner::new() directly.
#[cfg(test)]
mod tests;
+16 -66
View File
@@ -7,72 +7,14 @@ use std::{any::Any, future::Future, marker::PhantomData, slice::Iter, sync::Arc}
/// A unit of async work with typed inputs and outputs.
///
/// Use the [`task`](crate::task) macro to implement this trait. The macro automatically
/// Use the [`crate::task`] macro to implement this trait. The macro automatically
/// derives `Input` and `Output` types from your `run()` method signature and generates
/// type-specific extraction logic in `run()`.
/// extraction logic in `run()`.
///
/// **Custom types work automatically!** As long as your output type implements
/// `Send + Sync + 'static`, the macro handles everything. No trait implementations needed!
/// `Send + Sync + 'static`, the macro handles everything.
///
/// # 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
/// }
/// }
/// ```
/// **Note:** When implementing this trait manually for a task with a single dependency, the Input type must be (T,) instead of T.
///
/// # Input Patterns
///
@@ -80,10 +22,6 @@ use std::{any::Any, future::Future, marker::PhantomData, slice::Iter, sync::Arc}
/// - `&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.
@@ -101,6 +39,17 @@ where
fn run(self, input: TaskInput<Input>) -> impl Future<Output = Self::Output> + Send;
}
/// Linear type providing a [`Task`] access to its dependencies.
///
/// The framework ensures that all dependencies will be present when this task runs, and
/// can be retrieved with [`TaskInput::next`]. The nth call to `next` consumes this TaskInput instances and returns a
/// (&T, TaskInput) tuple with the nth dependency specified in the call to [`crate::TaskBuilder::depends_on`] and a
/// TaskInput with the remaining dependencies.
///
/// No dependencies can be retrieved from an empty TaskInput (Input = ()).
///
/// **Note:** Calling [`std::mem::forget`] on a `TaskInput` instance will make it impossible to retrieve outputs of dependencies.
/// There's no reason to ever do this.
pub struct TaskInput<'inputs, Input: Send> {
inputs: Iter<'inputs, Arc<dyn Any + Send + Sync + 'static>>,
phantom: PhantomData<Input>,
@@ -118,6 +67,7 @@ impl<'inputs, Input: Send> TaskInput<'inputs, Input> {
macro_rules! impl_task_input {
($First:ident $(, $T:ident)*) => {
impl<'inputs, $First: 'static + Send, $($T: 'static + Send),*> TaskInput<'inputs, ($First, $($T,)*)> {
/// Get a reference to the next dependency from this TaskInput, and a [`TaskInput`] instance with the remaining dependencies.
#[must_use]
pub fn next(mut self) -> (&'inputs $First, TaskInput<'inputs, ($($T,)*)>) {
let value = self.inputs.next().and_then(|value| value.downcast_ref()).unwrap();