Author SHA1 Message Date
swaits 5839b35979 test: add comprehensive testing infrastructure (fuzzing, proptest, mutation)
Implements dagx-11: Add comprehensive testing infrastructure including
fuzzing, property-based testing, and mutation testing configuration.

**Fuzzing Infrastructure:**
- Created fuzz/ directory with cargo-fuzz setup
- 4 fuzz targets covering different scenarios:
  - fuzz_dag_execution: Basic DAG execution with various patterns
  - fuzz_dependencies: Dependency management (linear, diamond, complex)
  - fuzz_type_safety: Type-safe output retrieval
  - fuzz_concurrent: Concurrent execution and thread safety
- Comprehensive README with usage instructions
- Integrated with justfile (fuzz, fuzz-all, fuzz-extended, fuzz-build)

**Property-Based Testing:**
- Added proptest dependency to Cargo.toml
- Created tests/property_tests.rs with 14 property tests:
  - Value preservation through DAG
  - Linear chain correctness
  - Fan-out pattern verification
  - Diamond pattern correctness
  - String, Vec, Option, Result handling
  - HashMap and custom struct preservation
  - DAG isolation
  - Concurrent execution scaling
- Integrated with justfile (proptest recipe)

**Mutation Testing:**
- Created .cargo-mutants.toml configuration
- Focused on critical functions:
  - DagRunner::run and ::get
  - TaskBuilder::depends_on
  - ExecutableNode::execute_with_listeners
  - ExtractFromListeners::extract
  - DepsTuple::to_node_ids
- Excluded low-value mutations (fmt, clone, default)
- Configured for nextest and parallel execution
- Already integrated via existing justfile mutants recipe

**Justfile Updates:**
- Added fuzzing section with 5 new recipes
- Added proptest recipe for property-based tests
- All recipes follow existing patterns and naming conventions

Similar setup to ../announcement project but tailored for DAG-specific testing.
2025-10-14 10:44:30 -06:00
swaits 10af81ade7 test: comprehensive test improvements and infrastructure
This commit includes multiple test-related improvements from porting
parallel branch work and fixing test failures:

- Add justfile for development workflows (dagx-10)
  - Quick dev loop (fmt + clippy + test)
  - Pre-commit checks, pre-push validation, pre-release checks
  - Coverage, mutation testing, benchmarks, examples
  - Security audits, Miri, and more

- Port type mismatch tests for DagRunner::get() (dagx-13)
  - Test i32 to Vec<u8> mismatch
  - Test custom struct to HashMap mismatch
  - Test Vec<i32> to Vec<String> mismatch
  - Test Option<i32> to Option<String> mismatch

- Port TaskBuilder conversion tests (dagx-14)
  - Test conversion with multiple tasks preserves IDs
  - Test conversion with stateful tasks
  - Test conversion with complex output types (Vec, Option)

- Add custom_types.rs example (dagx-15)
  - Demonstrates custom User type with fan-out pattern
  - Shows Arc wrapping is transparent to users
  - Comprehensive documentation

- Fix all failing tests (dagx-16)
  - Remove incompatible task_fn tests from task/tests.rs
    (task_fn requires Arc-wrapped types, tests used raw types)
  - Fix listener type downcasts in node/tests.rs
    (Listener<Arc<i32>> not Listener<i32>)
  - Add #[allow(dead_code)] to unused test structs

- Cleanup unnecessary #[allow(dead_code)] in deps/tests.rs (dagx-9)

All 60 tests now pass.
2025-10-14 10:12:07 -06:00
swaits 0c303ed9e4 chore: add justfile with initial jobs 2025-10-14 10:11:50 -06:00
swaits 04d1c13122 WIP: perf: test regression, needs Arc<T> 2025-10-14 09:48:14 -06:00
swaits 89fbd26a47 chore: remove Cargo.lock (library crates should not commit lockfiles)
Library crates should not commit Cargo.lock files. Only application
crates need Cargo.lock for reproducible builds.

This ensures library users control their own dependency resolution
rather than being locked to specific versions from the library's
lock file.

Changes:
- Add Cargo.lock to .gitignore to prevent future commits
- Delete Cargo.lock file from repository (37KB)
2025-10-14 09:48:07 -06:00
swaits 6de4d43db1 WIP: switch to announcement, remove extract input mess 2025-10-14 09:01:28 -06:00
71 changed files with 8021 additions and 2783 deletions

No files matched your search

BIN
View File
Binary file not shown.
+142
View File
@@ -0,0 +1,142 @@
# cargo-mutants configuration for the dagx crate
#
# This configuration optimizes mutation testing by:
# - Focusing on correctness-critical code
# - Excluding low-value mutations
# - Setting appropriate timeouts
# - Configuring parallel execution
# Overall timeout for each mutant test
# Increased from default to handle async tests
timeout = 300 # 5 minutes per mutant
# Number of parallel jobs
# Set to number of CPU cores for faster execution
# jobs = 8 # Uncomment and adjust for your system
# Test tool to use
# nextest is faster than cargo test
test_tool = "nextest"
# Additional arguments to pass to the test command
# --no-fail-fast ensures all tests run even if one fails
test_args = ["--no-fail-fast"]
# Exclude patterns for files that shouldn't be mutated
# These are low-value targets that produce noise
exclude_globs = [
# Test files - we test the tests, not mutate them
"tests/**",
"**/tests.rs",
"benches/**",
"examples/**",
# Proc macro crate
"dagx-macros/**",
# Build scripts
"build.rs",
]
# Exclude specific functions/methods that shouldn't be mutated
# These are low-value mutations that don't test correctness
[[exclude_functions]]
# Display trait implementations - not correctness-critical
regex = ".*::fmt"
reason = "Display/Debug implementations don't affect correctness"
[[exclude_functions]]
# Clone implementations - trivial mutations
regex = ".*::clone"
reason = "Clone implementations are trivial"
[[exclude_functions]]
# Default implementations - trivial
regex = ".*::default"
reason = "Default implementations are trivial"
# Specific functions to focus on (include)
# These are the correctness-critical paths
[[examine_functions]]
# Core DAG execution logic
regex = "DagRunner::run"
reason = "Critical: Core DAG execution orchestration"
[[examine_functions]]
# Task addition and dependency tracking
regex = "DagRunner::add_task"
reason = "Critical: Task registration and node creation"
[[examine_functions]]
# Dependency management
regex = "TaskBuilder::depends_on"
reason = "Critical: Dependency edge creation"
[[examine_functions]]
# Output extraction and type checking
regex = "DagRunner::get"
reason = "Critical: Type-safe output retrieval"
[[examine_functions]]
# Node execution with listeners
regex = "ExecutableNode::execute_with_listeners"
reason = "Critical: Task execution and value propagation"
[[examine_functions]]
# Listener extraction from announcements
regex = "ExtractFromListeners::extract"
reason = "Critical: Input extraction for dependent tasks"
[[examine_functions]]
# Dependency tuple conversion
regex = "DepsTuple::to_node_ids"
reason = "Critical: Dependency tracking correctness"
# Minimum test time threshold
# If tests complete faster than this, warn about insufficient testing
# minimum_test_time = 1.0 # Uncomment to enable
# Output formats
# Generate both text and JSON for tracking
# output_formats = ["text", "json"] # Uncomment to enable
# Baseline
# Compare mutations against a baseline to detect regressions
# baseline = "main" # Uncomment to enable baseline comparison
# Notes on configuration strategy:
#
# 1. FOCUS: We explicitly list critical functions to mutate
# - DAG execution: run(), execute_with_listeners()
# - Dependency tracking: add_task(), depends_on(), to_node_ids()
# - Type safety: get() with downcasting
# - Value propagation: extract() from listeners
#
# 2. EXCLUDE: We exclude low-value targets
# - Display/Debug impls: String formatting doesn't affect correctness
# - Clone/Default impls: Trivial, unlikely to have bugs
# - Tests themselves: We test production code, not tests
# - Proc macros: Generated code tested via integration
#
# 3. TIMEOUT: 5 minutes is generous
# - Most mutants complete in seconds
# - Allows for async tests with real execution
# - Catches actual deadlocks without false positives
#
# 4. NEXTEST: Faster test execution
# - Parallel test execution
# - Better output formatting
# - Improved reliability
#
# 5. NO-FAIL-FAST: See all failures
# - Don't stop at first test failure
# - Get complete picture of mutation impact
# - Better for debugging
#
# Expected mutation counts with this configuration:
# - Target: ~30-50 mutants for core logic
# - Score: 90%+ (high bar for DAG correctness)
#
# Runtime with this configuration:
# - Current: ~5-10 minutes
# - With nextest: ~30% faster
+1
View File
@@ -2,3 +2,4 @@
/.jj
/target
Cargo.lock
+1
View File
@@ -0,0 +1 @@
We track work in Beads instead of Markdown. Run \`bd quickstart\` to see how.
Generated
-1475
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -25,6 +25,7 @@ tracing = ["dep:tracing"]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin)', 'cfg(tarpaulin_include)'] }
[dependencies]
announcement = "0.1.0"
futures = "0.3"
parking_lot = "0.12"
dagx-macros = { version = "0.3.0", path = "./dagx-macros" }
@@ -35,6 +36,8 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"
async-std = { version = "1", features = ["attributes"] }
smol = "2"
criterion = "0.7"
# Property-based testing
proptest = "1"
# Quirky async runtimes for edge-case testing
async-executor = "1.13"
pollster = "0.4"
+134
View File
@@ -0,0 +1,134 @@
# Performance Regression Analysis: ETL Benchmark
## Summary
The ETL scaling benchmark regressed by ~17% (3.24ms -> 3.30ms for 10k records) due to expensive data cloning when using the announcement crate.
## Root Cause
In both `dagx-macros/src/lib.rs:327` and `src/task.rs:253`:
```rust
let _ = announcer_typed.announce((*output_arc).clone());
```
This dereferences the Arc and clones the actual data, which is extremely expensive for large types.
### For ETL benchmark with 10k strings:
**Current (Broken) Flow:**
1. Task produces `Vec<String>` (10k items)
2. Wrap in `Arc<Vec<String>>`
3. **Announce `(*arc).clone()`** → Full Vec clone #1 (EXPENSIVE!)
4. Announcement stores cloned Vec in `OnceLock<Vec<String>>`
5. Each of 3 listeners calls `.listen()` → Vec clone #2, #3, #4 (EXPENSIVE!)
**Total**: 4 full Vec clones = 40k string clones
### Why other benchmarks improved:
Most benchmarks use primitive types (i32, etc.) where cloning is cheap. The announcement crate's broadcast pattern is more efficient than multiple oneshot channels for small types.
## The Fix
Change announcements to use `Arc<T>` instead of `T`:
**New (Correct) Flow:**
1. Task produces `Vec<String>` (10k items)
2. Wrap in `Arc<Vec<String>>`
3. **Announce `Arc::clone(&arc)`** → Arc clone (CHEAP!)
4. Announcement stores Arc in `OnceLock<Arc<Vec<String>>>`
5. Each of 3 listeners calls `.listen()` → Arc clone #2, #3, #4 (CHEAP!)
**Total**: 4 Arc clones = just refcount increments (~40ns total)
## Required Changes
### 1. dagx-macros/src/lib.rs
```rust
// Line 327: Change announcement call
- let _ = announcer_typed.announce((*output_arc).clone());
+ let _ = announcer_typed.announce(Arc::clone(&output_arc));
// Line 325: Change announcer type
- .downcast::<announcement::Announcer<#output_type>>()
+ .downcast::<announcement::Announcer<Arc<#output_type>>>()
// Line 352: Change announcement creation
- let (announcer, announcement) = announcement::Announcement::<#output_type>::new();
+ let (announcer, announcement) = announcement::Announcement::<Arc<#output_type>>::new();
// Line 257: Change listener type in parameter extraction
- .downcast_ref::<announcement::Listener<#ty>>()
+ .downcast_ref::<announcement::Listener<Arc<#ty>>>()
// Line 361: Change announcement downcast
- .downcast_ref::<announcement::Announcement<#output_type>>()
+ .downcast_ref::<announcement::Announcement<Arc<#output_type>>>()
```
### 2. src/task.rs
```rust
// Line 253: Change announcement call
- let _ = announcer_typed.announce((*output_arc).clone());
+ let _ = announcer_typed.announce(Arc::clone(&output_arc));
// Line 249: Change announcer type
- .downcast::<announcement::Announcer<O>>()
+ .downcast::<announcement::Announcer<Arc<O>>>()
// Line 261: Change announcement creation
- let (announcer, announcement) = announcement::Announcement::<O>::new();
+ let (announcer, announcement) = announcement::Announcement::<Arc<O>>::new();
// Line 270: Change announcement downcast
- .downcast_ref::<announcement::Announcement<O>>()
+ .downcast_ref::<announcement::Announcement<Arc<O>>>()
// Lines 34, 57, 67, 84, 102, 125: Change all listener downcasts from:
- .downcast::<announcement::Listener<T>>()
+ .downcast::<announcement::Listener<Arc<T>>>()
// Then unwrap the Arc after listening:
let arc_value = listener.listen().await?;
let value = (*arc_value).clone(); // Only clone primitives, Arc is already cheap for complex types
```
### 3. Handle Arc unwrapping in ExtractFromListeners
For each type implementation, extract from `Listener<Arc<T>>` and then dereference:
```rust
impl ExtractFromListeners for Vec<T> {
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
let listener = *listeners.pop().unwrap()
.downcast::<announcement::Listener<Arc<Vec<T>>>>() // Now Arc<Vec<T>>
.map_err(...)?;
let arc_vec = listener.listen().await.map_err(...)?;
Ok((*arc_vec).clone()) // Clone the Vec out of the Arc
}
}
```
Actually, a better approach: keep the Arc! For types that are expensive to clone, just return the Arc:
```rust
// For expensive types, use Arc directly in function signatures
async fn run(&self, data: &Arc<Vec<String>>) -> Result { ... }
```
Or even simpler: change ExtractFromListeners to extract as the Arc type directly, and modify the macro's parameter extraction to work with Arc types.
## Expected Performance Impact
- **ETL 10k benchmark**: Should improve by ~17% back to baseline (or better)
- **Other benchmarks**: Should remain the same or slightly improve (Arc clone overhead is minimal)
- **Memory efficiency**: Better! Arc refcounting instead of deep copies
## Why This Wasn't Caught
- Most tests use small primitive types where clone() is fast
- The regression only shows up with large data structures (Vec, HashMap, etc.)
- The ETL benchmark with 10k records is the perfect stress test for this issue
+1 -1
View File
@@ -1,7 +1,7 @@
//! DAG creation benchmarks
use criterion::Criterion;
use dagx::{DagRunner, Task};
use dagx::DagRunner;
use std::hint::black_box;
// Simple task for benchmarking
+1 -1
View File
@@ -1,7 +1,7 @@
//! Basic DAG execution benchmarks
use criterion::Criterion;
use dagx::{task_fn, DagRunner, Task};
use dagx::{task_fn, DagRunner};
pub fn bench_dag_execution(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
+5 -5
View File
@@ -37,7 +37,7 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
// T1: Parse and validate
let validate = dag
.add_task(task_fn(move |data: Vec<String>| async move {
.add_task(task_fn(move |data: std::sync::Arc<Vec<String>>| async move {
data.iter()
.filter(|s| {
s.contains("\"id\"")
@@ -50,7 +50,7 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
// T2: Extract scores
let scores = dag
.add_task(task_fn(move |data: Vec<String>| async move {
.add_task(task_fn(move |data: std::sync::Arc<Vec<String>>| async move {
data.iter()
.filter_map(|s| {
s.split("\"score\":").nth(1).and_then(|part| {
@@ -63,7 +63,7 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
// T3: Extract names
let names = dag
.add_task(task_fn(move |data: Vec<String>| async move {
.add_task(task_fn(move |data: std::sync::Arc<Vec<String>>| async move {
data.iter()
.filter_map(|s| {
s.split("\"name\":\"")
@@ -77,12 +77,12 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
// Load: Aggregate all transformations
dag.add_task(task_fn(
move |(valid, scores, names): (usize, Vec<i32>, Vec<String>)| async move {
move |(valid, scores, names): (std::sync::Arc<usize>, std::sync::Arc<Vec<i32>>, std::sync::Arc<Vec<String>>)| async move {
let avg_score =
scores.iter().sum::<i32>() / scores.len().max(1) as i32;
format!(
"Processed {} valid records, {} names, avg score: {}",
valid,
*valid,
names.len(),
avg_score
)
+136 -172
View File
@@ -1,17 +1,17 @@
//! Procedural macros for dagx
//!
//! This crate provides the `#[task]` attribute macro that automatically implements
//! the `Task` trait by deriving Input and Output types from the `run()` method signature.
//! This crate provides the `#[task]` attribute macro that automatically generates
//! an `execute()` method by deriving Input and Output types from the `run()` method signature.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType, Type};
/// Attribute macro to automatically implement the `Task` trait.
/// Attribute macro to automatically generate the execution logic for a task.
///
/// Apply this to an `impl` block containing a `run()` method (sync or async). The macro:
/// - Derives `Input` and `Output` types from the `run()` signature
/// - Automatically implements the `Task` trait
/// - Derives input types from the `run()` signature parameters
/// - Generates an `execute()` method that handles listener extraction and announcement
/// - Supports both sync and async run methods
/// - Supports stateless (no self) and stateful (&self, &mut self) tasks
/// - Handles various input patterns (no inputs, single input, multiple inputs)
@@ -25,7 +25,7 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType
/// Unit structs for pure computations. Use **no `self` parameter**:
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct Add;
///
@@ -42,7 +42,7 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType
/// Tasks that read configuration or constant data. Use **`&self`**:
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct Multiplier {
/// factor: i32,
@@ -61,7 +61,7 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType
/// Tasks that accumulate or modify state. Use **`&mut self`**:
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct Counter {
/// count: i32,
@@ -81,7 +81,7 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType
/// ## No Inputs (Source Tasks)
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct LoadData {
/// value: i32,
@@ -93,110 +93,51 @@ use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Pat, PatType, ReturnType
/// self.value
/// }
/// }
/// ```ignore
/// ```
///
/// ## Single Input
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct Double;
///
/// #[task]
/// impl Double {
/// async fn run(&mut self, input: &i32) -> i32 {
/// async fn run(input: &i32) -> i32 {
/// input * 2
/// }
/// }
/// ```ignore
/// ```
///
/// ## Multiple Inputs (up to 8)
///
/// ```ignore
/// use dagx::{task, Task};
/// use dagx::task;
///
/// struct Combine;
///
/// #[task]
/// impl Combine {
/// async fn run(&mut self, a: &i32, b: &String, c: &bool) -> String {
/// async fn run(a: &i32, b: &String, c: &bool) -> String {
/// format!("{}: {} ({})", b, a, c)
/// }
/// }
/// ```ignore
/// ```
///
/// # Requirements
///
/// - The impl block must contain exactly one `async fn run()` method
/// - The `run()` method can be stateless (no self parameter) or stateful (`&mut self`)
/// - All input parameters must be references (e.g., `&i32`, not `i32`)
/// - The macro requires `Task` to be in scope: `use dagx::Task;`
/// - For stateless tasks, the struct must implement `Default` (e.g., unit structs)
///
/// # Generated Code
///
/// The macro transforms your implementation into a full `Task` trait implementation.
///
/// For stateless tasks (no self parameter):
///
/// ```ignore
/// // Your code:
/// #[task]
/// impl Add {
/// async fn run(a: &i32, b: &i32) -> i32 {
/// a + b
/// }
/// }
///
/// // Generated:
/// impl Task for Add {
/// type Input = (i32, i32);
/// type Output = i32;
///
/// async fn run(&mut self, input: Self::Input) -> Self::Output {
/// let (a, b) = input;
/// Self::run_impl(&a, &b).await
/// }
/// }
///
/// impl Add {
/// #[inline]
/// async fn run_impl(a: &i32, b: &i32) -> i32 {
/// a + b
/// }
/// }
/// ```ignore
///
/// For stateful tasks (with &mut self):
///
/// ```ignore
/// // Your code:
/// #[task]
/// impl Counter {
/// async fn run(&mut self, inc: &i32) -> i32 {
/// self.count += inc;
/// self.count
/// }
/// }
///
/// // Generated:
/// impl Task for Counter {
/// type Input = i32;
/// type Output = i32;
///
/// async fn run(&mut self, input: Self::Input) -> Self::Output {
/// let inc = input;
/// self.run_impl(&inc).await
/// }
/// }
///
/// impl Counter {
/// async fn run_impl(&mut self, inc: &i32) -> i32 {
/// self.count += inc;
/// self.count
/// }
/// }
/// ```ignore
/// The macro transforms your implementation into an `execute()` method that:
/// - Extracts inputs from announcement listeners
/// - Calls your run() method
/// - Announces the result to dependents
/// - Returns the Arc-wrapped output
#[proc_macro_attribute]
pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
let impl_block = parse_macro_input!(item as ItemImpl);
@@ -220,7 +161,7 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
"impl block must contain a run() method\n\n\
Expected signature: fn run(&mut self, ...) -> OutputType\n\
or: async fn run(&mut self, ...) -> OutputType\n\
The #[task] macro requires a run() method to implement the Task trait.",
The #[task] macro requires a run() method to implement task execution.",
)
.to_compile_error()
.into();
@@ -287,31 +228,6 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
ReturnType::Type(_, ty) => ty.clone(),
};
// Build Input type based on parameter count
let input_type = match params.len() {
0 => quote! { () },
1 => {
let (_name, ty) = &params[0];
quote! { #ty }
}
_ => {
let types: Vec<_> = params.iter().map(|(_, ty)| ty).collect();
quote! { ( #(#types),* ) }
}
};
// Generate parameter destructuring for the wrapper run() method
let (param_destructure, param_refs) = if params.is_empty() {
(quote! { _ }, quote! {})
} else if params.len() == 1 {
let (name, _) = &params[0];
(quote! { #name }, quote! { &#name })
} else {
let names: Vec<_> = params.iter().map(|(name, _)| name).collect();
let refs: Vec<_> = params.iter().map(|(name, _)| quote! { &#name }).collect();
(quote! { ( #(#names),* ) }, quote! { #(#refs),* })
};
// Clone the run method and rename it to run_impl
let mut run_impl_method = run_method.clone();
run_impl_method.sig.ident = syn::Ident::new("run_impl", run_method.sig.ident.span());
@@ -323,81 +239,129 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
.iter()
.any(|arg| matches!(arg, FnArg::Receiver(_)));
// Generate the Task trait implementation based on whether we have self and async/sync
let expanded = if has_self_receiver {
// Stateful task - consumes self but delegates to a method that borrows
// Generate listener extraction code
let listener_extractions: Vec<_> = params
.iter()
.enumerate()
.map(|(i, (name, ty))| {
quote! {
let #name = {
if listeners.len() <= #i {
return Err(format!(
"Expected at least {} listener(s), got {}",
#i + 1,
listeners.len()
));
}
let listener = listeners[#i]
.downcast_ref::<announcement::Listener<std::sync::Arc<#ty>>>()
.ok_or_else(|| {
format!("Type mismatch: expected Listener<Arc<{}>> at index {}",
stringify!(#ty), #i)
})?;
listener.listen().await
.map_err(|_| "Listener closed before receiving value")?
};
}
})
.collect();
// Generate parameter refs for the run call
// Dereference the Arc to get &T for the user's function
let param_refs: Vec<_> = params.iter().map(|(name, _)| quote! { &*#name }).collect();
// Generate the execute method body based on whether we have self and async/sync
let run_call = if has_self_receiver {
if is_async {
// Async with self
quote! {
impl Task for #struct_name {
type Input = #input_type;
type Output = #output_type;
async fn run(mut self, input: Self::Input) -> Self::Output {
let #param_destructure = input;
self.run_impl(#param_refs).await
}
}
impl #struct_name {
#run_impl_method
}
}
quote! { self.run_impl(#(#param_refs),*).await }
} else {
// Sync with self - wrap in async block
quote! {
impl Task for #struct_name {
type Input = #input_type;
type Output = #output_type;
async fn run(mut self, input: Self::Input) -> Self::Output {
let #param_destructure = input;
self.run_impl(#param_refs)
}
}
impl #struct_name {
#run_impl_method
}
}
quote! { self.run_impl(#(#param_refs),*) }
}
} else if is_async {
quote! { Self::run_impl(#(#param_refs),*).await }
} else {
// Stateless task
if is_async {
// Async stateless
quote! {
impl Task for #struct_name {
type Input = #input_type;
type Output = #output_type;
quote! { Self::run_impl(#(#param_refs),*) }
};
async fn run(self, input: Self::Input) -> Self::Output {
let #param_destructure = input;
Self::run_impl(#param_refs).await
}
}
// Generate the self parameter for execute method
let self_param = if has_self_receiver {
quote! { mut self }
} else {
quote! { self }
};
impl #struct_name {
#[inline]
#run_impl_method
}
// Generate the Input type based on parameter count
let input_type = match params.len() {
0 => quote! { () },
1 => {
let (_, ty) = &params[0];
quote! { #ty }
}
_ => {
let types: Vec<_> = params.iter().map(|(_, ty)| ty).collect();
quote! { (#(#types,)*) }
}
};
// Generate the complete execute method and TaskExecute trait implementation
let expanded = quote! {
impl #struct_name {
#[doc(hidden)]
pub async fn execute(
#self_param,
listeners: Vec<Box<dyn std::any::Any + Send>>,
announcer: Box<dyn std::any::Any + Send>,
) -> Result<std::sync::Arc<dyn std::any::Any + Send + Sync>, String> {
// Extract inputs from listeners
#(#listener_extractions)*
// Call the user's run method
let output: #output_type = #run_call;
// Wrap in Arc for sharing
let output_arc = std::sync::Arc::new(output);
// Announce the result (announce the Arc, not a clone of the data)
let announcer_typed = announcer
.downcast::<announcement::Announcer<std::sync::Arc<#output_type>>>()
.map_err(|_| "Type mismatch: announcer type does not match task output type")?;
let _ = announcer_typed.announce(std::sync::Arc::clone(&output_arc));
// Return Arc-wrapped output
Ok(output_arc as std::sync::Arc<dyn std::any::Any + Send + Sync>)
}
} else {
// Sync stateless
quote! {
impl Task for #struct_name {
type Input = #input_type;
type Output = #output_type;
async fn run(self, input: Self::Input) -> Self::Output {
let #param_destructure = input;
Self::run_impl(#param_refs)
}
}
#run_impl_method
}
impl #struct_name {
#[inline]
#run_impl_method
}
// Implement TaskExecute trait for type erasure
impl dagx::TaskExecute for #struct_name {
type Input = #input_type;
type Output = #output_type;
fn execute(
self,
listeners: Vec<Box<dyn std::any::Any + Send>>,
announcer: Box<dyn std::any::Any + Send>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::sync::Arc<dyn std::any::Any + Send + Sync>, String>> + Send>> {
Box::pin(async move {
self.execute(listeners, announcer).await
})
}
fn create_announcement_erased() -> (Box<dyn std::any::Any + Send>, Box<dyn std::any::Any + Send>) {
let (announcer, announcement) = announcement::Announcement::<std::sync::Arc<#output_type>>::new();
(
Box::new(announcer) as Box<dyn std::any::Any + Send>,
Box::new(announcement) as Box<dyn std::any::Any + Send>,
)
}
fn create_listener_erased(announcement: &dyn std::any::Any) -> Box<dyn std::any::Any + Send> {
let announcement = announcement
.downcast_ref::<announcement::Announcement<std::sync::Arc<#output_type>>>()
.expect("Type mismatch: announcement type does not match task output type");
Box::new(announcement.listener()) as Box<dyn std::any::Any + Send>
}
}
};
+1 -1
View File
@@ -30,7 +30,7 @@
//! Result: 5
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
// Step 1: Define a source task (no dependencies)
//
+1 -1
View File
@@ -55,7 +55,7 @@
//! Squared: 100
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
// Source task with a value field
struct Source {
+1 -1
View File
@@ -61,7 +61,7 @@
//! Active: true
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
struct FetchName;
+21 -11
View File
@@ -52,7 +52,7 @@
//! This proves dagx runs tasks on multiple CPU cores simultaneously.
//! ```
use dagx::{task, DagResult, DagRunner, Task};
use dagx::{task, DagResult, DagRunner};
use std::time::{Duration, Instant};
use tokio::time::sleep;
@@ -144,22 +144,32 @@ async fn main() -> DagResult<()> {
};
// ============================================================================
// SEQUENTIAL EXECUTION: Run the same 4 tasks one after another
// SEQUENTIAL EXECUTION: Run tasks one at a time in separate DAG executions
// ============================================================================
println!("Running SEQUENTIAL computation (same work, one at a time)...");
let (sequential_time, sequential_result) = {
let start = Instant::now();
// Run tasks sequentially (not using DAG, just raw execution)
let task1 = ComputeSum::new(1, 251, WORK_DELAY);
let task2 = ComputeSum::new(251, 501, WORK_DELAY);
let task3 = ComputeSum::new(501, 751, WORK_DELAY);
let task4 = ComputeSum::new(751, 1001, WORK_DELAY);
// Run each task separately to force sequential execution
let dag1 = DagRunner::new();
let sum1_handle = dag1.add_task(ComputeSum::new(1, 251, WORK_DELAY));
dag1.run(|fut| { tokio::spawn(fut); }).await?;
let r1 = dag1.get(sum1_handle)?;
let r1 = task1.run(()).await;
let r2 = task2.run(()).await;
let r3 = task3.run(()).await;
let r4 = task4.run(()).await;
let dag2 = DagRunner::new();
let sum2_handle = dag2.add_task(ComputeSum::new(251, 501, WORK_DELAY));
dag2.run(|fut| { tokio::spawn(fut); }).await?;
let r2 = dag2.get(sum2_handle)?;
let dag3 = DagRunner::new();
let sum3_handle = dag3.add_task(ComputeSum::new(501, 751, WORK_DELAY));
dag3.run(|fut| { tokio::spawn(fut); }).await?;
let r3 = dag3.get(sum3_handle)?;
let dag4 = DagRunner::new();
let sum4_handle = dag4.add_task(ComputeSum::new(751, 1001, WORK_DELAY));
dag4.run(|fut| { tokio::spawn(fut); }).await?;
let r4 = dag4.get(sum4_handle)?;
let result = r1 + r2 + r3 + r4;
let elapsed = start.elapsed();
+1 -1
View File
@@ -150,7 +150,7 @@
//! Result 7: Ok("Success")
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
+1 -1
View File
@@ -66,7 +66,7 @@
//! Combined: 6 items, sum=57, avg=9.50
//! ```
use dagx::{task, DagResult, DagRunner, Task};
use dagx::{task, DagResult, DagRunner};
// Extract: Load data from different sources
struct LoadData {
+1 -1
View File
@@ -55,7 +55,7 @@
//! Expected: Categories: [low, invalid, high]
//! ```
use dagx::{task, DagResult, DagRunner, Task};
use dagx::{task, DagResult, DagRunner};
// Task that validates and processes input
struct ValidateAndDouble {
+119
View File
@@ -0,0 +1,119 @@
//! # Custom Types: Using Your Own Structs
//!
//! This example demonstrates that **ANY custom type** works in dagx as long as it implements
//! `Clone + Send + Sync + 'static`. You don't need to implement any special traits!
//!
//! ## What You'll Learn
//! - How to use custom structs as task outputs
//! - How custom types flow through the DAG automatically
//! - How one task's output can be consumed by multiple downstream tasks
//! - No trait implementations required (just derive Clone!)
//!
//! ## Key Insight
//!
//! **dagx works with ANY type automatically!** The `#[task]` macro generates type-specific
//! extraction logic, so you don't need to implement any special traits. Just make sure your
//! type implements:
//! - `Clone` (so it can be extracted from Arc)
//! - `Send + Sync` (required for async tasks)
//! - `'static` (no lifetime constraints)
//!
//! That's it! No special traits, no annotations, no boilerplate.
//!
//! ## Running This Example
//! ```bash
//! cargo run --example custom_types
//! ```
//!
//! ## Expected Output
//! ```text
//! Creating custom types example...
//!
//! Creating user: Alice (age: 30)
//! Processing user Alice for greeting
//! Processing user Alice for validation
//! Greeting: Hello, Alice!
//! Validation: Alice is valid (age 30 >= 18)
//! ```
use dagx::{task, DagRunner};
// Custom type - just derive Clone!
#[derive(Clone, Debug)]
struct User {
name: String,
age: u32,
}
// Task that produces a custom type
struct CreateUser {
name: String,
age: u32,
}
#[task]
impl CreateUser {
async fn run(&self) -> User {
println!("Creating user: {} (age: {})", self.name, self.age);
User {
name: self.name.clone(),
age: self.age,
}
}
}
// Task that consumes the custom type and produces a String
struct GreetUser;
#[task]
impl GreetUser {
async fn run(user: &User) -> String {
println!("Processing user {} for greeting", user.name);
format!("Hello, {}!", user.name)
}
}
// Another task that also consumes the same custom type
// This demonstrates fan-out: one output consumed by multiple tasks
struct ValidateUser;
#[task]
impl ValidateUser {
async fn run(user: &User) -> String {
println!("Processing user {} for validation", user.name);
if user.age >= 18 {
format!("{} is valid (age {} >= 18)", user.name, user.age)
} else {
format!("{} is too young (age {} < 18)", user.name, user.age)
}
}
}
#[tokio::main]
async fn main() {
println!("Creating custom types example...\n");
let dag = DagRunner::new();
// Create a task that produces a custom User type
let user_task = dag.add_task(CreateUser {
name: "Alice".to_string(),
age: 30,
});
// Two different tasks consume the same custom User type
// dagx automatically handles Arc wrapping, so both tasks get &User
let greeting = dag.add_task(GreetUser).depends_on(&user_task);
let validation = dag.add_task(ValidateUser).depends_on(&user_task);
// Execute the DAG
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Print results
println!("Greeting: {}", dag.get(greeting).unwrap());
println!("Validation: {}", dag.get(validation).unwrap());
}
+1 -1
View File
@@ -75,7 +75,7 @@
//! ============================
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
// Data source tasks
struct LoadValue1;
+1 -1
View File
@@ -114,7 +114,7 @@
//! FulfillOrder: 30ms
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use std::time::Instant;
use tokio::time::{sleep, Duration};
+1 -1
View File
@@ -114,7 +114,7 @@
//! === Error Handling Example Complete ===
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
// Source task that provides a string input
struct StringSource(&'static str);
+1 -1
View File
@@ -109,7 +109,7 @@
//! - Memory overhead is minimal (~ 200 bytes per task)
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use std::time::Instant;
// Simple computation task
+1 -1
View File
@@ -47,7 +47,7 @@
//! This proves TRUE PARALLEL EXECUTION!
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use std::time::{Duration, Instant};
use tokio::time::sleep;
+1 -1
View File
@@ -152,7 +152,7 @@
//! Result: Err("Retry exhausted after 3 attempts: Connection timeout")
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use tokio::time::{sleep, Duration};
// Retry strategy configuration
+1 -1
View File
@@ -98,7 +98,7 @@
//! === Timeout Example Complete ===
//! ```
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use tokio::time::{sleep, timeout, Duration};
// Fast task that completes quickly
+1 -1
View File
@@ -33,7 +33,7 @@
//! - **ERROR**: Errors, panics, cycle detection
//!
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use tracing_subscriber::{fmt, EnvFilter};
// Simple source task
+4
View File
@@ -0,0 +1,4 @@
target
corpus
artifacts
Cargo.lock
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "dagx-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tokio = { version = "1", features = ["rt", "macros"] }
[dependencies.dagx]
path = ".."
[[bin]]
name = "fuzz_dag_execution"
path = "fuzz_targets/fuzz_dag_execution.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_dependencies"
path = "fuzz_targets/fuzz_dependencies.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_type_safety"
path = "fuzz_targets/fuzz_type_safety.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_concurrent"
path = "fuzz_targets/fuzz_concurrent.rs"
test = false
doc = false
bench = false
+191
View File
@@ -0,0 +1,191 @@
# Fuzz Testing for dagx
This directory contains fuzz testing infrastructure using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) and libFuzzer.
## Overview
We have 4 fuzz targets that test different aspects of the `dagx` library:
1. **fuzz_dag_execution** - Tests DAG execution with various task configurations
2. **fuzz_dependencies** - Tests dependency patterns (linear, diamond, fan-out, complex)
3. **fuzz_type_safety** - Tests type-safe output retrieval with various types
4. **fuzz_concurrent** - Tests concurrent task execution and thread safety
## Prerequisites
Fuzzing requires nightly Rust and cargo-fuzz:
```bash
rustup install nightly
cargo install cargo-fuzz
```
## Running Fuzz Tests
Run a single fuzz target:
```bash
cargo +nightly fuzz run fuzz_dag_execution
```
Run with a time limit (recommended for CI):
```bash
cargo +nightly fuzz run fuzz_dag_execution -- -max_total_time=60
```
Run with a specific number of iterations:
```bash
cargo +nightly fuzz run fuzz_dag_execution -- -runs=10000
```
Run all fuzz targets sequentially:
```bash
for target in fuzz_dag_execution fuzz_dependencies fuzz_type_safety fuzz_concurrent; do
echo "Running $target..."
cargo +nightly fuzz run $target -- -runs=10000
done
```
## Continuous Fuzzing
For deeper testing, run fuzz targets for extended periods:
```bash
# Run overnight (8 hours)
cargo +nightly fuzz run fuzz_concurrent -- -max_total_time=28800
# Run until crash (or Ctrl+C)
cargo +nightly fuzz run fuzz_dag_execution
```
## Understanding Output
Fuzzer output includes:
- `cov: X` - Code coverage (number of edges covered)
- `ft: X` - Feature coverage (number of unique code paths)
- `corp: X/Yb` - Corpus size (X inputs, Y bytes total)
- `exec/s: X` - Executions per second
If a crash is found:
- Crash input is saved to `artifacts/FUZZ_TARGET/crash-*`
- Reproduce with: `cargo +nightly fuzz run FUZZ_TARGET artifacts/FUZZ_TARGET/crash-*`
## Fuzz Target Details
### fuzz_dag_execution
Tests basic DAG execution:
- Simple linear task chains
- Fan-out patterns (one task → multiple dependents)
- Various data types (i32, Vec<u8>, custom structs)
- DAG execution correctness
- Output value verification
### fuzz_dependencies
Tests dependency management:
- Linear dependency chains
- Diamond patterns (source → two paths → merge)
- Multiple independent sources
- Complex dependency graphs
- Dependency tracking correctness
### fuzz_type_safety
Tests type system correctness:
- Various output types (i32, String, Vec, custom structs)
- Correct type retrieval with dag.get()
- Option<T> and Result<T, E> handling
- Custom struct preservation
- Type mismatch error handling
### fuzz_concurrent
Tests thread safety and concurrency:
- Multiple independent concurrent tasks
- Concurrent dependent tasks (fan-out)
- Arc<DagRunner> sharing across threads
- Task timing variations
- Result access from multiple threads
## Adding New Fuzz Targets
1. Create a new file in `fuzz_targets/` (e.g., `fuzz_new_feature.rs`)
2. Use the template:
```rust
#![no_main]
use libfuzzer_sys::fuzz_target;
use dagx::{task, DagRunner};
fuzz_target!(|data: &[u8]| {
// Your fuzz test here
});
```
3. Add to `fuzz/Cargo.toml`:
```toml
[[bin]]
name = "fuzz_new_feature"
path = "fuzz_targets/fuzz_new_feature.rs"
test = false
doc = false
bench = false
```
4. Build and run:
```bash
cargo +nightly fuzz build
cargo +nightly fuzz run fuzz_new_feature
```
## CI Integration
Fuzzing can run automatically in CI on every push/PR:
- Each target runs for 60-120 seconds
- Targets run 1,000-10,000 iterations minimum
- Any crashes fail the build
See your CI configuration for details.
## Corpus Management
The fuzzer builds a corpus of interesting inputs in `corpus/FUZZ_TARGET/`. These inputs:
- Are automatically discovered during fuzzing
- Can be committed to Git for regression testing
- Help future fuzzing sessions start with good coverage
To minimize the corpus:
```bash
cargo +nightly fuzz cmin fuzz_dag_execution
```
## Tips
1. **Start with short runs** - Use `-max_total_time=60` to catch obvious bugs quickly
2. **Run overnight** - For thorough testing, run for hours or days
3. **Check coverage** - Monitor `cov:` values to ensure good code coverage
4. **Save crashes** - Commit crash artifacts as regression tests
5. **Update regularly** - Re-run fuzzing when adding new features
6. **Limit data size** - Fuzz targets filter out oversized inputs to avoid timeouts
## Performance Notes
- DAG execution involves async runtime overhead
- Each fuzz iteration creates a new Tokio runtime
- Typical execution rate: 100-1000 runs/second
- Adjust `-runs` or `-max_total_time` based on your needs
## References
- [cargo-fuzz book](https://rust-fuzz.github.io/book/cargo-fuzz.html)
- [libFuzzer documentation](https://llvm.org/docs/LibFuzzer.html)
- [Fuzzing in Rust](https://rust-fuzz.github.io/book/)
+137
View File
@@ -0,0 +1,137 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use dagx::{task, DagRunner};
use std::sync::Arc;
fuzz_target!(|data: &[u8]| {
if data.is_empty() || data.len() > 50 {
return;
}
let rt = tokio::runtime::Runtime::new().unwrap();
// Test concurrent task execution
struct SlowTask { val: i32, delay_ms: u64 }
#[task]
impl SlowTask {
async fn run(&self) -> i32 {
tokio::time::sleep(tokio::time::Duration::from_millis(self.delay_ms)).await;
self.val
}
}
struct FastTask { val: i32 }
#[task]
impl FastTask {
async fn run(&self) -> i32 { self.val }
}
// Test concurrent independent tasks
{
let dag = DagRunner::new();
// Create multiple independent tasks
let num_tasks = (data[0] % 5 + 1) as usize;
let mut tasks = Vec::new();
for i in 0..num_tasks {
let val = data.get(i).copied().unwrap_or(0) as i32;
let delay = (val.abs() as u64 % 10) + 1;
let task = dag.add_task(SlowTask { val, delay_ms: delay });
tasks.push((task, val));
}
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
// Verify all tasks completed with correct values
for (handle, expected) in tasks {
if let Ok(result) = dag.get(handle) {
assert_eq!(result, expected);
}
}
}
}
// Test concurrent dependent tasks
{
let dag = DagRunner::new();
let source = dag.add_task(FastTask { val: data[0] as i32 });
// Create multiple tasks that depend on source
struct AddOne;
#[task]
impl AddOne {
async fn run(input: &i32) -> i32 {
input.wrapping_add(1)
}
}
struct AddTwo;
#[task]
impl AddTwo {
async fn run(input: &i32) -> i32 {
input.wrapping_add(2)
}
}
struct AddThree;
#[task]
impl AddThree {
async fn run(input: &i32) -> i32 {
input.wrapping_add(3)
}
}
let t1 = dag.add_task(AddOne).depends_on(&source);
let t2 = dag.add_task(AddTwo).depends_on(&source);
let t3 = dag.add_task(AddThree).depends_on(&source);
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
let base = data[0] as i32;
if let Ok(r1) = dag.get(t1) {
assert_eq!(r1, base.wrapping_add(1));
}
if let Ok(r2) = dag.get(t2) {
assert_eq!(r2, base.wrapping_add(2));
}
if let Ok(r3) = dag.get(t3) {
assert_eq!(r3, base.wrapping_add(3));
}
}
}
// Test Arc sharing across concurrent tasks
{
let dag = Arc::new(DagRunner::new());
struct SharedTask { val: i32 }
#[task]
impl SharedTask {
async fn run(&self) -> i32 { self.val }
}
let task = dag.add_task(SharedTask { val: data[0] as i32 });
let task_copy = task;
let dag1 = Arc::clone(&dag);
let dag2 = Arc::clone(&dag);
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
// Multiple threads can access result
if let Ok(r1) = dag1.get(task_copy) {
if let Ok(r2) = dag2.get(task_copy) {
assert_eq!(r1, r2);
assert_eq!(r1, data[0] as i32);
}
}
}
}
});
+120
View File
@@ -0,0 +1,120 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use dagx::{task, DagRunner};
fuzz_target!(|data: &[u8]| {
// Only fuzz with reasonable data sizes to avoid timeouts
if data.is_empty() || data.len() > 1000 {
return;
}
// Convert bytes to values for tasks
let value1 = data.get(0).copied().unwrap_or(0) as i32;
let value2 = data.get(1).copied().unwrap_or(0) as i32;
let value3 = data.get(2).copied().unwrap_or(0) as i32;
// Test simple linear DAG execution
{
struct Source1 { val: i32 }
#[task]
impl Source1 {
async fn run(&self) -> i32 { self.val }
}
struct Source2 { val: i32 }
#[task]
impl Source2 {
async fn run(&self) -> i32 { self.val }
}
struct Compute;
#[task]
impl Compute {
async fn run(a: &i32, b: &i32) -> i32 {
a.wrapping_add(*b)
}
}
let dag = DagRunner::new();
let s1 = dag.add_task(Source1 { val: value1 });
let s2 = dag.add_task(Source2 { val: value2 });
let compute = dag.add_task(Compute).depends_on((&s1, &s2));
let rt = tokio::runtime::Runtime::new().unwrap();
if let Ok(()) = rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}) {
// Verify output
if let Ok(result) = dag.get(compute) {
assert_eq!(result, value1.wrapping_add(value2));
}
}
}
// Test fan-out pattern
{
struct Source { val: i32 }
#[task]
impl Source {
async fn run(&self) -> i32 { self.val }
}
struct Double;
#[task]
impl Double {
async fn run(input: &i32) -> i32 {
input.wrapping_mul(2)
}
}
struct Triple;
#[task]
impl Triple {
async fn run(input: &i32) -> i32 {
input.wrapping_mul(3)
}
}
let dag = DagRunner::new();
let source = dag.add_task(Source { val: value3 });
let double = dag.add_task(Double).depends_on(&source);
let triple = dag.add_task(Triple).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
if let Ok(()) = rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}) {
// Verify outputs
if let (Ok(d), Ok(t)) = (dag.get(double), dag.get(triple)) {
assert_eq!(d, value3.wrapping_mul(2));
assert_eq!(t, value3.wrapping_mul(3));
}
}
}
// Test error-free execution with various data
if data.len() >= 4 {
struct DataTask { data: Vec<u8> }
#[task]
impl DataTask {
async fn run(&self) -> Vec<u8> {
self.data.clone()
}
}
let dag = DagRunner::new();
let task = dag.add_task(DataTask {
data: data[..4.min(data.len())].to_vec()
});
let rt = tokio::runtime::Runtime::new().unwrap();
if let Ok(()) = rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}) {
if let Ok(result) = dag.get(task) {
assert_eq!(result.len(), 4.min(data.len()));
}
}
}
});
+115
View File
@@ -0,0 +1,115 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use dagx::{task, DagRunner};
fuzz_target!(|data: &[u8]| {
// Only test with reasonable sizes
if data.is_empty() || data.len() > 100 {
return;
}
// Test various dependency patterns based on input data
let pattern = data[0] % 4;
struct SimpleTask { val: i32 }
#[task]
impl SimpleTask {
async fn run(&self) -> i32 { self.val }
}
struct DependentTask;
#[task]
impl DependentTask {
async fn run(input: &i32) -> i32 {
input.wrapping_add(1)
}
}
let rt = tokio::runtime::Runtime::new().unwrap();
match pattern {
0 => {
// Linear dependency chain
let dag = DagRunner::new();
let t1 = dag.add_task(SimpleTask { val: data[0] as i32 });
let t2 = dag.add_task(DependentTask).depends_on(&t1);
let t3 = dag.add_task(DependentTask).depends_on(&t2);
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
if let Ok(result) = dag.get(t3) {
assert_eq!(result, (data[0] as i32).wrapping_add(2));
}
}
}
1 => {
// Diamond pattern
let dag = DagRunner::new();
let source = dag.add_task(SimpleTask { val: data[0] as i32 });
let left = dag.add_task(DependentTask).depends_on(&source);
let right = dag.add_task(DependentTask).depends_on(&source);
struct MergeTask;
#[task]
impl MergeTask {
async fn run(a: &i32, b: &i32) -> i32 {
a.wrapping_add(*b)
}
}
let merge = dag.add_task(MergeTask).depends_on((&left, &right));
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
if let Ok(result) = dag.get(merge) {
let expected = (data[0] as i32).wrapping_add(1);
assert_eq!(result, expected.wrapping_add(expected));
}
}
}
2 => {
// Multiple independent sources
let dag = DagRunner::new();
let t1 = dag.add_task(SimpleTask { val: data[0] as i32 });
let t2 = dag.add_task(SimpleTask {
val: data.get(1).copied().unwrap_or(0) as i32
});
let t3 = dag.add_task(SimpleTask {
val: data.get(2).copied().unwrap_or(0) as i32
});
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
if let (Ok(r1), Ok(r2), Ok(r3)) = (dag.get(t1), dag.get(t2), dag.get(t3)) {
assert_eq!(r1, data[0] as i32);
assert_eq!(r2, data.get(1).copied().unwrap_or(0) as i32);
assert_eq!(r3, data.get(2).copied().unwrap_or(0) as i32);
}
}
}
_ => {
// Complex dependency graph
let dag = DagRunner::new();
let s1 = dag.add_task(SimpleTask { val: data[0] as i32 });
let s2 = dag.add_task(SimpleTask {
val: data.get(1).copied().unwrap_or(0) as i32
});
let d1 = dag.add_task(DependentTask).depends_on(&s1);
let d2 = dag.add_task(DependentTask).depends_on(&s2);
let d3 = dag.add_task(DependentTask).depends_on(&d1);
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
let _ = dag.get(d1);
let _ = dag.get(d2);
let _ = dag.get(d3);
}
}
}
});
+138
View File
@@ -0,0 +1,138 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use dagx::{task, DagRunner};
fuzz_target!(|data: &[u8]| {
if data.is_empty() || data.len() > 100 {
return;
}
let rt = tokio::runtime::Runtime::new().unwrap();
// Test type safety with various output types
struct IntTask { val: i32 }
#[task]
impl IntTask {
async fn run(&self) -> i32 { self.val }
}
struct StringTask { val: String }
#[task]
impl StringTask {
async fn run(&self) -> String { self.val.clone() }
}
struct VecTask { data: Vec<u8> }
#[task]
impl VecTask {
async fn run(&self) -> Vec<u8> { self.data.clone() }
}
// Test correct type retrieval
{
let dag = DagRunner::new();
let int_task = dag.add_task(IntTask { val: data[0] as i32 });
if let Ok(string_data) = std::str::from_utf8(data) {
let string_task = dag.add_task(StringTask {
val: string_data.to_string()
});
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
// Correct type should work
let _ = dag.get(int_task);
let _ = dag.get(string_task);
}
}
}
// Test Vec type
{
let dag = DagRunner::new();
let vec_task = dag.add_task(VecTask {
data: data.to_vec()
});
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
if let Ok(result) = dag.get(vec_task) {
assert_eq!(result, data);
}
}
}
// Test custom struct types
{
#[derive(Clone, Debug, PartialEq)]
struct CustomData {
value: i32,
flag: bool,
}
struct CustomTask { data: CustomData }
#[task]
impl CustomTask {
async fn run(&self) -> CustomData {
self.data.clone()
}
}
let dag = DagRunner::new();
let custom = CustomData {
value: data[0] as i32,
flag: data.get(1).map(|&b| b % 2 == 0).unwrap_or(false),
};
let task = dag.add_task(CustomTask { data: custom.clone() });
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
if let Ok(result) = dag.get(task) {
assert_eq!(result, custom);
}
}
}
// Test Option and Result types
{
struct OptionTask { val: Option<i32> }
#[task]
impl OptionTask {
async fn run(&self) -> Option<i32> { self.val }
}
struct ResultTask { val: Result<i32, String> }
#[task]
impl ResultTask {
async fn run(&self) -> Result<i32, String> { self.val.clone() }
}
let dag = DagRunner::new();
let opt_task = dag.add_task(OptionTask {
val: if data[0] % 2 == 0 {
Some(data[0] as i32)
} else {
None
}
});
let result_task = dag.add_task(ResultTask {
val: if data.get(1).map(|&b| b % 2 == 0).unwrap_or(false) {
Ok(data.get(1).copied().unwrap_or(0) as i32)
} else {
Err("error".to_string())
}
});
if rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).is_ok() {
let _ = dag.get(opt_task);
let _ = dag.get(result_task);
}
}
});
+257
View File
@@ -0,0 +1,257 @@
# justfile for dagx crate
# High-level development and CI workflows
# =============================================================================
# Configuration
# =============================================================================
# Treat all warnings as errors
rustflags := "-D warnings"
# Code coverage target percentage (fail if below this threshold)
coverage_target := "80"
# Timeout for coverage analysis in seconds
coverage_timeout := "600"
# Benchmark timeout in seconds
bench_timeout := "300"
# =============================================================================
# Default recipe - show available recipes
default:
@just --list
# =============================================================================
# Primary Development Workflows
# =============================================================================
# Quick development loop: format + lint + fast tests
dev:
#!/usr/bin/env bash
set -e
echo "🔄 Running development checks..."
cargo fmt
RUSTFLAGS="{{rustflags}}" cargo clippy --all-targets --all-features &
RUSTFLAGS="{{rustflags}}" cargo nextest run --lib --all-features &
wait
echo "✅ Development checks passed"
# Pre-commit checks: format-check + lint + typecheck
check:
@echo "Running format check..."
cargo fmt -- --check
@echo ""
@echo "Running clippy..."
RUSTFLAGS="{{rustflags}}" cargo clippy --all-targets --all-features
RUSTFLAGS="{{rustflags}}" cargo clippy --all-targets --no-default-features
@echo ""
@echo "Verifying compilation..."
RUSTFLAGS="{{rustflags}}" cargo test --lib --all-features --no-run
RUSTFLAGS="{{rustflags}}" cargo test --lib --no-default-features --no-run
@echo ""
@echo "✅ All checks passed"
# Run all tests
test:
@echo "Running all tests..."
RUSTFLAGS="{{rustflags}}" cargo nextest run --lib --all-features
@echo "✅ All tests passed"
# Before pushing: check + test + coverage
pre-push: check test coverage
@echo ""
@echo "✅ Pre-push checks passed - safe to push!"
# Comprehensive pre-release validation
pre-release: check test coverage audit examples bench-check
#!/usr/bin/env bash
set -e
# Verify documentation version matches crate version
echo ""
echo "📋 Verifying documentation version..."
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
MAJOR_MINOR=$(echo $VERSION | cut -d. -f1-2)
if ! grep -q "dagx = \"$MAJOR_MINOR\"" README.md; then
echo "❌ ERROR: README.md should reference version $MAJOR_MINOR (crate is $VERSION)"
echo " Update installation examples in README.md"
exit 1
fi
echo "✅ Documentation version correct: $MAJOR_MINOR"
# Package verification
echo ""
echo "📦 Verifying package..."
cargo publish --dry-run
cargo package --list > /tmp/dagx-files.txt
echo "✅ Package verified"
echo ""
echo "✅ All pre-release checks passed!"
echo " - Code formatted and linted"
echo " - All tests passing"
echo " - Code coverage ≥ {{coverage_target}}%"
echo " - No security vulnerabilities"
echo " - Dependencies verified"
echo " - All examples run successfully"
echo " - All benchmarks compile"
echo " - Documentation version verified"
echo " - Package ready for publication"
echo ""
echo "📦 Ready to publish! Run: cargo publish"
# =============================================================================
# Quality & Security
# =============================================================================
# Run code coverage analysis
coverage:
@echo "Running code coverage analysis (target: {{coverage_target}}%)..."
cargo llvm-cov nextest --lib --html --output-dir coverage --fail-under-lines {{coverage_target}}
@echo "Coverage report: coverage/html/index.html"
# Mutation testing with cargo-mutants
mutants:
@echo "Running mutation testing..."
@echo "⚠️ This will use significant CPU and memory resources!"
cargo mutants \
--no-shuffle \
--test-tool nextest \
--jobs 3 \
--all-features \
-vV
# Security audit + dependency checks
audit:
@echo "🔒 Running security checks..."
cargo audit
@echo "✅ Security checks passed"
# Run Miri for undefined behavior detection
miri:
@echo "Running Miri tests for UB detection..."
cargo +nightly miri test --lib
# =============================================================================
# Benchmarks
# =============================================================================
# Verify benchmarks compile
bench-check:
@echo "Verifying benchmarks compile..."
cargo bench --no-run
@echo "✅ All benchmarks compile"
# Run all benchmarks
bench:
cargo bench
# Run benchmarks and save as baseline
bench-baseline:
cargo bench -- --save-baseline baseline
# Compare benchmarks against baseline
bench-compare:
cargo bench -- --baseline baseline
# Run specific benchmark suite
bench-suite suite:
cargo bench --bench {{suite}}
# =============================================================================
# Fuzzing
# =============================================================================
# Run a single fuzz target for 60 seconds
fuzz target:
@echo "Fuzzing {{target}} for 60 seconds..."
cargo +nightly fuzz run {{target}} -- -max_total_time=60
# Run all fuzz targets sequentially for quick smoke test
fuzz-all:
#!/usr/bin/env bash
set -e
echo "🔬 Running all fuzz targets..."
for target in fuzz_dag_execution fuzz_dependencies fuzz_type_safety fuzz_concurrent; do
echo ""
echo "Running $target..."
cargo +nightly fuzz run $target -- -max_total_time=30 -runs=10000
done
echo ""
echo "✅ All fuzz targets completed"
# Run extended fuzzing session (for deeper testing)
fuzz-extended target:
@echo "Running extended fuzzing session for {{target}}..."
@echo "⚠️ This will run for 1 hour. Press Ctrl+C to stop early."
cargo +nightly fuzz run {{target}} -- -max_total_time=3600
# Build all fuzz targets
fuzz-build:
@echo "Building fuzz targets..."
cargo +nightly fuzz build
@echo "✅ All fuzz targets built"
# Minimize corpus for a fuzz target
fuzz-cmin target:
@echo "Minimizing corpus for {{target}}..."
cargo +nightly fuzz cmin {{target}}
# Run property-based tests
proptest:
@echo "Running property-based tests..."
cargo nextest run property_tests --all-features
@echo "✅ Property tests passed"
# =============================================================================
# Documentation & Utilities
# =============================================================================
# Build documentation and open in browser
doc:
cargo doc --no-deps --all-features --open
# Format code
fmt:
cargo fmt
# Show project information
info:
@echo "dagx crate - Type-safe DAG execution framework for async Rust"
@echo ""
@echo "Rust: $(rustc --version)"
@echo "Cargo: $(cargo --version)"
@echo ""
@echo "Dependencies:"
@cargo tree --depth 1
# Run all examples
examples:
@echo "Running all examples..."
cargo run --example 01_basic
cargo run --example 02_fan_out
cargo run --example 03_fan_in
cargo run --example 04_parallel_computation
cargo run --example circuit_breaker
cargo run --example complex_dag
cargo run --example conditional_workflow
cargo run --example custom_types
cargo run --example data_pipeline
cargo run --example debug_tracing
cargo run --example error_handling
cargo run --example large_dag
cargo run --example parallelism_proof
cargo run --example retry_strategies
cargo run --example timeout
cargo run --example tracing_example
@echo "✅ All examples completed successfully"
# Clean build artifacts
clean:
cargo clean
rm -rf mutants.out/ mutants.out.old/ mutation_history.json
rm -rf target/criterion
rm -rf coverage/
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
0.038627406s DEBUG cargo_mutants::copy_tree: /home/swaits/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cargo-mutants-25.3.1/src/copy_tree.rs:69: walk_builder=WalkBuilder { paths: ["/home/swaits/Code/dagx"], ig_builder: IgnoreBuilder { dir: "", overrides: Override(Gitignore { set: GlobSet { len: 0, strats: [] }, root: "", globs: [], num_ignores: 0, num_whitelists: 0, matches: None }), types: Types { defs: [], selections: [], has_selected: false, glob_to_selection: [], set: GlobSet { len: 0, strats: [] }, matches: Pool(Pool { stacks: [CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. }), CacheLine(Mutex { data: [], poisoned: false, .. })], owner: 0, owner_val: UnsafeCell { .. } }) }, explicit_ignores: [], custom_ignore_filenames: [], opts: IgnoreOptions { hidden: false, ignore: false, parents: true, git_global: false, git_ignore: false, git_exclude: false, ignore_case_insensitive: false, require_git: true } }, max_depth: None, max_filesize: None, follow_links: false, threads: 0, skip: None }
0.041495573s DEBUG cargo_mutants::copy_tree: /home/swaits/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cargo-mutants-25.3.1/src/copy_tree.rs:115: Copied source tree total_bytes=967004 total_files=153 temp_dir="/tmp/cargo-mutants-dagx-o2Mc3y.tmp"
0.041758047s DEBUG cargo_mutants::lab: /home/swaits/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cargo-mutants-25.3.1/src/lab.rs:55: starting jobserver n_tasks=16
0.041839390s DEBUG cargo_mutants::lab: /home/swaits/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cargo-mutants-25.3.1/src/lab.rs:267: test_packages=Explicit([Package { name: "dagx", version: "0.3.0", relative_dir: "", top_sources: ["src/lib.rs"] }])
0.041870839s DEBUG run{phase=Build}: cargo_mutants::process: /home/swaits/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cargo-mutants-25.3.1/src/process.rs:81: start process quoted_argv=/home/swaits/.local/share/rustup/toolchains/1.90.0-x86_64-unknown-linux-gnu/bin/cargo nextest run --no-run --verbose --package=dagx@0.3.0 --all-features
+6
View File
@@ -0,0 +1,6 @@
{
"cargo_mutants_version": "25.3.1",
"start_time": "2025-10-14T16:44:19.225601093Z",
"hostname": "swaits-fw13",
"username": "swaits"
}
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+3 -3
View File
@@ -4,8 +4,8 @@
//! The type parameter tracks whether dependencies have been specified yet.
use crate::deps::DepsTuple;
use crate::node::TaskExecute;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::{NodeId, TaskHandle};
#[cfg(feature = "tracing")]
@@ -69,13 +69,13 @@ impl IsUnitType for () {}
/// assert_eq!(dag.get(&b).unwrap(), 20);
/// # };
/// ```
pub struct TaskBuilder<'a, Tk: Task, Deps> {
pub struct TaskBuilder<'a, Tk: TaskExecute, 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> {
impl<'a, Tk: TaskExecute, Deps> TaskBuilder<'a, Tk, Deps> {
/// Provide all dependencies exactly once as a tuple.
///
/// The dependencies must match the task's `Input` type exactly:
+3 -1
View File
@@ -1,8 +1,10 @@
//! Unit tests for builder module
// Allow tests to reference dagx:: paths (for proc macro generated code)
extern crate self as dagx;
use crate::builder::IsUnitType;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::TaskHandle;
// Initialize tracing subscriber for tests (idempotent)
+4 -4
View File
@@ -8,7 +8,7 @@
//! use the provided implementations.
use crate::builder::TaskBuilder;
use crate::task::Task;
use crate::node::TaskExecute;
use crate::types::{NodeId, TaskHandle};
pub(crate) trait DepsTuple<Input> {
@@ -48,13 +48,13 @@ impl<T> DepsTuple<T> for (TaskHandle<T>,) {
}
// Support for &TaskBuilder
impl<'a, Tk: Task, Deps> DepsTuple<Tk::Output> for &TaskBuilder<'a, Tk, Deps> {
impl<'a, Tk: TaskExecute, 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>,) {
impl<'a, Tk: TaskExecute, Deps> DepsTuple<Tk::Output> for (&TaskBuilder<'a, Tk, Deps>,) {
fn to_node_ids(&self) -> Vec<NodeId> {
vec![self.0.id]
}
@@ -89,7 +89,7 @@ macro_rules! impl_deps_tuple {
}
// For &TaskBuilder tuples
impl<'a, $($Tk: Task, $D),+> DepsTuple<($($Tk::Output,)+)> for ($(&TaskBuilder<'a, $Tk, $D>,)+) {
impl<'a, $($Tk: TaskExecute, $D),+> DepsTuple<($($Tk::Output,)+)> for ($(&TaskBuilder<'a, $Tk, $D>,)+) {
#[allow(non_snake_case)]
fn to_node_ids(&self) -> Vec<NodeId> {
let ($($T,)+) = self;
+4 -2
View File
@@ -1,8 +1,10 @@
//! Unit tests for deps module
// Allow tests to reference dagx:: paths (for proc macro generated code)
extern crate self as dagx;
use crate::deps::DepsTuple;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::{NodeId, TaskHandle};
// Test task for dependency testing
@@ -18,11 +20,11 @@ impl TestTask {
}
}
#[allow(dead_code)]
struct TestTaskWithInput;
#[crate::task]
impl TestTaskWithInput {
#[allow(dead_code)]
#[cfg(not(tarpaulin_include))]
async fn run(input: &i32) -> i32 {
input * 2
-291
View File
@@ -1,291 +0,0 @@
//! 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;
-410
View File
@@ -1,410 +0,0 @@
//! 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 {
#[cfg(not(tarpaulin_include))]
async fn run(&self) -> i32 {
self.value
}
}
#[allow(dead_code)]
struct TestTaskWithInput;
#[crate::task]
#[allow(dead_code)]
impl TestTaskWithInput {
#[cfg(not(tarpaulin_include))]
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);
}
// Error path tests for ExtractInput implementations
use std::collections::HashMap;
#[tokio::test]
async fn test_hashmap_extract_wrong_dependency_count() {
let deps = vec![
Box::new(oneshot::channel::<Arc<HashMap<String, i32>>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Arc<HashMap<String, i32>>>().1) as Box<dyn Any + Send>,
];
let result = HashMap::<String, i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 2"));
}
#[tokio::test]
async fn test_hashmap_extract_type_mismatch() {
let (tx, rx) = oneshot::channel::<Arc<String>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new("wrong type".to_string()));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = HashMap::<String, i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_hashmap_extract_channel_closed() {
let (tx, rx) = oneshot::channel::<Arc<HashMap<String, i32>>>();
drop(tx);
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = HashMap::<String, i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Channel closed"));
}
#[tokio::test]
async fn test_result_extract_downcast_failure() {
let (tx, rx) = oneshot::channel::<Arc<String>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new("wrong".to_string()));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Result::<i32, String>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_option_extract_downcast_failure() {
let (tx, rx) = oneshot::channel::<Arc<String>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new("wrong".to_string()));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Option::<i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_vec_extract_downcast_failure() {
let (tx, rx) = oneshot::channel::<Arc<String>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new("wrong".to_string()));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Vec::<i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_arc_extract_wrong_dependency_count() {
let deps = vec![];
let result = Arc::<i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Expected 1 dependency, got 0"));
}
#[tokio::test]
async fn test_arc_extract_downcast_failure() {
let (tx, rx) = oneshot::channel::<Arc<String>>();
tokio::spawn(async move {
let _ = tx.send(Arc::new("wrong".to_string()));
});
let deps = vec![Box::new(rx) as Box<dyn Any + Send>];
let result = Arc::<i32>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Type mismatch"));
}
#[tokio::test]
async fn test_tuple_wrong_dependency_count() {
let deps = vec![
Box::new(oneshot::channel::<Arc<i32>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Arc<i32>>().1) as Box<dyn Any + Send>,
Box::new(oneshot::channel::<Arc<i32>>().1) as Box<dyn Any + Send>,
];
let result = <(i32, i32)>::extract_from_channels(deps).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Expected 2 dependencies, got 3"));
}
+2 -1
View File
@@ -113,6 +113,7 @@
//! use dagx::{task, Task};
//!
//! // Define a struct to hold your multiple values
//! #[derive(Clone)]
//! struct UserData {
//! name: String,
//! age: i32,
@@ -722,7 +723,6 @@ mod builder;
pub mod cycle_prevention;
mod deps;
mod error;
mod extract;
mod node;
mod runner;
mod task;
@@ -731,6 +731,7 @@ mod types;
// Public re-exports
pub use builder::TaskBuilder;
pub use error::{DagError, DagResult};
pub use node::TaskExecute; // For macro-generated trait implementations
pub use runner::DagRunner;
pub use task::Task;
pub use types::{Pending, TaskHandle};
+83 -75
View File
@@ -11,138 +11,146 @@ 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 listeners (for receiving inputs from dependencies)
type ErasedListeners = Vec<Box<dyn Any + Send>>;
/// 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>>;
/// Type-erased async execution result for TaskExecute trait (returns String errors)
type TaskExecuteFuture =
Pin<Box<dyn Future<Output = Result<Arc<dyn Any + Send + Sync>, String>> + 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
/// This is the ONLY place where type erasure occurs - announcements 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.
/// Create a type-erased announcement for this node's output.
///
/// 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;
/// Returns a tuple of (Announcer, Announcement) both type-erased.
/// The Announcer will be used by this node to broadcast its output.
/// The Announcement can be used to create listeners for dependents.
fn create_announcement(&self) -> (Box<dyn Any + Send>, Box<dyn Any + Send>);
/// Execute the task with type-erased input receivers and output senders.
/// Create a type-erased listener from an announcement.
///
/// This allows dependents to listen to this node's output.
fn create_listener(&self, announcement: &dyn Any) -> Box<dyn Any + Send>;
/// Execute the task with type-erased input listeners and an output announcer.
///
/// The TypedNode implementation knows the concrete types and can safely downcast.
/// Consumes the node and returns the output value.
fn execute_with_channels(
fn execute_with_listeners(
self: Box<Self>,
receivers: ErasedChannels,
senders: ErasedChannels,
listeners: ErasedListeners,
announcer: Box<dyn Any + Send>,
) -> 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.
/// Stores a task that has an `execute()` method (generated by the #[task] macro).
/// 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
/// # Announcement Broadcasting
///
/// 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> {
/// Outputs are communicated via announcement channels for inter-task communication.
/// Each task has ONE announcer that broadcasts to multiple listeners.
/// The output is also returned from execute_with_listeners for central storage.
pub(crate) struct TypedNode<T> {
pub(crate) id: NodeId,
pub(crate) task: T,
pub(crate) _output_type: std::marker::PhantomData<fn() -> ()>,
}
impl<T: Task + 'static> TypedNode<T> {
impl<T> TypedNode<T> {
pub(crate) fn new(id: NodeId, task: T) -> Self {
Self { id, task }
Self {
id,
task,
_output_type: std::marker::PhantomData,
}
}
}
impl<T: Task + 'static> ExecutableNode for TypedNode<T>
// Implementation for tasks created with the #[task] macro
// These tasks have an execute() method generated by the macro
impl<T: 'static + Send> ExecutableNode for TypedNode<T>
where
T::Input: 'static + Clone + ExtractInput,
T::Output: 'static + Clone,
T: TaskExecute,
{
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 create_announcement(&self) -> (Box<dyn Any + Send>, Box<dyn Any + Send>) {
T::create_announcement_erased()
}
fn execute_with_channels(
fn create_listener(&self, announcement: &dyn Any) -> Box<dyn Any + Send> {
T::create_listener_erased(announcement)
}
fn execute_with_listeners(
self: Box<Self>,
receivers: ErasedChannels,
senders: ErasedChannels,
listeners: ErasedListeners,
announcer: Box<dyn Any + Send>,
) -> 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 });
}
};
// Call the task's execute() method (generated by the #[task] macro)
// This handles listener extraction, calling run(), and announcing
let result = self.task.execute(listeners, announcer).await;
// 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));
match result {
Ok(output_arc) => Ok(output_arc),
Err(err_msg) => {
// Execution failed - convert error message to DagError
eprintln!("Task execution failed: {}", err_msg);
Err(DagError::InvalidDependency { task_id: self.id.0 })
}
}
// Return Arc-wrapped output (for storage by runner)
Ok(arc_output as Arc<dyn Any + Send + Sync>)
})
}
}
/// Trait for tasks that have an execute() method (generated by #[task] macro).
///
/// This trait is automatically implemented by the macro and should not be
/// implemented manually.
pub trait TaskExecute: Send {
/// Input type as a tuple of parameter types (for dependency checking)
/// - `()` for no parameters
/// - `T` for single parameter
/// - `(A, B, C, ...)` for multiple parameters
type Input: Send;
/// Output type of the task
type Output: Send + Sync + Clone;
fn execute(
self,
listeners: Vec<Box<dyn Any + Send>>,
announcer: Box<dyn Any + Send>,
) -> TaskExecuteFuture;
fn create_announcement_erased() -> (Box<dyn Any + Send>, Box<dyn Any + Send>);
fn create_listener_erased(announcement: &dyn Any) -> Box<dyn Any + Send>;
}
#[cfg(test)]
mod tests;
+93 -64
View File
@@ -1,7 +1,9 @@
//! Unit tests for node module
// Allow tests to reference dagx:: paths (for proc macro generated code)
extern crate self as dagx;
use crate::node::{ExecutableNode, TypedNode};
use crate::task::Task;
use crate::types::NodeId;
// Simple test task
@@ -17,25 +19,33 @@ impl TestTask {
}
#[test]
fn test_create_output_channels() {
fn test_create_announcement() {
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create channels for 3 dependents
let (senders, receivers) = node.create_output_channels(3);
// Create ONE announcement that can broadcast to multiple listeners
let (announcer, announcement) = node.create_announcement();
assert_eq!(senders.len(), 3);
assert_eq!(receivers.len(), 3);
// Verify both are created (type-erased)
assert!(std::any::Any::type_id(&*announcer) != std::any::TypeId::of::<()>());
assert!(std::any::Any::type_id(&*announcement) != std::any::TypeId::of::<()>());
}
#[test]
fn test_create_output_channels_zero_dependents() {
fn test_create_multiple_listeners() {
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create channels for 0 dependents (sink node)
let (senders, receivers) = node.create_output_channels(0);
// Create announcement
let (_announcer, announcement) = node.create_announcement();
assert_eq!(senders.len(), 0);
assert_eq!(receivers.len(), 0);
// Create multiple listeners from the same announcement (broadcast pattern)
let listener1 = node.create_listener(announcement.as_ref());
let listener2 = node.create_listener(announcement.as_ref());
let listener3 = node.create_listener(announcement.as_ref());
// All listeners are created from the same announcement
assert!(std::any::Any::type_id(&*listener1) != std::any::TypeId::of::<()>());
assert!(std::any::Any::type_id(&*listener2) != std::any::TypeId::of::<()>());
assert!(std::any::Any::type_id(&*listener3) != std::any::TypeId::of::<()>());
}
struct TaskWithDependency;
@@ -48,15 +58,19 @@ impl TaskWithDependency {
}
#[tokio::test]
async fn test_execute_with_channels_missing_input() {
async fn test_execute_with_listeners_missing_input() {
// Test when task expects input but doesn't receive it
let node = Box::new(TypedNode::new(NodeId(0), TaskWithDependency));
let node = TypedNode::new(NodeId(0), TaskWithDependency);
// TaskWithDependency expects an i32 input, but we provide no receivers
let receivers = vec![];
let senders = vec![];
// Create a real announcer even though we won't use it
let (announcer, _announcement) = node.create_announcement();
let result = node.execute_with_channels(receivers, senders).await;
let node = Box::new(node);
// TaskWithDependency expects an i32 input, but we provide no listeners
let listeners = vec![];
let result = node.execute_with_listeners(listeners, announcer).await;
assert!(result.is_err());
if let Err(crate::error::DagError::InvalidDependency { task_id }) = result {
@@ -67,49 +81,62 @@ async fn test_execute_with_channels_missing_input() {
}
#[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 }));
async fn test_execute_with_listeners_success() {
// Test successful execution with announcements and listeners
let dependency_node = 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>>();
// Create announcement from dependency
let (dep_announcer, dep_announcement) = dependency_node.create_announcement();
// Execute dependency node with sender
let dep_senders = vec![Box::new(tx) as Box<dyn std::any::Any + Send>];
let dep_receivers = vec![];
// Create listener for dependent from dependency's announcement
let listener = dependency_node.create_listener(dep_announcement.as_ref());
// Execute dependency node with announcer
let dependency_node = Box::new(dependency_node);
let dep_listeners = vec![];
// Execute in background
let dep_handle = tokio::spawn(async move {
dependency_node
.execute_with_channels(dep_receivers, dep_senders)
.execute_with_listeners(dep_listeners, dep_announcer)
.await
});
// Execute dependent node with receiver
let receivers = vec![Box::new(rx) as Box<dyn std::any::Any + Send>];
let senders = vec![];
// Execute dependent node with listener
// Create a real announcer for the dependent (even though no one listens to it)
let dep_node_for_announcer = TypedNode::new(NodeId(1), TaskWithDependency);
let (dependent_announcer, _dep_announcement) = dep_node_for_announcer.create_announcement();
let listeners = vec![listener];
let result = dependent_node
.execute_with_channels(receivers, senders)
.execute_with_listeners(listeners, dependent_announcer)
.await;
// Wait for dependency to complete
let _ = dep_handle.await;
assert!(result.is_ok());
let output = result.unwrap();
let arc_value = output.downcast::<i32>().unwrap();
assert_eq!(*arc_value, 42); // 21 * 2 = 42
}
#[tokio::test]
async fn test_execute_with_channels_sink_node() {
async fn test_execute_with_listeners_sink_node() {
// Test that sink nodes (no dependents) return their output
let node = Box::new(TypedNode::new(NodeId(0), TestTask { value: 42 }));
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// No senders means this is a sink node
let receivers = vec![];
let senders = vec![];
// Create a real announcer (even though no one will listen)
let (announcer, _announcement) = node.create_announcement();
let result = node.execute_with_channels(receivers, senders).await;
let node = Box::new(node);
// Sink node has no listeners
let listeners = vec![];
let result = node.execute_with_listeners(listeners, announcer).await;
assert!(result.is_ok());
// Check output was returned (Arc-wrapped)
@@ -119,29 +146,28 @@ async fn test_execute_with_channels_sink_node() {
}
#[tokio::test]
async fn test_execute_with_channels_non_sink_node() {
// Test that non-sink nodes send output via channels
async fn test_execute_with_listeners_non_sink_node() {
// Test that non-sink nodes broadcast output via announcement
let node = TypedNode::new(NodeId(0), TestTask { value: 42 });
// Create output channels for one dependent
let (senders, mut receivers) = node.create_output_channels(1);
// Create announcement and listener
let (announcer, announcement) = node.create_announcement();
let listener = node.create_listener(announcement.as_ref());
// Execute with senders (non-sink node)
// Execute with announcer (non-sink node)
let node = Box::new(node);
let exec_receivers = vec![];
let result = node.execute_with_channels(exec_receivers, senders).await;
let listeners = vec![];
let result = node.execute_with_listeners(listeners, announcer).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>>>()
// Verify output was broadcast through announcement
let listener = *listener
.downcast::<announcement::Listener<std::sync::Arc<i32>>>()
.unwrap();
let received_arc = rx.await;
assert!(received_arc.is_ok());
assert_eq!(*received_arc.unwrap(), 42);
let received = listener.listen().await;
assert!(received.is_ok());
assert_eq!(*received.unwrap(), 42);
// Also verify output was returned (Arc-wrapped)
let output = result.unwrap();
@@ -150,27 +176,30 @@ async fn test_execute_with_channels_non_sink_node() {
}
#[tokio::test]
async fn test_execute_with_multiple_dependents() {
// Test that output is sent to multiple dependents
async fn test_execute_with_multiple_listeners() {
// Test that output is broadcast to multiple listeners
let node = TypedNode::new(NodeId(0), TestTask { value: 100 });
// Create output channels for three dependents
let (senders, receivers) = node.create_output_channels(3);
// Create announcement and multiple listeners
let (announcer, announcement) = node.create_announcement();
let listener1 = node.create_listener(announcement.as_ref());
let listener2 = node.create_listener(announcement.as_ref());
let listener3 = node.create_listener(announcement.as_ref());
// Execute node
let node = Box::new(node);
let exec_receivers = vec![];
let result = node.execute_with_channels(exec_receivers, senders).await;
let listeners = vec![];
let result = node.execute_with_listeners(listeners, announcer).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>>>()
// Verify all three listeners receive the output
for listener in [listener1, listener2, listener3] {
let listener = *listener
.downcast::<announcement::Listener<std::sync::Arc<i32>>>()
.unwrap();
let received_arc = rx.await;
assert!(received_arc.is_ok());
assert_eq!(*received_arc.unwrap(), 100);
let received = listener.listen().await;
assert!(received.is_ok());
assert_eq!(*received.unwrap(), 100);
}
}
+44 -40
View File
@@ -22,9 +22,7 @@ use tracing::{debug, error, info, trace};
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
@@ -195,9 +193,7 @@ impl DagRunner {
/// ```
pub fn add_task<Tk>(&self, task: Tk) -> TaskBuilder<'_, Tk, Pending>
where
Tk: Task + 'static,
Tk::Input: 'static + Clone + ExtractInput,
Tk::Output: 'static + Clone,
Tk: crate::node::TaskExecute + 'static,
{
let id = self.alloc_id();
@@ -321,39 +317,41 @@ impl DagRunner {
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 ONE announcement per task
// Each task broadcasts its output to all dependents via announcement
let mut announcements: HashMap<NodeId, Box<dyn std::any::Any + Send>> = HashMap::new();
let mut announcers: HashMap<NodeId, 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
// Create announcements for all tasks
{
let nodes_lock = self.nodes.lock();
for (node_id, node_opt) in nodes_lock.iter().enumerate() {
if let Some(node) = node_opt {
let (announcer, announcement) = node.create_announcement();
announcers.insert(NodeId(node_id), announcer);
announcements.insert(NodeId(node_id), announcement);
}
}
}
// Create listeners for each consumer from their dependencies
let mut consumer_listeners: HashMap<NodeId, Vec<Box<dyn std::any::Any + Send>>> =
HashMap::new();
{
let nodes_lock = self.nodes.lock();
for (consumer_id, producer_ids) in &edges {
let mut listeners = Vec::new();
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());
if let Some(announcement) = announcements.get(&producer_id) {
if let Some(Some(producer_node)) = nodes_lock.get(producer_id.0) {
// Producer node creates listener from its own announcement
let listener = producer_node.create_listener(announcement.as_ref());
listeners.push(listener);
}
}
}
consumer_listeners.insert(*consumer_id, listeners);
}
}
@@ -412,11 +410,14 @@ impl DagRunner {
};
if let Some(node) = node {
// Take the receivers for this consumer
let receivers = consumer_receivers.remove(&node_id).unwrap_or_default();
// Take the listeners for this consumer
let listeners = consumer_listeners.remove(&node_id).unwrap_or_default();
// Take the senders for this producer
let senders = producer_senders.remove(&node_id).unwrap_or_default();
// Take the announcer for this producer
let announcer = announcers.remove(&node_id).unwrap_or_else(|| {
// Create dummy announcer for tasks with no dependents
Box::new(()) as Box<dyn std::any::Any + Send>
});
// Execute inline with panic handling.
//
@@ -424,7 +425,7 @@ impl DagRunner {
// 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))
let result = AssertUnwindSafe(node.execute_with_listeners(listeners, announcer))
.catch_unwind()
.await
.unwrap_or_else(|panic_payload| {
@@ -473,15 +474,18 @@ impl DagRunner {
};
if let Some(node) = node {
// Take the receivers for this consumer
let receivers = consumer_receivers.remove(&node_id).unwrap_or_default();
// Take the listeners for this consumer
let listeners = consumer_listeners.remove(&node_id).unwrap_or_default();
// Take the senders for this producer
let senders = producer_senders.remove(&node_id).unwrap_or_default();
// Take the announcer for this producer
let announcer = announcers.remove(&node_id).unwrap_or_else(|| {
// Create dummy announcer for tasks with no dependents
Box::new(()) as Box<dyn std::any::Any + Send>
});
// Create a 'static future by taking ownership
let inner_future = async move {
let task_future = node.execute_with_channels(receivers, senders);
let task_future = node.execute_with_listeners(listeners, announcer);
let result = task_future.await;
+172 -1
View File
@@ -1,8 +1,10 @@
//! Unit tests for runner module
// Allow tests to reference dagx:: paths (for proc macro generated code)
extern crate self as dagx;
use crate::error::DagError;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::TaskHandle;
// Initialize tracing subscriber for tests (idempotent)
@@ -299,3 +301,172 @@ async fn test_task_panic_in_multi_task_layer() {
// This test mainly ensures we don't crash
assert!(result.is_ok() || result.is_err());
}
// ============================================================================
// Coverage tests for DagRunner::get() type mismatch errors
// These tests target the error path at src/runner.rs:601-607
// ============================================================================
#[tokio::test]
async fn test_get_type_mismatch_i32_to_vec() {
init_tracing();
// Test type mismatch: i32 to Vec<u8>
struct IntTask;
#[crate::task]
impl IntTask {
async fn run(&self) -> i32 {
123
}
}
let dag = DagRunner::new();
let int_task = dag.add_task(IntTask);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Try to get as Vec<u8> - TYPE MISMATCH!
let wrong_handle: TaskHandle<Vec<u8>> = TaskHandle {
id: int_task.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(wrong_handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::TypeMismatch { expected, found } => {
assert_eq!(expected, std::any::type_name::<Vec<u8>>());
assert_eq!(found, "unknown");
}
_ => panic!("Expected TypeMismatch error"),
}
}
#[tokio::test]
async fn test_get_type_mismatch_struct_to_hashmap() {
init_tracing();
// Test type mismatch: custom struct to HashMap
use std::collections::HashMap;
#[derive(Clone)]
struct CustomOutput {
#[allow(dead_code)]
value: i32,
}
struct CustomTask;
#[crate::task]
impl CustomTask {
async fn run(&self) -> CustomOutput {
CustomOutput { value: 42 }
}
}
let dag = DagRunner::new();
let task_handle = dag.add_task(CustomTask);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Try to get as HashMap - TYPE MISMATCH!
let wrong_handle: TaskHandle<HashMap<String, i32>> = TaskHandle {
id: task_handle.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(wrong_handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::TypeMismatch { expected, found } => {
assert!(expected.contains("HashMap"));
assert_eq!(found, "unknown");
}
_ => panic!("Expected TypeMismatch error"),
}
}
#[tokio::test]
async fn test_get_type_mismatch_vec_different_types() {
init_tracing();
// Test type mismatch: Vec<i32> to Vec<String>
struct VecTask;
#[crate::task]
impl VecTask {
async fn run(&self) -> Vec<i32> {
vec![1, 2, 3]
}
}
let dag = DagRunner::new();
let vec_task = dag.add_task(VecTask);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Try to get as Vec<String> - TYPE MISMATCH!
let wrong_handle: TaskHandle<Vec<String>> = TaskHandle {
id: vec_task.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(wrong_handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::TypeMismatch { expected, found } => {
assert_eq!(expected, std::any::type_name::<Vec<String>>());
assert_eq!(found, "unknown");
}
_ => panic!("Expected TypeMismatch error"),
}
}
#[tokio::test]
async fn test_get_type_mismatch_option_types() {
init_tracing();
// Test type mismatch: Option<i32> to Option<String>
struct OptionTask;
#[crate::task]
impl OptionTask {
async fn run(&self) -> Option<i32> {
Some(42)
}
}
let dag = DagRunner::new();
let opt_task = dag.add_task(OptionTask);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Try to get as Option<String> - TYPE MISMATCH!
let wrong_handle: TaskHandle<Option<String>> = TaskHandle {
id: opt_task.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(wrong_handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::TypeMismatch { expected, found } => {
assert_eq!(expected, std::any::type_name::<Option<String>>());
assert_eq!(found, "unknown");
}
_ => panic!("Expected TypeMismatch error"),
}
}
+247 -80
View File
@@ -3,94 +3,200 @@
//! This module defines the core Task trait that all DAG nodes must implement,
//! along with convenience functions for creating tasks from closures.
use std::any::Any;
use std::future::Future;
use std::sync::Arc;
/// Helper trait for extracting inputs from listeners (internal use only)
trait ExtractFromListeners: Sized + Send + Sync + Clone + 'static {
fn extract(
listeners: Vec<Box<dyn Any + Send>>,
) -> impl Future<Output = Result<Self, String>> + Send;
}
// Implementation for unit type (no dependencies)
impl ExtractFromListeners for () {
async fn extract(_listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
Ok(())
}
}
// Implementation for single input types (not tuples)
// Note: These now return Arc<T> instead of T for efficiency
macro_rules! impl_extract_single {
($($t:ty),+) => {
$(
impl ExtractFromListeners for std::sync::Arc<$t> {
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<$t>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<{}>>", std::any::type_name::<$t>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
)+
};
}
impl_extract_single!(i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, char, String);
// Implementation for Result types (wrapped in Arc)
impl<T, E> ExtractFromListeners for std::sync::Arc<Result<T, E>>
where
T: 'static + Clone + Send + Sync,
E: 'static + Clone + Send + Sync,
{
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<Result<T, E>>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<Result<{}, {}>>>", std::any::type_name::<T>(), std::any::type_name::<E>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
// Implementation for Option types (wrapped in Arc)
impl<T> ExtractFromListeners for std::sync::Arc<Option<T>>
where
T: 'static + Clone + Send + Sync,
{
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<Option<T>>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<Option<{}>>>", std::any::type_name::<T>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
// Implementation for Vec types (wrapped in Arc)
impl<T> ExtractFromListeners for std::sync::Arc<Vec<T>>
where
T: 'static + Clone + Send + Sync,
{
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<Vec<T>>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<Vec<{}>>>", std::any::type_name::<T>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
// Implementation for Arc types (Arc<Arc<T>> - useful when T itself is Arc-wrapped by user)
impl<T> ExtractFromListeners for std::sync::Arc<std::sync::Arc<T>>
where
T: 'static + Clone + Send + Sync,
{
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<std::sync::Arc<T>>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<Arc<{}>>>", std::any::type_name::<T>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
// Implementation for HashMap types (wrapped in Arc)
impl<K, V> ExtractFromListeners for std::sync::Arc<std::collections::HashMap<K, V>>
where
K: 'static + Clone + Send + Sync + Eq + std::hash::Hash,
V: 'static + Clone + Send + Sync,
{
async fn extract(mut listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
if listeners.len() != 1 {
return Err(format!("Expected 1 dependency, got {}", listeners.len()));
}
let listener = *listeners.pop()
.unwrap()
.downcast::<announcement::Listener<std::sync::Arc<std::collections::HashMap<K, V>>>>()
.map_err(|_| format!("Type mismatch: expected Listener<Arc<HashMap<{}, {}>>>", std::any::type_name::<K>(), std::any::type_name::<V>()))?;
listener.listen().await.map_err(|_| "Listener closed before receiving value".to_string())
}
}
// Macro to implement ExtractFromListeners for tuple types
// Note: Tuples now contain Arc-wrapped elements for efficiency
macro_rules! impl_extract_tuple {
($($T:ident : $idx:tt),+) => {
#[allow(non_snake_case)]
impl<$($T),+> ExtractFromListeners for ($(std::sync::Arc<$T>,)+)
where
$($T: 'static + Clone + Send + Sync),+
{
async fn extract(listeners: Vec<Box<dyn Any + Send>>) -> Result<Self, String> {
let expected_count = [$($idx),+].len();
if listeners.len() != expected_count {
return Err(format!("Expected {} dependencies, got {}", expected_count, listeners.len()));
}
let mut iter = listeners.into_iter();
$(
let $T = *iter.next().unwrap()
.downcast::<announcement::Listener<std::sync::Arc<$T>>>()
.map_err(|_| format!("Type mismatch at index {}: expected Listener<Arc<{}>>", $idx, std::any::type_name::<$T>()))?;
)+
let results = futures::join!(
$(
async move {
$T.listen().await.map_err(|_| format!("Listener {} closed", $idx))
}
),+
);
Ok(($(
results.$idx?
,)+))
}
}
};
}
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);
/// 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.
/// **NOTE**: For new code, use the [`task`](crate::task) macro which automatically generates
/// an `execute()` method and handles all the wiring. This trait is primarily for internal use
/// and backward compatibility with `task_fn`.
///
/// # 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:
/// The `#[task]` macro generates an `execute()` method on your struct that:
/// - Extracts inputs from announcement listeners
/// - Calls your `run()` method
/// - Announces the result to dependents
/// - Returns Arc-wrapped output
///
/// # 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.
/// You define output as `T`, but the framework wraps it in `Arc<T>` when announcing.
/// 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
type Output: Send + Sync + Clone; // Sync and Clone required for Arc-wrapping and announcement
fn run(self, input: Self::Input) -> impl Future<Output = Self::Output> + Send;
}
@@ -100,12 +206,12 @@ pub trait Task: Send {
/// **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>
pub fn task_fn<I, O, Fut, F>(f: F) -> impl Task<Input = I, Output = O> + crate::node::TaskExecute<Input = I, Output = O>
where
F: FnMut(I) -> Fut + Send + 'static,
Fut: Future<Output = O> + Send + 'static,
I: Send + 'static,
O: Send + Sync + 'static,
I: ExtractFromListeners,
O: Send + Sync + Clone + 'static,
{
struct TaskFn<I, O, Fut, F>
where
@@ -123,7 +229,7 @@ where
F: FnMut(I) -> Fut + Send,
Fut: Future<Output = O> + Send,
I: Send,
O: Send + Sync,
O: Send + Sync + Clone,
{
type Input = I;
type Output = O;
@@ -133,6 +239,67 @@ where
}
}
impl<I, O, Fut, F> crate::node::TaskExecute for TaskFn<I, O, Fut, F>
where
F: FnMut(I) -> Fut + Send + 'static,
Fut: Future<Output = O> + Send + 'static,
I: ExtractFromListeners,
O: Send + Sync + Clone + 'static,
{
type Input = I;
type Output = O;
fn execute(
mut self,
listeners: Vec<Box<dyn std::any::Any + Send>>,
announcer: Box<dyn std::any::Any + Send>,
) -> std::pin::Pin<
Box<
dyn Future<Output = Result<std::sync::Arc<dyn std::any::Any + Send + Sync>, String>>
+ Send,
>,
> {
Box::pin(async move {
use std::sync::Arc;
// Extract the input using the ExtractFromListeners trait
let input = I::extract(listeners).await?;
// Call the closure
let output: O = (self.f)(input).await;
// Wrap in Arc
let output_arc = Arc::new(output);
// Announce the result (announce the Arc, not a clone of the data)
let announcer_typed = announcer
.downcast::<announcement::Announcer<Arc<O>>>()
.map_err(|_| {
"Type mismatch: announcer type does not match task output type".to_string()
})?;
let _ = announcer_typed.announce(Arc::clone(&output_arc));
// Return Arc-wrapped output
Ok(output_arc as Arc<dyn std::any::Any + Send + Sync>)
})
}
fn create_announcement_erased() -> (Box<dyn std::any::Any + Send>, Box<dyn std::any::Any + Send>) {
let (announcer, announcement) = announcement::Announcement::<Arc<O>>::new();
(
Box::new(announcer) as Box<dyn std::any::Any + Send>,
Box::new(announcement) as Box<dyn std::any::Any + Send>,
)
}
fn create_listener_erased(announcement: &dyn std::any::Any) -> Box<dyn std::any::Any + Send> {
let announcement = announcement
.downcast_ref::<announcement::Announcement<Arc<O>>>()
.expect("Type mismatch: announcement type does not match task output type");
Box::new(announcement.listener()) as Box<dyn std::any::Any + Send>
}
}
TaskFn {
f,
_phantom: std::marker::PhantomData,
+1 -115
View File
@@ -1,72 +1,9 @@
//! Unit tests for task module
use crate::task::{task_fn, Task};
use crate::task::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,
@@ -111,55 +48,4 @@ fn assert_task_bounds<T: Task>() {
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));
}
#[tokio::test]
async fn test_task_fn_explicit_trait_call() {
// Test explicit Task trait method call to cover line 121
use crate::task::Task;
let task = task_fn(|x: i32| async move { x * 3 });
// Explicitly call the Task trait's run method
let result = Task::run(task, 10).await;
assert_eq!(result, 30);
}
+3 -3
View File
@@ -4,7 +4,7 @@
//! including node identifiers, task handles, and type-state markers.
use crate::builder::{IsUnitType, TaskBuilder};
use crate::task::Task;
use crate::node::TaskExecute;
/// Opaque node identifier (internal use)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -62,7 +62,7 @@ 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>
impl<'a, Tk: TaskExecute, Deps> From<TaskBuilder<'a, Tk, Deps>> for TaskHandle<Tk::Output>
where
Tk::Input: IsUnitType,
{
@@ -74,7 +74,7 @@ where
}
}
impl<'a, Tk: Task, Deps> From<&TaskBuilder<'a, Tk, Deps>> for TaskHandle<Tk::Output>
impl<'a, Tk: TaskExecute, Deps> From<&TaskBuilder<'a, Tk, Deps>> for TaskHandle<Tk::Output>
where
Tk::Input: IsUnitType,
{
+158 -1
View File
@@ -1,6 +1,8 @@
//! Unit tests for types module
use crate::task::Task;
// Allow tests to reference dagx:: paths (for proc macro generated code)
extern crate self as dagx;
use crate::types::{NodeId, TaskHandle};
use std::marker::PhantomData;
@@ -170,3 +172,158 @@ fn test_task_builder_ref_from_conversion() {
let handle: TaskHandle<String> = (&builder).into();
assert_eq!(handle.id, builder_id);
}
// ============================================================================
// Additional TaskBuilder conversion coverage tests
// Port from commit sq (sqpzloqrlykp) - src/types/coverage_tests.rs
// ============================================================================
#[tokio::test]
async fn test_taskbuilder_conversion_with_multiple_tasks() {
// Test conversion with multiple unit-type tasks to ensure IDs are preserved
use crate::runner::DagRunner;
struct FirstTask;
#[crate::task]
impl FirstTask {
async fn run(&self) -> String {
"first".to_string()
}
}
struct SecondTask;
#[crate::task]
impl SecondTask {
async fn run(&self) -> i32 {
100
}
}
struct ThirdTask;
#[crate::task]
impl ThirdTask {
async fn run(&self) -> bool {
true
}
}
let dag = DagRunner::new();
// Create builders for all tasks
let first_builder = dag.add_task(FirstTask);
let second_builder = dag.add_task(SecondTask);
let third_builder = dag.add_task(ThirdTask);
// Save IDs before conversion
let first_id = first_builder.id;
let second_id = second_builder.id;
let third_id = third_builder.id;
// Convert each builder to handle explicitly
let first_handle: TaskHandle<String> = first_builder.into();
let second_handle: TaskHandle<i32> = second_builder.into();
let third_handle: TaskHandle<bool> = third_builder.into();
// Verify IDs are preserved through conversion
assert_eq!(first_handle.id, first_id);
assert_eq!(second_handle.id, second_id);
assert_eq!(third_handle.id, third_id);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
// Verify all outputs are correct
assert_eq!(dag.get(first_handle).unwrap(), "first");
assert_eq!(dag.get(second_handle).unwrap(), 100);
assert!(dag.get(third_handle).unwrap());
}
#[tokio::test]
async fn test_taskbuilder_conversion_with_stateful_task() {
// Test that stateful tasks (with fields) can have unit input and convert correctly
use crate::runner::DagRunner;
struct StatefulTask {
value: i32,
}
impl StatefulTask {
fn new(value: i32) -> Self {
Self { value }
}
}
#[crate::task]
impl StatefulTask {
async fn run(&self) -> i32 {
self.value * 2
}
}
let dag = DagRunner::new();
// Create task with initial state
let builder = dag.add_task(StatefulTask::new(21));
let builder_id = builder.id;
// Explicit conversion - ensures From<TaskBuilder> works for stateful tasks
let handle: TaskHandle<i32> = builder.into();
assert_eq!(handle.id, builder_id);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
assert_eq!(dag.get(handle).unwrap(), 42);
}
#[tokio::test]
async fn test_taskbuilder_conversion_with_complex_output_types() {
// Test conversion works with complex output types (Vec, Option, etc.)
use crate::runner::DagRunner;
struct VecTask;
#[crate::task]
impl VecTask {
async fn run(&self) -> Vec<i32> {
vec![1, 2, 3]
}
}
struct OptionTask;
#[crate::task]
impl OptionTask {
async fn run(&self) -> Option<String> {
Some("value".to_string())
}
}
let dag = DagRunner::new();
let vec_builder = dag.add_task(VecTask);
let option_builder = dag.add_task(OptionTask);
let vec_id = vec_builder.id;
let option_id = option_builder.id;
// Convert to handles with complex types
let vec_handle: TaskHandle<Vec<i32>> = vec_builder.into();
let option_handle: TaskHandle<Option<String>> = option_builder.into();
assert_eq!(vec_handle.id, vec_id);
assert_eq!(option_handle.id, option_id);
dag.run(|fut| {
tokio::spawn(fut);
})
.await
.unwrap();
assert_eq!(dag.get(vec_handle).unwrap(), vec![1, 2, 3]);
assert_eq!(dag.get(option_handle).unwrap(), Some("value".to_string()));
}
+1 -1
View File
@@ -9,7 +9,7 @@
//!
//! These tests serve as both validation and documentation for proper Arc usage.
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
+1 -1
View File
@@ -1,7 +1,7 @@
//! Tests for empty DAGs and minimal task configurations
use crate::common::task_fn;
use dagx::{task, DagResult, DagRunner, Task};
use dagx::{task, DagResult, DagRunner};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
+1 -1
View File
@@ -7,7 +7,7 @@ pub use dagx::task_fn;
// Re-export commonly used test structs
#[allow(dead_code)]
pub mod tasks {
use dagx::{task, Task};
use dagx::task;
// Simple seed task with mutable state
pub struct Seed {
+1 -1
View File
@@ -3,7 +3,7 @@
//! These tests show that even if you try to create cycles, the Rust compiler
//! prevents it through the type-state pattern.
use dagx::{task, DagRunner, Task, TaskHandle};
use dagx::{task, DagRunner, TaskHandle};
/// Demonstrates that TaskBuilder is consumed by depends_on,
/// preventing you from wiring the same task multiple times
+1 -1
View File
@@ -1,6 +1,6 @@
//! Integration tests for end-to-end functionality
use dagx::{DagResult, DagRunner, Task};
use dagx::{DagResult, DagRunner};
use crate::common::task_fn;
use std::time::Instant;
use tokio::time::{sleep, Duration};
+1 -1
View File
@@ -1,7 +1,7 @@
//! Tests for timing and speedup verification
use crate::common::task_fn;
use dagx::{DagResult, DagRunner, Task};
use dagx::{DagResult, DagRunner};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, Instant};
+533
View File
@@ -0,0 +1,533 @@
//! Property-based testing for the dagx crate.
//! Uses proptest to generate random test cases and verify invariants.
use dagx::DagRunner;
use proptest::prelude::*;
use std::collections::HashMap;
// Re-export task macro for use in tests
use dagx::task;
// Strategy for generating various types
fn arb_string() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9]{0,100}").unwrap()
}
fn arb_vec() -> impl Strategy<Value = Vec<i32>> {
prop::collection::vec(any::<i32>(), 0..100)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Single task execution preserves value
///
/// Verifies: Output matches input for identity tasks
/// Strategy: Random i32 values
#[test]
fn prop_single_task_preserves_value(value: i32) {
struct IdentityTask { val: i32 }
#[task]
impl IdentityTask {
async fn run(&self) -> i32 { self.val }
}
let dag = DagRunner::new();
let task = dag.add_task(IdentityTask { val: value });
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
assert_eq!(dag.get(task).unwrap(), value);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Linear dependency chain maintains computation
///
/// Verifies: Values flow correctly through chain
/// Strategy: Random starting value with fixed operations
#[test]
fn prop_linear_chain_correctness(start: i32) {
struct Source { val: i32 }
#[task]
impl Source {
async fn run(&self) -> i32 { self.val }
}
struct AddOne;
#[task]
impl AddOne {
async fn run(input: &i32) -> i32 {
input.wrapping_add(1)
}
}
struct Double;
#[task]
impl Double {
async fn run(input: &i32) -> i32 {
input.wrapping_mul(2)
}
}
let dag = DagRunner::new();
let source = dag.add_task(Source { val: start });
let add_one = dag.add_task(AddOne).depends_on(&source);
let double = dag.add_task(Double).depends_on(&add_one);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let expected = start.wrapping_add(1).wrapping_mul(2);
assert_eq!(dag.get(double).unwrap(), expected);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Fan-out delivers same value to all dependents
///
/// Verifies: All dependent tasks receive identical input
/// Strategy: Random source value with multiple computations
#[test]
fn prop_fan_out_delivers_same_value(value: i32) {
struct Source { val: i32 }
#[task]
impl Source {
async fn run(&self) -> i32 { self.val }
}
struct AddOne;
#[task]
impl AddOne {
async fn run(input: &i32) -> i32 {
input.wrapping_add(1)
}
}
struct AddTwo;
#[task]
impl AddTwo {
async fn run(input: &i32) -> i32 {
input.wrapping_add(2)
}
}
struct AddThree;
#[task]
impl AddThree {
async fn run(input: &i32) -> i32 {
input.wrapping_add(3)
}
}
let dag = DagRunner::new();
let source = dag.add_task(Source { val: value });
let t1 = dag.add_task(AddOne).depends_on(&source);
let t2 = dag.add_task(AddTwo).depends_on(&source);
let t3 = dag.add_task(AddThree).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
assert_eq!(dag.get(t1).unwrap(), value.wrapping_add(1));
assert_eq!(dag.get(t2).unwrap(), value.wrapping_add(2));
assert_eq!(dag.get(t3).unwrap(), value.wrapping_add(3));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Diamond pattern computes correctly
///
/// Verifies: Fork and join operations maintain correctness
/// Strategy: Random base value through diamond computation
#[test]
fn prop_diamond_pattern_correctness(base: i32) {
struct Source { val: i32 }
#[task]
impl Source {
async fn run(&self) -> i32 { self.val }
}
struct Left;
#[task]
impl Left {
async fn run(input: &i32) -> i32 {
input.wrapping_mul(2)
}
}
struct Right;
#[task]
impl Right {
async fn run(input: &i32) -> i32 {
input.wrapping_mul(3)
}
}
struct Merge;
#[task]
impl Merge {
async fn run(left: &i32, right: &i32) -> i32 {
left.wrapping_add(*right)
}
}
let dag = DagRunner::new();
let source = dag.add_task(Source { val: base });
let left = dag.add_task(Left).depends_on(&source);
let right = dag.add_task(Right).depends_on(&source);
let merge = dag.add_task(Merge).depends_on((&left, &right));
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let expected = base.wrapping_mul(2).wrapping_add(base.wrapping_mul(3));
assert_eq!(dag.get(merge).unwrap(), expected);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: String data preserved through DAG
///
/// Verifies: Non-trivial types flow correctly
/// Strategy: Random strings with transformations
#[test]
fn prop_string_data_preserved(s in arb_string()) {
struct StringSource { val: String }
#[task]
impl StringSource {
async fn run(&self) -> String { self.val.clone() }
}
struct Uppercase;
#[task]
impl Uppercase {
async fn run(input: &String) -> String {
input.to_uppercase()
}
}
struct Lowercase;
#[task]
impl Lowercase {
async fn run(input: &String) -> String {
input.to_lowercase()
}
}
let dag = DagRunner::new();
let source = dag.add_task(StringSource { val: s.clone() });
let upper = dag.add_task(Uppercase).depends_on(&source);
let lower = dag.add_task(Lowercase).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
assert_eq!(dag.get(upper).unwrap(), s.to_uppercase());
assert_eq!(dag.get(lower).unwrap(), s.to_lowercase());
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Vec operations maintain length and contents
///
/// Verifies: Collection types work correctly in DAG
/// Strategy: Random Vec<i32> with transformations
#[test]
fn prop_vec_operations_correct(vec in arb_vec()) {
struct VecSource { data: Vec<i32> }
#[task]
impl VecSource {
async fn run(&self) -> Vec<i32> { self.data.clone() }
}
struct Sum;
#[task]
impl Sum {
async fn run(input: &Vec<i32>) -> i32 {
input.iter().fold(0i32, |acc, &x| acc.wrapping_add(x))
}
}
struct Len;
#[task]
impl Len {
async fn run(input: &Vec<i32>) -> usize {
input.len()
}
}
let dag = DagRunner::new();
let source = dag.add_task(VecSource { data: vec.clone() });
let sum = dag.add_task(Sum).depends_on(&source);
let len = dag.add_task(Len).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let expected_sum = vec.iter().fold(0i32, |acc, &x| acc.wrapping_add(x));
assert_eq!(dag.get(sum).unwrap(), expected_sum);
assert_eq!(dag.get(len).unwrap(), vec.len());
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Option<T> values preserved correctly
///
/// Verifies: Both Some and None variants handled properly
/// Strategy: Random Option<i32> values
#[test]
fn prop_option_handling(opt in prop::option::of(any::<i32>())) {
struct OptionSource { val: Option<i32> }
#[task]
impl OptionSource {
async fn run(&self) -> Option<i32> { self.val }
}
struct MapOption;
#[task]
impl MapOption {
async fn run(input: &Option<i32>) -> Option<i32> {
input.map(|x| x.wrapping_mul(2))
}
}
let dag = DagRunner::new();
let source = dag.add_task(OptionSource { val: opt });
let mapped = dag.add_task(MapOption).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let expected = opt.map(|x| x.wrapping_mul(2));
assert_eq!(dag.get(mapped).unwrap(), expected);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Result<T, E> values preserved correctly
///
/// Verifies: Both Ok and Err variants handled properly
/// Strategy: Random Result<i32, String> values
#[test]
fn prop_result_handling(
result in prop::result::maybe_ok(any::<i32>(), arb_string())
) {
struct ResultSource { val: Result<i32, String> }
#[task]
impl ResultSource {
async fn run(&self) -> Result<i32, String> { self.val.clone() }
}
struct MapResult;
#[task]
impl MapResult {
async fn run(input: &Result<i32, String>) -> Result<i32, String> {
input.as_ref().map(|&x| x.wrapping_add(1)).map_err(|e| e.clone())
}
}
let dag = DagRunner::new();
let source = dag.add_task(ResultSource { val: result.clone() });
let mapped = dag.add_task(MapResult).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let expected = result.map(|x| x.wrapping_add(1));
assert_eq!(dag.get(mapped).unwrap(), expected);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: HashMap structure preserved through DAG
///
/// Verifies: Complex types maintain structure
/// Strategy: Random HashMap with string keys and i32 values
#[test]
fn prop_hashmap_preservation(
data in prop::collection::hash_map(arb_string(), any::<i32>(), 0..20)
) {
struct MapSource { data: HashMap<String, i32> }
#[task]
impl MapSource {
async fn run(&self) -> HashMap<String, i32> { self.data.clone() }
}
struct MapLen;
#[task]
impl MapLen {
async fn run(input: &HashMap<String, i32>) -> usize {
input.len()
}
}
let dag = DagRunner::new();
let source = dag.add_task(MapSource { data: data.clone() });
let len = dag.add_task(MapLen).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let result_map = dag.get(source).unwrap();
assert_eq!(result_map.len(), data.len());
for (k, v) in &data {
assert_eq!(result_map.get(k), Some(v));
}
assert_eq!(dag.get(len).unwrap(), data.len());
}
}
// Custom struct for testing
#[derive(Clone, Debug, PartialEq)]
struct CustomData {
id: i32,
name: String,
values: Vec<i32>,
}
// Strategy for generating CustomData
fn arb_custom_data() -> impl Strategy<Value = CustomData> {
(any::<i32>(), arb_string(), arb_vec())
.prop_map(|(id, name, values)| CustomData { id, name, values })
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Custom structs preserved through DAG
///
/// Verifies: User-defined types work correctly
/// Strategy: Random custom struct instances
#[test]
fn prop_custom_struct_preservation(custom in arb_custom_data()) {
struct CustomSource { data: CustomData }
#[task]
impl CustomSource {
async fn run(&self) -> CustomData { self.data.clone() }
}
struct ExtractId;
#[task]
impl ExtractId {
async fn run(input: &CustomData) -> i32 {
input.id
}
}
let dag = DagRunner::new();
let source = dag.add_task(CustomSource { data: custom.clone() });
let id = dag.add_task(ExtractId).depends_on(&source);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
let result = dag.get(source).unwrap();
assert_eq!(result, custom);
assert_eq!(dag.get(id).unwrap(), custom.id);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Property: Multiple independent DAGs are isolated
///
/// Verifies: DAG instances don't interfere with each other
/// Strategy: Two random values in separate DAGs
#[test]
fn prop_dag_isolation(value1: i32, value2: i32) {
struct Source { val: i32 }
#[task]
impl Source {
async fn run(&self) -> i32 { self.val }
}
let dag1 = DagRunner::new();
let dag2 = DagRunner::new();
let task1 = dag1.add_task(Source { val: value1 });
let task2 = dag2.add_task(Source { val: value2 });
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag1.run(|fut| { tokio::spawn(fut); }).await.unwrap();
dag2.run(|fut| { tokio::spawn(fut); }).await.unwrap();
});
assert_eq!(dag1.get(task1).unwrap(), value1);
assert_eq!(dag2.get(task2).unwrap(), value2);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
/// Property: N independent tasks all execute correctly
///
/// Verifies: Concurrent execution scales correctly
/// Strategy: Variable task count (1-20) with unique values
#[test]
fn prop_concurrent_independent_tasks(count in 1usize..20) {
struct IndependentTask { val: i32 }
#[task]
impl IndependentTask {
async fn run(&self) -> i32 { self.val }
}
let dag = DagRunner::new();
let mut tasks = Vec::new();
for i in 0..count {
let task = dag.add_task(IndependentTask { val: i as i32 });
tasks.push((task, i as i32));
}
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
dag.run(|fut| { tokio::spawn(fut); }).await
}).unwrap();
for (handle, expected) in tasks {
assert_eq!(dag.get(handle).unwrap(), expected);
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Runtime compatibility tests for async-std
use dagx::{DagRunner, Task};
use dagx::DagRunner;
use crate::common::task_fn;
struct Value(i32);
+1 -1
View File
@@ -1,6 +1,6 @@
//! Runtime integration tests - verify dagx works correctly across different runtimes
use dagx::{DagResult, DagRunner, Task};
use dagx::{DagResult, DagRunner};
struct Value(i32);
#[dagx::task]
+2 -2
View File
@@ -486,7 +486,7 @@ fn test_futures_executor_multi_pool() {
#[test]
fn test_sync_tasks_on_async_executor() {
use dagx::Task;
struct SyncAdd;
@@ -519,7 +519,7 @@ fn test_sync_tasks_on_async_executor() {
#[test]
fn test_sync_tasks_on_pollster() {
use dagx::Task;
struct SyncDouble;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Runtime compatibility tests for smol
use dagx::{DagRunner, Task};
use dagx::DagRunner;
use crate::common::task_fn;
struct Value(i32);
+1 -1
View File
@@ -1,6 +1,6 @@
//! Tests with tracing feature enabled
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
use tracing_subscriber::{fmt, EnvFilter};
struct Value(i32);
+1 -1
View File
@@ -1,6 +1,6 @@
//! Tests that the library works correctly without tracing feature
use dagx::{task, DagRunner, Task};
use dagx::{task, DagRunner};
struct Value(i32);