Merge pull request #13 from TechnoPorg/push-qlytvyqokrzl

Cleanup and documentation updates before release
This commit is contained in:
TechnoPorg authored and GitHub committed 2026-02-19 21:24:20 +01:00
commit c8efb00175
44 files changed
+443 -1389

No files matched your search

+23
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Now able to return bare tuples as task outputs
- No longer able to create a DAG with cyclic dependencies ([#3](https://github.com/swaits/dagx/pull/3))
- The `#[task]` macro no longer requires an explicit return type ([#2](https://github.com/swaits/dagx/pull/2))
### Changed
- Task inputs no longer need to be `Clone` ([#7](https://github.com/swaits/dagx/pull/7))
- Building a DAG is now a single-threaded operation ([#12](https://github.com/swaits/dagx/pull/12))
- Running a DAG now consumes the `DagRunner` and returns a `DagOutput` from which to get results ([#12](https://github.com/swaits/dagx/pull/12))
- `#[task]` macro support is now gated behind the `derive` default feature
- Tasks now receive input via the `TaskInput` type, which guarantees via the public API that dependencies can be extracted ([#7](https://github.com/swaits/dagx/pull/7))
- Task outputs are now extracted as owned values, not clones of an Arc value ([#7](https://github.com/swaits/dagx/pull/7))
### Internal
- The `task_fn` macro has been extracted to the dagx-test crate
- Task inputs are passed to tasks directly from the runner, not through channels ([#1](https://github.com/swaits/dagx/pull/1))
- Redundant tests/examples/benchmarks removed to lower maintenance and refactor burden ([#11](https://github.com/swaits/dagx/pull/11))
- parking_lot dependency removed ([#12](https://github.com/swaits/dagx/pull/12))
- futures dependency replaced with futures-util ([#8](https://github.com/swaits/dagx/pull/8))
## [0.3.1] - 2025-10-10
[View changes](https://github.com/swaits/dagx/compare/v0.3.0...v0.3.1)
+2 -4
View File
@@ -29,8 +29,8 @@ cargo clippy -- -D warnings
```bash
rustup component add rustfmt clippy
cargo install cargo-llvm-cov # For coverage
cargo install cargo-audit # For security checks
cargo binstall cargo-tarpaulin # For coverage
cargo binstall cargo-audit # For security checks
```
## Making Changes
@@ -99,8 +99,6 @@ pub fn add_task<T: Task>(&self, task: T) -> TaskBuilder<T> {
```bash
cargo test # Default (tokio)
cargo test --test async_std_runtime # async-std
cargo test --test smol_runtime # smol
```
**Benchmarks:**
Generated
+122 -307
View File
@@ -2,26 +2,11 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
@@ -52,9 +37,9 @@ dependencies = [
[[package]]
name = "async-executor"
version = "1.13.3"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
@@ -90,14 +75,14 @@ dependencies = [
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "async-lock"
version = "3.4.1"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
@@ -148,7 +133,7 @@ dependencies = [
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -180,26 +165,11 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "bitflags"
version = "2.9.4"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blocking"
@@ -216,9 +186,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4"
[[package]]
name = "cast"
@@ -228,9 +198,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cfg-if"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
@@ -261,18 +231,18 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.48"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.48"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstyle",
"clap_lex",
@@ -280,9 +250,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.5"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "concurrent-queue"
@@ -359,9 +329,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "dagrs"
version = "0.5.0"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4bb104a09802f2db14e50ba926d8bca3a96f50eac8b4798ca8a010e6807ae63"
checksum = "377ed242839af227cbb674dc4c6eb44866e1fa922e962c7159c938e1f62797dd"
dependencies = [
"async-trait",
"futures",
@@ -418,7 +388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -450,9 +420,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "futures"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
@@ -465,9 +435,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -475,27 +445,26 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
"num_cpus",
]
[[package]]
name = "futures-io"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
@@ -512,9 +481,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
@@ -523,21 +492,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -547,24 +516,18 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "half"
version = "2.6.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
@@ -573,17 +536,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "io-uring"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -595,15 +547,15 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.81"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -617,9 +569,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.176"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "linux-raw-sys"
@@ -629,9 +581,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
@@ -644,37 +596,17 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"wasi",
"windows-sys 0.59.0",
]
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.52.0",
"windows-sys",
]
[[package]]
@@ -686,25 +618,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -729,12 +642,6 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
@@ -785,7 +692,7 @@ dependencies = [
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -796,18 +703,18 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "proc-macro2"
version = "1.0.101"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
@@ -834,9 +741,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.3"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -846,9 +753,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.11"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -857,27 +764,21 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.6"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "rustc-demangle"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "rustix"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -886,12 +787,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -933,15 +828,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -955,18 +850,19 @@ dependencies = [
[[package]]
name = "signal-hook-registry"
version = "1.4.6"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.11"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
@@ -993,9 +889,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.106"
version = "2.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
dependencies = [
"proc-macro2",
"quote",
@@ -1056,24 +952,19 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.47.1"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"backtrace",
"io-uring",
"libc",
"mio",
"pin-project-lite",
"slab",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -1082,9 +973,9 @@ dependencies = [
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
@@ -1093,9 +984,9 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.30"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
@@ -1104,9 +995,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.34"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -1125,9 +1016,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
@@ -1143,9 +1034,9 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.19"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "valuable"
@@ -1163,17 +1054,11 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
@@ -1182,25 +1067,11 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1208,31 +1079,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.81"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1244,7 +1115,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -1253,24 +1124,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1281,65 +1134,27 @@ dependencies = [
]
[[package]]
name = "windows-targets"
version = "0.52.6"
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"zerocopy-derive",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+3 -2
View File
@@ -18,7 +18,8 @@ categories = ["asynchronous", "concurrency", "data-structures"]
exclude = [".claude/", ".jj/", "dagx-spec.md"]
[features]
default = []
default = ["derive"]
derive = ["dep:dagx-macros"]
tracing = ["dep:tracing"]
[workspace.lints.rust]
@@ -32,7 +33,7 @@ workspace = true
[dependencies]
futures-util = { version = "0.3", default-features = false, features = ["std"] }
dagx-macros = { version = "0.3.1", path = "./dagx-macros" }
dagx-macros = { version = "0.3.1", path = "./dagx-macros", optional = true }
tracing = { version = "0.1", optional = true }
[dev-dependencies]
+62 -155
View File
@@ -10,34 +10,20 @@ A minimal, type-safe, runtime-agnostic async DAG (Directed Acyclic Graph) execut
## Why dagx?
### 🚀 Blazing Fast: 1.04-129x faster than dagrs
### Blazing Fast: 1-100x faster than dagrs
| Workload | Tasks | dagx | dagrs | Speedup |
| ----------------- | ------ | ------- | ------- | ------------------ |
| Sequential chain | 5 | 3.0 µs | 385 µs | **129x faster** 🚀 |
| Sequential chain | 100 | 79 µs | 703 µs | **8.9x faster** |
| Diamond pattern | 4 | 11 µs | 387 µs | **34x faster** |
| Fan-out (1→100) | 101 | 155 µs | 595 µs | **3.85x faster** |
| Independent tasks | 10,000 | 12.7 ms | 13.3 ms | **1.04x faster** |
| ----------------- | ------ | --------- | --------- | ------------------ |
| Sequential chain | 5 | 1.02 µs | 770.42 µs | **755x faster** 🚀 |
| Diamond pattern | 4 | 5.16 µs | 770.87 µs | **149x faster** |
| Sequential chain | 100 | 25.09 µs | 1.19 ms | **47.4x faster** |
| Fan-out (1→100) | 101 | 100.75 µs | 1.02 ms | **10.1x faster** |
| Independent tasks | 10,000 | 8.61 ms | 15.37 ms | **1.79x faster** |
**Per-task overhead:**
- Construction: ~100 ns/task
- Inline execution (sequential): ~790 ns/task
- Parallel execution: ~1.3 µs/task
### 🛡️ Compile-Time Safety
- **Cycles are impossible** — the type system prevents them at compile time, zero runtime overhead
- **No runtime type errors** — dependencies validated at compile time
- **Compiler-verified correctness** — no surprise failures in production
See [how it works](docs/CYCLE_PREVENTION.md).
### ✨ Simple API
### Simple API
```rust
let sum = dag.add_task(Add).depends_on((&x, &y));
let sum = dag.add_task(Add).depends_on((x, y));
dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await?;
```
@@ -87,8 +73,7 @@ async fn main() {
let y = dag.add_task(Value(3));
// Add task that depends on both x and y.
// Here, y could be reused as a dependency for other tasks, while x could not.
let sum = dag.add_task(Add).depends_on((x, &y));
let sum = dag.add_task(Add).depends_on((x, y));
// Execute with true parallelism
let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
@@ -98,33 +83,32 @@ async fn main() {
}
```
## Performance
dagx provides true parallel execution with sub-microsecond overhead per task.
**Why is dagx so fast?**
- **Inline fast-path**: Sequential chains execute inline without spawning (8.9-129x faster)
- **Adaptive execution**: Inline for sequential work, true parallelism for concurrent work
- **Zero-cost abstractions**: Generics and monomorphization eliminate overhead
See [design philosophy](docs/DESIGN_PHILOSOPHY.md) for details.
## Features
- **Compile-time cycle prevention**: Type system makes cycles impossible — no runtime checks
- **Compile-time type safety**: Dependencies validated at compile time, no runtime type errors
- **Works with ANY type**: Custom types work automatically — just `Send + Sync + 'static`, no trait implementations needed
- **Runtime-agnostic**: Works with Tokio, async-std, smol, or any async runtime
- **True parallelism**: Tasks spawn to multiple threads for genuine parallel execution
- **Type-state pattern**: API prevents incorrect wiring through compile-time errors
- **Zero-cost abstractions**: Leverages generics and monomorphization for minimal overhead
- **Flexible task patterns**: Supports stateless, read-only, and mutable state tasks
- **Simple API**: Just `#[task]`, `DagRunner`, `TaskHandle`, and `TaskBuilder`
- **Comprehensive error handling**: Result-based errors with actionable messages
- **Optional tracing**: Zero-cost observability via optional `tracing` feature flag
### Compile-Time Safety
## Core Concepts
- **Cycles are impossible** — the type system prevents them at compile time, zero runtime overhead
- **No runtime type errors** — dependencies validated at compile time
- **Compiler-verified correctness** — no surprise failures in production
See [how it works](docs/CYCLE_PREVENTION.md).
### Runtime Agnostic
dagx works with any async runtime. Provide a spawner function to `run()`:
```rust
// With Tokio
// The join handle result can be unwrapped because dagx catches panics internally
dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
// With smol
dag.run(|fut| smol::spawn(fut)).await.unwrap();
// Single-threaded concurrency on the invoking runtime
// Can be faster in situations where waiting time dominates
dag.run(|fut| fut).await.unwrap()
```
### Task Patterns
@@ -166,73 +150,41 @@ impl Counter {
}
```
### DagRunner
### Tracing
The `DagRunner` orchestrates task execution:
dagx provides optional observability using the `tracing` crate, controlled by the `tracing` feature flag.
```rust
let mut dag = DagRunner::new();
let handle = dag.add_task(MyTask::new());
**Enabling Tracing**
```toml
[dependencies]
dagx = { version = "0.3", features = ["tracing"] }
tracing-subscriber = "0.3"
```
### TaskHandle
**Log Levels**
A `TaskHandle<T>` is a typed reference to a task's output. Use it to wire dependencies and retrieve results:
- **INFO**: DAG execution start/completion
- **DEBUG**: Task additions, dependency wiring, layer computation
- **TRACE**: Individual task execution (inline vs spawned), detailed execution flow
- **ERROR**: Task panics, concurrent execution attempts
```rust
// Single dependency
let task = dag.add_task(my_task).depends_on(&upstream);
### Other
// Multiple dependencies (order matters!)
let task = dag.add_task(my_task).depends_on((&upstream1, &upstream2));
```
- **True parallelism**: Chosen runtime executes tasks concurrently and/or in parallel
- **No boilerplate**: The `derive` feature and the `#[task]` macro are enabled by default to simplify task implementation.
### Custom Types
## Performance
**dagx works with ANY type automatically!** As long as your type implements `Send + Sync + 'static`, it works seamlessly:
dagx provides true parallel execution with sub-microsecond overhead per task.
```rust
struct User {
name: String,
age: u32,
}
**How is dagx so fast?**
#[task]
impl CreateUser {
async fn run(&self) -> User {
User { name: "Alice".to_string(), age: 30 }
}
}
- **Inline fast-path**: Sequential chains execute inline without spawning
- **Adaptive execution**: Inline for sequential work, executor-agnostic parallelism for concurrent work
- **Zero-cost abstractions**: Compile-time graph validation eliminates overhead
#[task]
impl ProcessUser {
async fn run(user: &User) -> String {
format!("{} is {} years old", user.name, user.age)
}
}
```
**No trait implementations needed!** The `#[task]` macro generates type-specific extraction logic automatically. This includes nested structs, collections, enums, and any other type you define.
### Runtime Agnostic
dagx works with any async runtime. Provide a spawner function to `run()`:
```rust
// With Tokio
// The join handle result can be unwrapped because dagx catches panics internally
dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
// With async-std
dag.run(|fut| async_std::task::spawn(fut)).await.unwrap();
// With smol
dag.run(|fut| smol::spawn(fut)).await.unwrap();
// Single-threaded on the invoking runtime
// Faster for situations with many computationally light tasks
dag.run(|fut| fut).await.unwrap()
```
See [design philosophy](docs/DESIGN_PHILOSOPHY.md) for details.
## Tutorials & Examples
@@ -262,59 +214,18 @@ Real-world patterns:
- [`data_pipeline.rs`](examples/data_pipeline.rs) - ETL data processing pipeline
- [`error_handling.rs`](examples/error_handling.rs) - Error propagation and recovery
Run any example: `cargo run --example custom_types`
Run any example: `cargo run --example circuit_breaker`
## Advanced Topics
## Documentation
Detailed documentation on dagx internals and advanced features:
Full API documentation is available at [docs.rs/dagx](https://docs.rs/dagx).
Detailed documentation on dagx's internals and advanced features:
- [**Compile-Time Cycle Prevention**](docs/CYCLE_PREVENTION.md) - How the type system prevents cycles
- [**Design Philosophy**](docs/DESIGN_PHILOSOPHY.md) - Primitives as scheduler, inline fast-path optimization
- [**Tracing Support**](docs/TRACING.md) - Zero-cost observability with the `tracing` crate
- [**Library Comparisons**](docs/COMPARISONS.md) - Detailed comparison with dagrs, async_dag, and others
## Important Limitations
### Tasks Cannot Return Bare Tuples
Tasks cannot return bare tuples as output types. If you need to return multiple values, use one of these workarounds:
**Option 1: Use a struct (recommended)**
```rust
struct UserData {
name: String,
age: i32,
}
struct FetchUser;
#[task]
impl FetchUser {
async fn run(id: &i32) -> UserData {
UserData {
name: "Alice".to_string(),
age: 30,
}
}
}
```
**Option 2: Wrap in Result**
```rust
struct FetchData;
#[task]
impl FetchData {
async fn run(id: &i32) -> Result<(String, i32), String> {
Ok(("Alice".to_string(), 30))
}
}
```
Structs are preferred because they're self-documenting and easier to refactor.
## When to Use dagx
dagx is ideal for:
@@ -346,16 +257,12 @@ xdg-open target/criterion/report/index.html
start target/criterion/report/index.html
```
_Benchmarks run on AMD Ryzen 7 7840U (Zen 4) @ 3.3GHz._
_Benchmarks run on Intel i9-13950HX @ 5.5GHz._
## Code of Conduct
This project follows the [Builder's Code of Conduct](https://builderscode.org).
## Documentation
Full API documentation is available at [docs.rs/dagx](https://docs.rs/dagx).
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+9 -12
View File
@@ -8,7 +8,7 @@ dagx is a task execution library designed for safety and predictability. This do
| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| 0.3.x | :white_check_mark: |
## Security Model
@@ -17,10 +17,10 @@ dagx is a task execution library designed for safety and predictability. This do
- **Compile-time type safety**: Dependencies are validated at compile time, preventing type errors
- **Cycle detection**: Runtime detection of circular dependencies
- **Memory safety**: Rust's ownership system prevents memory corruption
- **Panic isolation**: Panics in any task will be caught and not propagated through the executor
### What dagx Does NOT Provide
- **Panic isolation**: Panics in one task will propagate and may affect other tasks (see Panic Handling below)
- **Resource limits**: No built-in timeout, memory limits, or CPU throttling
- **Sandboxing**: Tasks run with full process privileges
- **Authentication/Authorization**: No access control between tasks
@@ -30,15 +30,14 @@ dagx is a task execution library designed for safety and predictability. This do
### 1. Panic Handling
**Current Behavior**: dagx does NOT isolate panics between tasks. If a task panics:
**Current Behavior**: dagx isolates panics between tasks. If a task panics:
- The panic will propagate through the executor
- Other tasks in the same execution layer may or may not complete (timing-dependent)
- The entire DAG execution will terminate
- The panic will be caught and converted to a `TaskPanicked` error
- Other tasks in the same execution layer will complete
- The entire DAG execution will terminate after layer execution has finished and return **only the first panic**
**Recommendation**:
- Use `std::panic::catch_unwind` in tasks that may panic
- Return `Result<T, E>` from tasks and handle errors explicitly
- Test panic scenarios thoroughly
@@ -249,10 +248,9 @@ impl MonitoredTask {
### Known Limitations
1. **No panic recovery**: Task panics will crash the DAG execution
2. **No resource limits**: Tasks can consume unlimited CPU, memory, or time
3. **No isolation**: All tasks run in the same process with same privileges
4. **Shared executor**: Tasks compete for executor resources
1. **No resource limits**: Tasks can consume unlimited CPU, memory, or time
2. **No isolation**: All tasks run in the same process with same privileges
3. **Shared executor**: Tasks compete for executor resources
### Not Security Features
@@ -302,7 +300,6 @@ Security updates will be:
- [Rust Security Guidelines](https://anssi-fr.github.io/rust-guide/)
- [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/)
- [async-std Security](https://async.rs/security)
- [Tokio Security](https://tokio.rs/tokio/topics/security)
## License
+1 -1
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_deep_chain(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("deep_chain_100_tasks");
let mut group = c.benchmark_group("comparisons/deep_chain_100_tasks");
// dagx implementation
group.bench_function("dagx", |b| {
+2 -2
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_diamond_pattern(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("diamond_pattern");
let mut group = c.benchmark_group("comparisons/diamond_pattern");
// dagx implementation
group.bench_function("dagx", |b| {
@@ -26,7 +26,7 @@ pub fn bench_diamond_pattern(c: &mut Criterion) {
.depends_on(a);
let d = dag
.add_task(task_fn::<(i32, i32), _, _>(|(x, y): (&i32, &i32)| x + y))
.depends_on((&b, &c));
.depends_on((b, c));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+2 -2
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_etl_pipeline(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("etl_pipeline");
let mut group = c.benchmark_group("comparisons/etl_pipeline");
// dagx implementation
group.bench_function("dagx", |b| {
@@ -51,7 +51,7 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
result
},
))
.depends_on((&transform1, &transform2, &transform3));
.depends_on((transform1, transform2, transform3));
// Validate
let validate = dag
+1 -1
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_large_scale(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("large_scale_10k_tasks");
let mut group = c.benchmark_group("comparisons/large_scale_10k_tasks");
group.sample_size(10);
group.measurement_time(std::time::Duration::from_secs(20));
+1 -1
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_linear_pipeline(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("linear_pipeline_5_tasks");
let mut group = c.benchmark_group("comparisons/linear_pipeline_5_tasks");
// dagx implementation
group.bench_function("dagx", |b| {
+1 -1
View File
@@ -6,7 +6,7 @@ use criterion::Criterion;
pub fn bench_wide_fanout(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut group = c.benchmark_group("wide_fanout_100_tasks");
let mut group = c.benchmark_group("comparisons/wide_fanout_100_tasks");
// dagx implementation
group.bench_function("dagx", |b| {
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn bench_diamond(c: &mut Criterion) {
.depends_on(source);
dag.add_task(task_fn::<(i32, i32), _, _>(|(l, r): (&i32, &i32)| l + r))
.depends_on((&left, &right));
.depends_on((left, right));
}
let _output = dag
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn bench_etl_pipeline(c: &mut Criterion) {
)
},
))
.depends_on((&validate, &scores, &names));
.depends_on((validate, scores, names));
let _output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+1 -1
View File
@@ -42,7 +42,7 @@ pub fn bench_mixed_patterns(c: &mut Criterion) {
format!("Matches: {}, Total bytes: {}, Count: {}", a1, a2, a3)
},
))
.depends_on((&analysis1, &analysis2, &analysis3));
.depends_on((analysis1, analysis2, analysis3));
// Stage 4: Fan-out final report to multiple destinations
for i in 0..3 {
+7 -47
View File
@@ -155,9 +155,10 @@ use syn::{parse_macro_input, FnArg, Ident, ImplItem, ItemImpl, Pat, PatType, Ret
/// impl Task<(i32, i32)> for Add {
/// type Output = i32;
///
/// async fn run(&mut self, input: Self::Input) -> Self::Output {
/// let (a, b) = input;
/// Self::run_impl(&a, &b).await
/// async fn run(&mut self, _input: Self::Input) -> Self::Output {
/// let (a, _input) = _input.next();
/// let (b, _input) = _input.next();
/// Self::run_impl(a, b).await
/// }
/// }
///
@@ -168,36 +169,6 @@ use syn::{parse_macro_input, FnArg, Ident, ImplItem, ItemImpl, Pat, PatType, Ret
/// }
/// }
/// ```
///
/// 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<(i32)> for Counter {
/// 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
/// }
/// }
/// ```
#[proc_macro_attribute]
pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
let impl_block = parse_macro_input!(item as ItemImpl);
@@ -273,18 +244,7 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Extract return type
let output_type = match &run_method.sig.output {
ReturnType::Default => syn::parse_quote!(()),
ReturnType::Type(_, ty) => {
if let Type::Tuple(_tuple) = &**ty {
return syn::Error::new_spanned(
ty,
"Returning bare tuples from task functions is currently not supported.\n\n\
Please wrap this in a newtype or struct.",
)
.into_compile_error()
.into();
}
ty.clone()
}
ReturnType::Type(_, ty) => ty.clone(),
};
// Build Input type based on parameter count
@@ -299,7 +259,7 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
let ident = Ident::new(&format!("__dagx_extract_{}", ident), Span::mixed_site());
(
quote! {
let (#ident, input) = input.next();
let (#ident, _input) = _input.next();
},
ident,
)
@@ -333,7 +293,7 @@ pub fn task(_attr: TokenStream, item: TokenStream) -> TokenStream {
impl ::dagx::Task<#input_type> for #struct_name {
type Output = #output_type;
async fn run(mut self, mut input: ::dagx::TaskInput<'_, #input_type>) -> Self::Output {
async fn run(mut self, mut _input: ::dagx::TaskInput<'_, #input_type>) -> Self::Output {
#param_destructure
#run_call
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13 -58
View File
@@ -67,35 +67,17 @@ graph.update_dependency(y, sum, 1)?;
## Quick Comparison
| Project | License | Runtime | Type Safety | API Complexity | Performance vs dagx | Key Features |
| ------------------------------------------------------------- | -------------- | ------------------- | --------------------- | -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
| ------------------------------------------------------------- | -------------- | ------------------- | --------------------- | -------------- | ------------------------- | ------------------------------------------------------------------------------------ |
| **dagx** | MIT | Any async runtime | Compile-time | Simple | Baseline (see benchmarks) | Primitives-as-scheduler, inline fast-path, automatic Arc wrapping, up to 8 deps/task |
| [**dagrs**](https://github.com/dagrs-dev/dagrs) (v0.5) | MIT/Apache-2.0 | Creates own runtime | Runtime (async_trait) | Complex | 1.04-129x slower (see below) | Flow-based Programming, cyclic graphs, loops, conditional nodes, YAML config |
| [**dagrs**](https://github.com/dagrs-dev/dagrs) (v0.5) | MIT/Apache-2.0 | Creates own runtime | Runtime (async_trait) | Complex | 1-100x slower (see below) | Flow-based Programming, cyclic graphs, loops, conditional nodes, YAML config |
| [**async_dag**](https://github.com/chubei-oppen/async_dag) | MIT | Any async runtime | Compile-time | Medium | No benchmarks | Slot-based dependencies, Graph/TryGraph modes, maximum parallelism |
| [**dag-flow**](https://github.com/makisevon/dag-flow) | MIT/Apache-2.0 | Any async runtime | Runtime (HashMap) | Complex | No benchmarks | Experimental, all tasks run simultaneously, weak dependencies |
| [**RenovZ/dag-runner**](https://github.com/RenovZ/dag-runner) | MIT | Tokio only | Unclear | Simple | No benchmarks | Edge-based API, cycle detection, stops on first error |
| [**tasksitter**](https://github.com/lionkor/tasksitter) | Unspecified | Unclear | Unclear | Medium | No benchmarks | Cyclic graphs, dynamic runtime modification, pause/resume |
_GitHub stars (as of 2025): dagrs (449), async_dag (25), dag-flow (2), others (0-2)_
**Performance**: As noted in the README, dagx is **1-100x faster** than dagrs across all benchmark patterns. The inline fast-path optimization eliminates spawning overhead for sequential workloads while maintaining excellent parallel performance. Most real-world DAGs mix sequential and parallel patterns. dagx automatically optimizes for both, performing up to 2 orders of magnitude faster than dagrs regardless of workload shape.
## Performance Benchmarks vs dagrs
Direct comparison benchmarks (lower is better):
| Workload | Tasks | dagx | dagrs | Speedup |
| ----------------- | ------ | ------- | ------- | ------------------ |
| Sequential chain | 5 | 3.0 µs | 385 µs | **129x faster** 🚀 |
| Sequential chain | 100 | 79 µs | 703 µs | **8.9x faster** |
| Diamond pattern | 4 | 11 µs | 387 µs | **34x faster** |
| Fan-out (1→100) | 101 | 155 µs | 595 µs | **3.85x faster** |
| Independent tasks | 10,000 | 12.7 ms | 13.3 ms | **1.04x faster** |
**Summary**: dagx is **1.04-129x faster** than dagrs across all benchmark patterns. The inline fast-path optimization eliminates spawning overhead for sequential workloads while maintaining excellent parallel performance.
**How does dagx achieve this?** The inline fast-path detects single-task layers (common in sequential chains) and executes them directly without spawning overhead. For parallel workloads, tasks still spawn to maximize concurrency. This adaptive execution strategy combines the best of both worlds: sub-microsecond overhead for sequential work, true parallelism for concurrent work.
**Key insight**: Most real-world DAGs mix sequential and parallel patterns. dagx automatically optimizes for both, delivering 1.04-129x better performance than dagrs regardless of workload shape.
_Benchmarks run on AMD Ryzen 7 7840U (Zen 4) @ 3.3GHz. Run `cargo bench` to test on your hardware._
_Benchmarks run on Intel i9-13950HX @ 5.5GHz. Run `cargo bench` to test on your hardware._
## Detailed Comparison
@@ -107,7 +89,6 @@ _Benchmarks run on AMD Ryzen 7 7840U (Zen 4) @ 3.3GHz. Run `cargo bench` to test
- Most mature (449 GitHub stars, active community)
- Rich feature set: Flow-based Programming, cyclic graphs, loops, conditional nodes
- YAML configuration support for declarative workflows
- Designed for complex orchestration patterns
**Trade-offs**:
@@ -115,29 +96,7 @@ _Benchmarks run on AMD Ryzen 7 7840U (Zen 4) @ 3.3GHz. Run `cargo bench` to test
- Creates own Tokio runtime internally (not runtime-agnostic, cannot be nested)
- More complex API: `Action` trait, `InChannels`/`OutChannels`, `NodeTable`, `Content` wrappers, manual node ID tracking
- Uses `async_trait` for type erasure (runtime overhead)
- **1.04-129x slower than dagx** across all benchmark patterns (see comparison benchmarks)
**API Style** (v0.5):
```rust
// Define action with channels for communication
#[async_trait]
impl Action for MyTask {
async fn run(&self, input: &mut InChannels,
output: &mut OutChannels, env: Arc<EnvVar>) -> Output {
let inputs: Vec<T> = input.map(|c| ...).await; // Manually extract
output.broadcast(Content::new(result)).await; // Manually wrap
Output::Out(Some(Content::new(result)))
}
}
let mut table = NodeTable::new();
let node = DefaultNode::with_action("name".into(), MyTask, &mut table);
let node_id = node.id(); // Must capture ID before moving
graph.add_node(node);
graph.add_edge(source_id, vec![target_id]);
graph.set_env(EnvVar::new(table)); graph.start().unwrap();
```
- **Slower than dagx** across all benchmark patterns (see comparison benchmarks)
### async_dag (Clean Type Safety)
@@ -154,8 +113,7 @@ graph.set_env(EnvVar::new(table)); graph.start().unwrap();
- Medium API complexity: slot-based dependency management
- Must manually specify slot indices (0, 1, etc.) when connecting tasks
- Less mature (25 stars)
- No performance benchmarks
- Less mature
**API Style**:
@@ -188,7 +146,7 @@ graph.run().await;
- Runtime type safety via `HashMap<String, Input>`
- Complex API: implement `Task` trait with `id()`, `dependencies()`, `run()`
- Named dependencies (string-based lookup)
- Very early stage (2 stars)
- Very early stage
**API Style**:
@@ -219,7 +177,7 @@ impl Task<String, Bytes> for MyTask {
- Requires Tokio runtime
- Manual channel setup for task communication
- Type safety model unclear
- Very early stage (0 stars)
- Very early stage
**API Style**:
@@ -245,29 +203,26 @@ dag.run().await?;
- Limited documentation
- Runtime and type safety model unclear
- Very early stage (0 stars)
- Very early stage
## When to Choose dagx
Choose dagx when you value:
- **Performance**: 1.04-129x faster than dagrs across all workload patterns (see benchmarks)
- **Runtime flexibility**: Works with Tokio, async-std, smol, or any async runtime
- **Compile-time safety**: Full type safety with no runtime type errors in the public API
- **Minimal overhead**: ~790 ns per task (sequential), ~1.3 µs per task (parallel)
- **Performance**: Faster than dagrs across all workload patterns (see benchmarks)
- **Runtime flexibility**: Works with Tokio, smol, or any async runtime
- **Compile-time safety**: Full type safety with no runtime type errors possible
- **Simple, ergonomic API**: `#[task]` macro, `add_task()`, `depends_on()` - that's it
- **Automatic optimizations**: Arc wrapping, inline execution, adaptive spawning - all transparent
- **Predictable performance**: Linear scaling, no hidden complexity, consistent sub-µs overhead
dagx is **not** the right choice if you need:
- Cyclic graphs or dynamic flow control (loops, conditions) → Consider **dagrs** or **tasksitter**
- More than 8 dependencies per task → Consider **dagrs** or **async_dag**
- YAML-based configuration → Consider **dagrs**
## When to Consider Alternatives
- **Choose dagrs** if you need advanced flow control (loops, conditionals, cyclic graphs), YAML configuration, or are already committed to Tokio and want a mature, feature-rich solution
- **Choose dagrs** if you need advanced flow control (loops, conditionals, cyclic graphs), or are already committed to Tokio and want a mature, feature-rich solution
- **Choose async_dag** if you want compile-time type safety with runtime flexibility and the slot-based API appeals to you
- **Choose dag-flow** if you're building experimental projects and the all-tasks-run-simultaneously model fits your use case
- **Choose RenovZ/dag-runner** if you need the simplest possible edge-based API and are already using Tokio
+5 -7
View File
@@ -26,12 +26,12 @@ let b_builder = dag.add_task(TaskB);
// To make A depend on B, we need B as a TaskHandle.
// But calling depends_on() consumes the builder!
let b_handle = b_builder.depends_on(&some_source);
let b_handle = b_builder.depends_on(some_source);
// Now we can't make B depend on A because b_builder was moved!
// This won't compile:
// a_builder.depends_on(&b_handle); // ✓ OK: A→B
// b_handle.depends_on(&a_handle); // ❌ ERROR: TaskHandle has no depends_on() method!
// a_builder.depends_on(b_handle); // ✓ OK: A→B
// b_handle.depends_on(a_handle); // ❌ ERROR: TaskHandle has no depends_on() method!
```
## How It Works: The Type-State Pattern
@@ -58,18 +58,16 @@ let b_builder = dag.add_task(TaskB); // TaskBuilder
let b_handle = b_builder.into(); // Convert B to TaskHandle (consumes builder)
// To wire B→A, we need A as a TaskHandle
let a_handle = a_builder.depends_on(&b_handle); // A now depends on B
let a_handle = a_builder.depends_on(b_handle); // A now depends on B
// Now if we try to complete the cycle:
// a_handle.depends_on(&...); // ❌ ERROR: no method named `depends_on` found
// a_handle.depends_on(...); // ❌ ERROR: no method named `depends_on` found
```
**The type system enforces strict topological ordering!** You can't create a `TaskHandle` until all its dependencies exist as handles, which prevents cycles.
## Proof & Examples
See [`src/cycle_prevention.rs`](../src/cycle_prevention.rs) for comprehensive documentation with `compile_fail` tests proving various cycle attempts don't compile.
See [`tests/cycle_prevention.rs`](../tests/cycle_prevention.rs) for runtime tests demonstrating the type-state pattern in action.
## Benefits
+9 -65
View File
@@ -1,16 +1,10 @@
# Design Philosophy: Primitives as Scheduler
dagx takes an unconventional approach to task orchestration: **there is no traditional scheduler**. Instead of complex scheduling logic managing when tasks run, the entire system is built on communication and synchronization primitives that do the scheduling themselves.
## How It Works
Traditional DAG executors contain substantial scheduling code—algorithms that track task states, manage dependencies, coordinate execution order, and handle synchronization. This code is complex, error-prone, and difficult to verify.
dagx eliminates this entirely:
1. **Wire up tasks with primitives**: During DAG construction, tasks are organized into topological layers based on their dependencies
2. **Start everything simultaneously**: When you call `run()`, all tasks spawn at once—there's no scheduler deciding when each task should start
3. **Let the primitives handle coordination**: Layers naturally enforce execution order—tasks wait on their dependencies until . No custom orchestration needed.
2. **Start everything simultaneously**: When you call `run()`, all tasks in the first layer spawn at once—there's no scheduler deciding when each task should start
3. **Let the primitives handle coordination**: Layers naturally enforce execution order. No custom orchestration needed.
4. **Runtime joins on completion**: The runtime simply spawns all tasks and waits for them to finish. That's it.
## The Implementation
@@ -18,54 +12,14 @@ dagx eliminates this entirely:
Under the hood, dagx uses:
- **Ownership model**: Tasks take ownership (`self`) and are consumed during execution - no Mutex needed for task state
- **Direct data flow**: Outputs flow from producer(s) to consumer(s) through a shared hash map
- **Direct data flow**: Arc-wrapped outputs flow from producer(s) to consumer(s) through a store in the runner
- **Fast-path optimization**: Single-task layers execute inline without spawning overhead
The core execution logic in `run()` is remarkably simple:
```rust
let outputs = HashMap::new();
// Execute layer by layer
for layer in layers {
if layer.len() == 1 {
// Fast path: Execute inline, no spawning overhead
// IMPORTANT: Panic handling is required here!
let result = task.execute().catch_unwind().await;
// Convert panic to error to match spawned task behavior
} else {
// Parallel path: Spawn all tasks in layer
let tasks = layers
.map(async {
let input = get_inputs(map); // Deps are guaranteed to be in the map
let output = task.run(input); // Consumes task
output
})
.collect::<FuturesUnordered<_>>(); // Run all tasks in parallel
while let Some(out) = tasks.next().await {
outputs.insert(out.task_id, out.output); // Make result available for dependents
}
}
// Wait for layer completion
}
// FuturesUnordered coordinates task execution - no custom scheduler needed
```
## Inline Execution Fast-Path
**Performance optimization for sequential workloads**: When a layer contains only a single task (common in deep chains and linear pipelines), dagx executes it inline rather than spawning it. This eliminates spawning overhead and context switching, resulting in a 10-100x performance improvements for sequential patterns.
**Panic handling guarantee**: To maintain behavioral consistency between inline and spawned execution, panics in inline tasks are caught using `FutureExt::catch_unwind()` and converted to errors. This matches the behavior of all major async runtimes (Tokio, async-std, smol, embassy-rs), which catch panics in spawned tasks and convert them to `JoinError` or equivalent.
**Why this matters**:
- **Spawned tasks** (layer.len() > 1): Runtime catches panics automatically → becomes error
- **Inline tasks** (layer.len() == 1): We catch panics manually → becomes error
- **Result**: Identical behavior regardless of execution path
This ensures your code behaves the same whether a task runs inline or spawned, making dagx's optimizations transparent and predictable.
**Panic handling guarantee**: To maintain behavioral consistency between runtimes, as well as inline vs. spawned tasks, panics in tasks are caught using `FutureExt::catch_unwind()` and converted to errors. This ensures your code behaves the same whether a task runs inline or spawned, making dagx's optimizations transparent and predictable.
## Benefits
@@ -75,7 +29,7 @@ This ensures your code behaves the same whether a task runs inline or spawned, m
**Bug resistance**: Fewer moving parts means fewer places for bugs to hide. The type system enforces correct wiring at compile time. The ownership model prevents data races. What's left to break?
**Performance**: Near zero-overhead. No Mutex locks during execution. Arc reference counting for efficient fanout (atomic operations, not locks). Tasks start as soon as their dependencies complete - maximum parallelism.
**Performance**: Near zero-overhead. No locks during execution. Arc reference counting for efficient fanout (atomic operations, not locks). Tasks start as soon as their dependencies complete - maximum parallelism.
**Auditability**: Want to verify correctness? Check the dependency wiring, verify tasks await their inputs, done. No need to trace through complex state machine transitions or wake-up cascades.
@@ -87,24 +41,14 @@ This is dagx's core philosophy: leverage the type system for correctness, use pr
## Measured Overhead
How much overhead does this approach actually add? Benchmarks on an AMD 7840U (Zen 4 laptop CPU) show:
How much overhead does this approach actually add? Benchmarks on an Intel i9-13950HX show:
**DAG Construction**:
- Empty DAG creation: **~20 nanoseconds**
- Adding tasks: **~100 nanoseconds per task**
- Building a 10,000-task DAG: **~1.0 milliseconds** (100 ns/task)
**Execution Overhead** (framework coordination, excluding actual task work):
- Sequential workloads: **~790 nanoseconds per task** (inline execution fast-path)
- Parallel workloads: **~1.3 microseconds per task**
- 100-task sequential chain: **~79 microseconds total**
- 100 independent tasks: **~122 microseconds total**
- 10,000 independent tasks: **~12.7 milliseconds total**
- Empty DAG creation: **~7 nanoseconds**
- Adding tasks: **~5 nanoseconds per task**
- Building a 10,000-task DAG: **~865 microseconds** (100 ns/task)
**Scaling**: Sub-microsecond per-task overhead across all workload patterns. Linear scaling verified to 10k+ tasks.
**Comparison**: dagx is **1.04-129x faster** than dagrs (v0.5) across all benchmark patterns (see [comparisons](COMPARISONS.md)).
The primitives-as-scheduler approach with inline fast-path optimization delivers exceptional performance: coordination overhead is sub-microsecond per task, and for real-world workloads where tasks do meaningful work (I/O, computation, etc.), framework overhead is negligible—typically well under 1% of total execution time.
-53
View File
@@ -1,53 +0,0 @@
# Tracing Support
dagx provides optional observability through the `tracing` crate with **zero runtime overhead when disabled**. The tracing instrumentation is conditionally compiled using feature flags, meaning when the feature is off, the logging code doesn't exist in the compiled binary at all.
## Enabling Tracing
```toml
[dependencies]
dagx = { version = "0.3", features = ["tracing"] }
tracing-subscriber = "0.3"
```
## Usage Example
```rust
use tracing_subscriber::{fmt, EnvFilter};
// Initialize tracing subscriber
fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("dagx=info"))
)
.init();
// Your DAG code here - tracing will log execution details
```
## Log Levels
- **INFO**: DAG execution start/completion
- **DEBUG**: Task additions, dependency wiring, layer computation
- **TRACE**: Individual task execution (inline vs spawned), detailed execution flow
- **ERROR**: Task panics, concurrent execution attempts
Run with different levels:
```bash
RUST_LOG=dagx=info cargo run # High-level execution info
RUST_LOG=dagx=debug cargo run # Task and layer details
RUST_LOG=dagx=trace cargo run # All execution details
```
## Zero-Cost Guarantee
When the `tracing` feature is disabled (the default), there is **literally 0ns overhead**:
- Logging code is removed at compile time via `#[cfg(feature = "tracing")]`
- No branches, no function calls, nothing
- The `tracing` crate isn't even linked
- Benchmarks verify identical performance with/without the feature
This follows the same pattern used by Tokio, Hyper, and other performance-critical Rust async libraries.
+1 -1
View File
@@ -93,7 +93,7 @@ async fn main() {
.add_task(Add {
label: "Sum".to_string(),
})
.depends_on((&x, &y)); // Pass handles as a tuple
.depends_on((x, y)); // Pass handles as a tuple
// sum is now TaskHandle<i32>
// Step 6: Execute the DAG
+1 -1
View File
@@ -29,7 +29,7 @@
//! ## Key Concepts
//! - **Fan-in**: Multiple tasks' outputs are consumed by a single downstream task
//! - **Aggregation**: Combining multiple values into a single result
//! - **Parameter order**: The order in `.depends_on((&a, &b, &c))` must match
//! - **Parameter order**: The order in `.depends_on((a, b, c))` must match
//! the function signature `fn run(a: &T1, b: &T2, c: &T3)`
//!
//! ## Use Cases
+9 -9
View File
@@ -52,7 +52,7 @@
//! This proves dagx runs tasks on multiple CPU cores simultaneously.
//! ```
use dagx::{task, DagResult, DagRunner, Task, TaskInput};
use dagx::{task, DagResult, DagRunner};
use std::time::{Duration, Instant};
use tokio::time::sleep;
@@ -149,15 +149,15 @@ async fn main() -> DagResult<()> {
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);
let mut task1 = ComputeSum::new(1, 251, WORK_DELAY);
let mut task2 = ComputeSum::new(251, 501, WORK_DELAY);
let mut task3 = ComputeSum::new(501, 751, WORK_DELAY);
let mut task4 = ComputeSum::new(751, 1001, WORK_DELAY);
let r1 = task1.run(TaskInput::empty()).await;
let r2 = task2.run(TaskInput::empty()).await;
let r3 = task3.run(TaskInput::empty()).await;
let r4 = task4.run(TaskInput::empty()).await;
let r1 = task1.run_impl().await;
let r2 = task2.run_impl().await;
let r3 = task3.run_impl().await;
let r4 = task4.run_impl().await;
let result = r1 + r2 + r3 + r4;
let elapsed = start.elapsed();
+4 -6
View File
@@ -203,20 +203,18 @@ async fn main() {
// Stage 3: Statistical analysis (parallel operations on transformed data)
let sum = dag
.add_task(ComputeSum)
.depends_on((&transformed_1, &transformed_2, &transformed_3));
.depends_on((transformed_1, transformed_2, transformed_3));
let product =
dag.add_task(ComputeProduct)
.depends_on((&transformed_1, &transformed_2, &transformed_3));
.depends_on((transformed_1, transformed_2, transformed_3));
let max = dag
.add_task(FindMax)
.depends_on((&transformed_1, &transformed_2, &transformed_3));
.depends_on((transformed_1, transformed_2, transformed_3));
// Stage 4: Final report
let report = dag
.add_task(GenerateReport)
.depends_on((&sum, &product, &max));
let report = dag.add_task(GenerateReport).depends_on((sum, product, max));
// Execute the pipeline
println!("Executing pipeline...\n");
+11 -38
View File
@@ -88,7 +88,7 @@ check_pass "Zero clippy warnings"
echo ""
echo "→ Checking for TODO/FIXME/XXX/HACK in source..."
TODO_COUNT=$(grep -r "TODO\|FIXME\|XXX\|HACK" src/ dagx-macros/src/ 2>/dev/null | wc -l)
TODO_COUNT=$(grep -r "TODO\|FIXME\|XXX\|HACK" src/ dagx-macros/src/ dagx-test/src/ 2>/dev/null | wc -l)
if [ "$TODO_COUNT" -gt 0 ]; then
check_warn "Found $TODO_COUNT TODO/FIXME/HACK comments in source"
else
@@ -209,6 +209,7 @@ echo ""
echo "→ Verifying LICENSE..."
[ -f LICENSE ] || check_fail "LICENSE missing"
[ -f dagx-macros/LICENSE ] || check_fail "dagx-macros/LICENSE missing"
[ -f dagx-test/LICENSE ] || check_fail "dagx-test/LICENSE missing"
check_pass "LICENSE files present in both crates"
# =============================================================================
@@ -259,45 +260,13 @@ echo "→ Verifying benchmarks compile..."
cargo bench --no-run || check_fail "Benchmarks failed to compile"
check_pass "Benchmarks compile successfully"
# =============================================================================
# CARGO.TOML METADATA VERIFICATION
# =============================================================================
section "CARGO.TOML METADATA"
echo "→ Checking dagx Cargo.toml..."
grep -q 'name = "dagx"' Cargo.toml || check_fail "Package name incorrect"
grep -q "version = \"$VERSION\"" Cargo.toml || check_fail "Version not $VERSION"
grep -q 'edition = "2021"' Cargo.toml || check_fail "Edition not 2021"
grep -q 'license = "MIT"' Cargo.toml || check_fail "License not MIT"
grep -q 'description = "A minimal, type-safe' Cargo.toml || check_fail "Description missing"
grep -q 'repository = "https://github.com/swaits/dagx"' Cargo.toml || check_fail "Repository URL incorrect"
grep -q 'homepage = "https://github.com/swaits/dagx"' Cargo.toml || check_fail "Homepage URL incorrect"
grep -q 'documentation = "https://docs.rs/dagx"' Cargo.toml || check_fail "Documentation URL incorrect"
grep -q 'readme = "README.md"' Cargo.toml || check_fail "README not specified"
grep -q 'keywords = \["dag"' Cargo.toml || check_fail "Keywords missing"
grep -q 'categories = \["asynchronous"' Cargo.toml || check_fail "Categories missing"
check_pass "dagx Cargo.toml metadata complete and correct"
echo ""
echo "→ Checking dagx-macros Cargo.toml..."
grep -q 'name = "dagx-macros"' dagx-macros/Cargo.toml || check_fail "dagx-macros name incorrect"
grep -q "version = \"$VERSION\"" dagx-macros/Cargo.toml || check_fail "dagx-macros version not $VERSION"
grep -q 'edition = "2021"' dagx-macros/Cargo.toml || check_fail "dagx-macros edition not 2021"
grep -q 'license = "MIT"' dagx-macros/Cargo.toml || check_fail "dagx-macros license not MIT"
grep -q 'description = "Procedural macros for dagx"' dagx-macros/Cargo.toml || check_fail "dagx-macros description missing"
grep -q 'repository = "https://github.com/swaits/dagx"' dagx-macros/Cargo.toml || check_fail "dagx-macros repository URL incorrect"
grep -q 'homepage = "https://github.com/swaits/dagx"' dagx-macros/Cargo.toml || check_fail "dagx-macros homepage URL incorrect"
grep -q 'documentation = "https://docs.rs/dagx-macros"' dagx-macros/Cargo.toml || check_fail "dagx-macros documentation URL incorrect"
grep -q 'readme = "README.md"' dagx-macros/Cargo.toml || check_fail "dagx-macros README not specified"
check_pass "dagx-macros Cargo.toml metadata complete and correct"
echo ""
echo "→ Verifying version consistency..."
DAGX_VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2)
MACROS_VERSION=$(grep '^version = ' dagx-macros/Cargo.toml | head -1 | cut -d'"' -f2)
if [ "$DAGX_VERSION" != "$MACROS_VERSION" ]; then
check_fail "Version mismatch: dagx=$DAGX_VERSION, dagx-macros=$MACROS_VERSION"
TEST_VERSION=$(grep '^version = ' dagx-test/Cargo.toml | head -1 | cut -d'"' -f2)
if [ "$DAGX_VERSION" != "$MACROS_VERSION" ] || [ "$DAGX_VERSION" != "$TEST_VERSION" ]; then
check_fail "Version mismatch: dagx=$DAGX_VERSION, dagx-macros=$MACROS_VERSION, dagx-test=$TEST_VERSION"
else
check_pass "Versions match: both are $DAGX_VERSION"
fi
@@ -344,7 +313,7 @@ fi
section "SECURITY CHECKS"
echo "→ Checking for sensitive data..."
if grep -r "api[_-]key\|secret\|password\|token" src/ dagx-macros/src/ 2>/dev/null | grep -v "^Binary" | grep -v "token for a node" | grep -q .; then
if grep -r "api[_-]key\|secret\|password\|token" src/ dagx-macros/src/ dagx-test/src 2>/dev/null | grep -v "^Binary" | grep -v "token for a node" | grep -q .; then
check_fail "Potential sensitive data found in source"
else
check_pass "No sensitive data found"
@@ -387,7 +356,11 @@ echo "4. Then publish dagx:"
echo " cd .."
echo " cargo publish"
echo ""
echo "5. Push changes and bookmark:"
echo "5. Then publish dagx-test:"
echo " cd dagx-test"
echo " cargo publish"
echo ""
echo "6. Push changes and bookmark:"
echo " jj git push"
echo ""
echo -e "${BLUE}Note: dagx-macros MUST be published before dagx${NC}"
+11 -29
View File
@@ -1,7 +1,6 @@
//! Task builder with type-state pattern for dependency tracking.
//!
//! TaskBuilder uses compile-time type checking to ensure dependencies are wired correctly.
//! The type parameter tracks whether dependencies have been specified yet.
use std::marker::PhantomData;
@@ -14,10 +13,8 @@ use tracing::debug;
/// Node builder that tracks dependency completion via type state.
///
/// A `TaskBuilder<Tk, Deps>` is returned from [`DagRunner::add_task`] and uses the type-state
/// pattern to ensure dependencies are wired correctly at compile time:
/// - `TaskBuilder<Tk, Pending>`: No dependencies specified yet
/// - `TaskBuilder<Tk, Tk::Input>`: Dependencies complete; acts as a [`TaskHandle<Tk::Output>`]
/// A `TaskBuilder<Tk, Input>` is returned from [`DagRunner::add_task`] for tasks with non-unit inputs.
/// After calling [`TaskBuilder::depends_on`], it becomes a [`TaskHandle`].
///
/// # Examples
///
@@ -47,13 +44,11 @@ use tracing::debug;
/// # async {
/// let mut dag = DagRunner::new();
///
/// // Construct with initial value (tuple struct)
/// let a = dag.add_task(Constant(10));
/// // a is TaskBuilder<_, Pending> since no dependencies needed (Input = ())
/// // a is TaskHandle since no dependencies needed (Input = ())
///
/// // Construct with ::new() pattern
/// let b = dag.add_task(Multiplier::new(2));
/// // b is TaskBuilder<_, Pending> until we call depends_on()
/// // b is TaskBuilder until we call depends_on()
///
/// let b = b.depends_on(a);
/// // Now b is a TaskHandle<i32>
@@ -81,9 +76,8 @@ where
/// Provide all dependencies exactly once as a tuple.
///
/// The dependencies must match the task's `Input` type exactly:
/// - `Input = ()`: Do **not** call `depends_on`
/// - `Input = T`: Pass `&TaskHandle` or `(&TaskHandle,)`
/// - `Input = (A, B, ...)`: Pass `(&TaskHandle<A>, &TaskHandle<B>, ...)`
/// - `Input = A`: Pass `TaskHandle<A>`
/// - `Input = (A, B, ...)`: Pass `(TaskHandle<A>, TaskHandle<B>, ...)`
///
/// The order of dependencies in the tuple must match the order in `Input`.
///
@@ -119,14 +113,14 @@ where
/// # async {
/// let mut dag = DagRunner::new();
///
/// let x = dag.add_task(Value(2)).into();
/// let x = dag.add_task(Value(2));
/// let y = dag.add_task(Value(3));
///
/// // Single dependency
/// let double = dag.add_task(Scale(2)).depends_on(&x);
/// let double = dag.add_task(Scale(2)).depends_on(x);
///
/// // Multiple dependencies: tuple form
/// let sum = dag.add_task(Add).depends_on((&x, y));
/// let sum = dag.add_task(Add).depends_on((x, y));
///
///let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
/// # };
@@ -177,8 +171,6 @@ pub struct NodeId(pub u32);
/// 1. Pass it to [`crate::TaskBuilder::depends_on`] to wire up dependencies
/// 2. Use it with [`crate::DagOutput::get`] to retrieve the output after [`crate::DagRunner::run`]
///
/// Handles are cheap to clone and copy.
///
/// # Examples
///
/// ```no_run
@@ -212,19 +204,11 @@ impl<T> Clone for TaskHandle<T> {
impl<T> Copy for TaskHandle<T> {}
// TaskHandle can be converted from &TaskHandle (for .get() calls)
impl<T> From<&TaskHandle<T>> for TaskHandle<T> {
fn from(handle: &TaskHandle<T>) -> Self {
*handle
}
}
/// Takes a task and converts it to either a TaskBuilder or a TaskHandle,
/// depending on whether it has inputs or not.
///
/// This is useful to enforce at compile time that a task with unit input never has
/// depends_on called, and that it can be used directly as a dependency
/// without converting it manually to a TaskHandle.
/// This is useful to enforce at compile time that a TaskBuilder is never created for task with unit input,
/// and that it can be used directly as a dependency without converting it manually to a TaskHandle.
pub trait TaskWire<Input>: Task<Input> + Sync + 'static
where
Input: Send + Sync + 'static,
@@ -269,8 +253,6 @@ macro_rules! impl_wire_tuple {
};
}
// Generate DepsTuple implementations for tuples of size 2-8.
// Supporting up to 8 elements covers the vast majority of use cases.
impl_wire_tuple!(T1);
impl_wire_tuple!(T1, T2);
impl_wire_tuple!(T1, T2, T3);
+18 -37
View File
@@ -1,11 +1,7 @@
//! Dependency tuple trait and implementations.
//!
//! This module defines the DepsTuple trait that allows specifying dependencies in
//! various forms (handles, builders, tuples). It uses macro-generated implementations
//! for different tuple sizes.
//!
//! This trait is internal and not meant for external implementation. Users should only
//! use the provided implementations.
//! various combinations of handles for tuples of different sizes.
use crate::builder::{NodeId, TaskHandle};
@@ -21,60 +17,45 @@ impl DepsTuple<()> for () {
}
// Implementation for single dependency (all forms)
impl<T, O> DepsTuple<(O,)> for T
where
T: Into<TaskHandle<O>>,
{
impl<O> DepsTuple<(O,)> for TaskHandle<O> {
fn to_node_ids(self) -> Vec<NodeId> {
vec![self.into().id]
}
}
impl<T, O> DepsTuple<O> for (T,)
where
T: Into<TaskHandle<O>>,
{
fn to_node_ids(self) -> Vec<NodeId> {
vec![self.0.into().id]
vec![self.id]
}
}
/// Macro to implement DepsTuple for different tuple sizes.
///
/// This macro exists because Rust lacks variadic generics - we need separate implementations
/// for each tuple size.
///
/// This allows flexible dependency specification:
/// ```ignore
/// // Using handles
/// task.depends_on((&handle_a, &handle_b))
/// task.depends_on((handle_a, handle_b))
///
/// // Using builders directly
/// task.depends_on((&dag.add_task(TaskA), &dag.add_task(TaskB)))
/// task.depends_on((dag.add_task(TaskA), dag.add_task(TaskB)))
/// ```
macro_rules! impl_deps_tuple {
($($T:ident : $O:ident),+) => {
// For tuples of any combination of &TaskHandle, TaskHandle, and TaskBuilder
impl<$($T: Into<TaskHandle<$O>>, $O),+> DepsTuple<($($O,)+)> for ($($T,)+)
($($O:ident),+) => {
impl<$($O),+> DepsTuple<($($O,)+)> for ($(TaskHandle<$O>,)+)
{
#[allow(non_snake_case)]
fn to_node_ids(self) -> Vec<NodeId> {
let ($($T,)+) = self;
vec![$($T.into().id,)+]
let ($($O,)+) = self;
vec![$($O.id,)+]
}
}
};
}
// Generate DepsTuple implementations for tuples of size 2-8.
// Generate DepsTuple implementations for tuples of size 1-8.
// Supporting up to 8 elements covers the vast majority of use cases.
impl_deps_tuple!(T1:O1, T2:O2);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6, T7:O7);
impl_deps_tuple!(T1:O1, T2:O2, T3:O3, T4:O4, T5:O5, T6:O6, T7:O7, T8:O8);
impl_deps_tuple!(O1);
impl_deps_tuple!(O1, O2);
impl_deps_tuple!(O1, O2, O3);
impl_deps_tuple!(O1, O2, O3, O4);
impl_deps_tuple!(O1, O2, O3, O4, O5);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6, O7);
impl_deps_tuple!(O1, O2, O3, O4, O5, O6, O7, O8);
#[cfg(test)]
mod tests;
+6 -28
View File
@@ -57,7 +57,7 @@ fn test_deps_tuple_single_handle_tuple_ref() {
id: NodeId(7),
_phantom: std::marker::PhantomData,
};
let node_ids = (&handle,).to_node_ids();
let node_ids = (handle,).to_node_ids();
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(7));
}
@@ -114,7 +114,7 @@ fn test_deps_tuple_multiple_handles() {
};
// Test 2-tuple
let node_ids = (&handle1, &handle2).to_node_ids();
let node_ids = (handle1, handle2).to_node_ids();
assert_eq!(node_ids.len(), 2);
assert_eq!(node_ids[0], NodeId(1));
assert_eq!(node_ids[1], NodeId(2));
@@ -124,7 +124,7 @@ fn test_deps_tuple_multiple_handles() {
id: NodeId(3),
_phantom: std::marker::PhantomData,
};
let node_ids = (&handle1, &handle2, &handle3).to_node_ids();
let node_ids = (handle1, handle2, handle3).to_node_ids();
assert_eq!(node_ids.len(), 3);
assert_eq!(node_ids[0], NodeId(1));
assert_eq!(node_ids[1], NodeId(2));
@@ -162,7 +162,7 @@ fn test_deps_tuple_large_tuples() {
}
// Test 4-tuple
let node_ids = (&handles[0], &handles[1], &handles[2], &handles[3]).to_node_ids();
let node_ids = (handles[0], handles[1], handles[2], handles[3]).to_node_ids();
assert_eq!(node_ids.len(), 4);
for (i, node_id) in node_ids.iter().enumerate().take(4) {
assert_eq!(*node_id, NodeId(i as u32));
@@ -170,14 +170,8 @@ fn test_deps_tuple_large_tuples() {
// Test 8-tuple
let node_ids = (
&handles[0],
&handles[1],
&handles[2],
&handles[3],
&handles[4],
&handles[5],
&handles[6],
&handles[7],
handles[0], handles[1], handles[2], handles[3], handles[4], handles[5], handles[6],
handles[7],
)
.to_node_ids();
assert_eq!(node_ids.len(), 8);
@@ -185,19 +179,3 @@ fn test_deps_tuple_large_tuples() {
assert_eq!(*node_id, NodeId(i as u32));
}
}
#[test]
fn test_deps_handle_ref_explicit() {
// Test explicit &TaskHandle DepsTuple impl (line 27-28 in deps.rs)
let handle: TaskHandle<i32> = TaskHandle {
id: NodeId(77),
_phantom: std::marker::PhantomData,
};
// Create a reference and explicitly call to_node_ids
let handle_ref = &handle;
let node_ids = DepsTuple::<(i32,)>::to_node_ids(handle_ref);
assert_eq!(node_ids.len(), 1);
assert_eq!(node_ids[0], NodeId(77));
}
+25 -321
View File
@@ -10,15 +10,8 @@
//! - **Compile-time type safety**: Dependencies are validated at compile time through the type
//! system. The public API is fully type-safe with no runtime type errors. Internal execution
//! uses type erasure for heterogeneous task storage, but this is never exposed to users.
//! - **Works with ANY type**: Custom types work automatically with the `#[task]` macro—just
//! implement `Send + Sync`, no trait implementations needed! The macro generates
//! type-specific extraction logic for seamless integration.
//! - **Runtime-agnostic**: Works with any async runtime (Tokio, async-std, smol, Embassy, etc.)
//! - **Type-state pattern**: The API guides you with compile-time errors if you wire dependencies
//! incorrectly. This is what prevents cycles at compile time.
//! - **Runtime-agnostic**: Works with any async runtime (Tokio, smol, Embassy, etc.)
//! - **Optimal execution**: Topological scheduling with maximum safe parallelism
//! - **Zero-cost abstractions**: Leverages generics and monomorphization for minimal overhead
//! - **Error handling**: Result-based error handling with [`DagResult<T>`] for validation
//!
//! # Quick Start
//!
@@ -46,10 +39,10 @@
//!
//! // Add source tasks
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3)).into(); // Allows y to be reused as a dependency
//! let y = dag.add_task(Value(3));
//!
//! // Add task with dependencies
//! let sum = dag.add_task(Add).depends_on((x, &y));
//! let sum = dag.add_task(Add).depends_on((x, y));
//!
//! // Execute and retrieve results
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
@@ -66,10 +59,9 @@
//! that task executes inline rather than being spawned. This eliminates spawning overhead,
//! and context switching.
//!
//! **Panic handling**: To maintain consistent behavior between inline and spawned execution,
//! panics in inline tasks are caught using `FutureExt::catch_unwind()` and converted to
//! `DagError::TaskPanicked`. This matches the behavior of async runtimes like Tokio, async-std,
//! and smol, which catch panics in spawned tasks and convert them to errors.
//! **Panic handling**: To maintain consistent behavior between runtimes,
//! panics in tasks are caught using `FutureExt::catch_unwind()` and converted to
//! `DagError::TaskPanicked`. This matches the behavior of async runtimes like Tokio.
//!
//! **What this means for you**:
//! - Tasks behave identically whether executed inline or spawned
@@ -78,8 +70,6 @@
//! - The optimization is completely transparent - no code changes needed
//!
//! **When inline execution happens**:
//! - Single-task layers (e.g., long sequential chains)
//! - Linear pipelines (A→B→C→D)
//! - Any layer where `layer.len() == 1` after topological ordering
//!
//! ## Dependency Limits
@@ -89,71 +79,11 @@
//! 2. Use intermediate aggregation tasks
//! 3. Consider if 8+ dependencies indicates a design issue
//!
//! ## Task Output Limitations
//!
//! **IMPORTANT**: Tasks **cannot return bare tuples** as output types. This is a technical
//! limitation of the current implementation. If you need to return multiple values, use one
//! of these workarounds:
//!
//! ### Workaround 1: Wrap in Result (Recommended for Fallible Operations)
//!
//! ```
//! use dagx::{task, Task};
//!
//! struct ProcessData;
//! #[task]
//! impl ProcessData {
//! async fn run(input: &String) -> Result<(String, i32, bool), String> {
//! // Wrap the tuple in Result - this works!
//! Ok(("Alice".to_string(), 30, true))
//! }
//! }
//! ```
//!
//! ### Workaround 2: Use a Struct (Recommended for Most Cases)
//!
//! ```
//! use dagx::{task, Task};
//!
//! // Define a struct to hold your multiple values
//! struct UserData {
//! name: String,
//! age: i32,
//! active: bool,
//! }
//!
//! struct ProcessDataStruct;
//! #[task]
//! impl ProcessDataStruct {
//! async fn run(input: &String) -> UserData {
//! // Return the struct - clean and self-documenting
//! UserData {
//! name: "Alice".to_string(),
//! age: 30,
//! active: true,
//! }
//! }
//! }
//! ```
//!
//! ### Why Use Structs Over `Result<(...)>`?
//!
//! While both workarounds solve the technical limitation, **structs are the better choice**
//! for most cases:
//!
//! - **Self-documenting**: `user.name` is clearer than `user.0`
//! - **Refactorable**: Easy to add/remove fields without breaking all dependencies
//! - **Type-safe**: Named fields prevent field order mistakes
//! - **Semantic clarity**: `Result` should indicate fallibility, not just wrap data
//!
//! Use `Result<(...), E>` only when your operation can genuinely fail and you need to
//! return multiple values on success
//!
//! # Core Concepts
//!
//! ## Task
//!
//! A [`Task`] is a unit of async work with typed inputs and outputs. Use the `#[task]` macro.
//! A [`Task`] is a unit of async work with typed inputs and outputs. Use the `#[task]` macro to generate an implementation.
//!
//! ### Task Patterns
//!
@@ -220,7 +150,7 @@
//!
//! ### No Dependencies (Source Tasks)
//!
//! Tasks with no dependencies don't call `depends_on()`:
//! Tasks with no dependencies return a [`TaskHandle`] directly and don't call `depends_on()`:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
@@ -258,6 +188,8 @@
//! # };
//! ```
//!
//! To manually implement [`Task`] for a single-dependency type, you must implement [`Task<(T,)>`], not [`Task<T>`].
//!
//! ### Multiple Dependencies
//!
//! Tasks with multiple dependencies receive separate reference parameters (order matters!):
@@ -319,9 +251,9 @@
//! # async {
//! let mut dag = DagRunner::new();
//!
//! let base = dag.add_task(Value(10)).into();
//! let plus1 = dag.add_task(Add(1)).depends_on(&base);
//! let times2 = dag.add_task(Scale(2)).depends_on(&base);
//! let base = dag.add_task(Value(10));
//! let plus1 = dag.add_task(Add(1)).depends_on(base);
//! let times2 = dag.add_task(Scale(2)).depends_on(base);
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//!
@@ -386,109 +318,10 @@
//! # };
//! ```
//!
//! ## Many-to-Many Pattern (m ↔ n)
//!
//! Complex DAGs with multiple layers and dependencies:
//!
//! ```no_run
//! use dagx::{task, DagRunner, Task};
//!
//!
//! // Source task (tuple struct)
//! struct Value(i32);
//!
//! #[task]
//! impl Value {
//! async fn run(&self) -> i32 { self.0 }
//! }
//!
//! // Stateless addition (unit struct)
//! struct Add;
//!
//! #[task]
//! impl Add {
//! async fn run(a: &i32, b: &i32) -> i32 { a + b }
//! }
//!
//! // Stateless multiplication (unit struct)
//! struct Multiply;
//!
//! #[task]
//! impl Multiply {
//! async fn run(a: &i32, b: &i32) -> i32 { a * b }
//! }
//!
//! # async {
//! let mut dag = DagRunner::new();
//!
//! // Layer 1: Sources
//! let x = dag.add_task(Value(2));
//! let y = dag.add_task(Value(3)).into();
//! let z = dag.add_task(Value(5));
//!
//! // Layer 2: Intermediate computations
//! let sum_xy = dag.add_task(Add).depends_on((x, &y)); // 2 + 3 = 5
//! let prod_yz = dag.add_task(Multiply).depends_on((&y, z)); // 3 * 5 = 15
//!
//! // Layer 3: Final result
//! let total = dag.add_task(Add).depends_on((&sum_xy, &prod_yz)); // 5 + 15 = 20
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//!
//! assert_eq!(output.get(total).unwrap(), 20);
//! # };
//! ```
//!
//! ## Custom Types
//!
//! **dagx works with ANY type automatically!** As long as your type is `Send + Sync + 'static`,
//! the `#[task]` macro generates the necessary extraction logic:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! #
//!
//! struct User {
//! name: String,
//! age: u32,
//! }
//!
//! struct CreateUser;
//! #[task]
//! impl CreateUser {
//! async fn run() -> User {
//! User { name: "Alice".to_string(), age: 30 }
//! }
//! }
//!
//! struct FormatUser;
//! #[task]
//! impl FormatUser {
//! async fn run(user: &User) -> String {
//! format!("{} is {} years old", user.name, user.age)
//! }
//! }
//!
//! # async {
//! let mut dag = DagRunner::new();
//!
//! let user = dag.add_task(CreateUser);
//! let formatted = dag.add_task(FormatUser).depends_on(user);
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//!
//! assert_eq!(output.get(formatted).unwrap(), "Alice is 30 years old");
//! # };
//! ```
//!
//! **No trait implementations needed!** Works with nested structs, collections, enums, and any
//! other custom type.
//!
//! # Runtime Agnostic
//!
//! dagx works with any async runtime. The library has been tested with:
//! - **Tokio** - Most popular async runtime
//! - **async-std** - Alternative async runtime
//! - **smol** - Lightweight async runtime
//!
//! Examples with different runtimes:
@@ -498,14 +331,8 @@
//! #[tokio::main]
//! async fn main() {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! }
//!
//! // With async-std
//! #[async_std::main]
//! async fn main() {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! // ... build DAG
//! let result = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await;
//! }
//!
//! // With smol
@@ -513,6 +340,7 @@
//! smol::block_on(async {
//! let mut dag = DagRunner::new();
//! // ... build and run DAG
//! let result = dag.run(|fut| smol::spawn(fut)).await;
//! });
//! }
//! ```
@@ -527,8 +355,6 @@
//! - [`DagOutput::get`] returns `DagResult<T>` and can fail if the task hasn't executed or
//! the handle is invalid
//!
//! You can handle errors explicitly or use `.unwrap()` for simple cases:
//!
//! ```no_run
//! # use dagx::{task, DagRunner, Task};
//! #
@@ -552,25 +378,6 @@
//! }
//! # };
//! ```
//!
//! # Documentation Accuracy
//!
//! This library maintains documentation accuracy through:
//!
//! 1. **Doctests**: All code examples are tested via `cargo test --doc`
//! 2. **Examples**: All files in `examples/` are tested via `cargo build --examples`
//! 3. **Type signatures**: Documentation reflects actual API return types
//! 4. **Error handling**: All examples show proper `DagResult<T>` handling
//! 5. **Safety claims**: All safety guarantees are verified by tests
//!
//! Last audited: 2025-10-06 for v0.1.0
//!
//! Changes that require documentation updates:
//! - API signature changes (`run()`, `get()`, etc.)
//! - New error types or error handling changes
//! - Safety guarantee changes
//! - Example code updates
//!
//! # API Design Principles
//!
//! dagx follows these API design principles:
@@ -578,48 +385,13 @@
//! 1. **Type Safety**: Dependencies validated at compile time via type-state pattern
//! 2. **Builder Pattern**: Fluent interface with `add_task().depends_on()`
//! 3. **Error Handling**: All fallible operations return `DagResult<T>`
//! 4. **Minimal Surface**: Small, focused API with 4 core types and 1 trait
//! 5. **Zero Cost**: Leverages generics for monomorphization
//! 6. **Consistency**:
//! - All types are PascalCase
//! - All methods are snake_case
//! - Mutable operations take `&mut self`
//! - Builder methods consume `self`
//! - Results are always `DagResult<T>`
//! 4. **Minimal Surface**: Small, focused API
//!
//! # Performance Characteristics
//!
//! dagx is designed for minimal overhead and optimal parallel execution.
//!
//! ## Benchmarks
//!
//! Run benchmarks with:
//! ```bash
//! cargo bench
//! ```
//!
//! View the detailed HTML reports:
//! ```bash
//! # macOS
//! open target/criterion/report/index.html
//!
//! # Linux
//! xdg-open target/criterion/report/index.html
//!
//! # Windows
//! start target/criterion/report/index.html
//!
//! # Or manually open target/criterion/report/index.html in your browser
//! ```
//!
//! Typical performance on modern hardware (AMD 7840U - Zen 4 laptop CPU):
//! - **DAG creation**: ~20ns empty DAG, ~96ns per task added
//! - **Execution overhead**: ~119ns per task (framework coordination only)
//! - **Total overhead** (construction + execution): ~223ns per task
//! - **Scaling**: Linear overhead with task count - 100 tasks in ~123µs, 1000 tasks in ~1.15ms
//!
//! For real-world workloads where tasks perform meaningful work (I/O, computation), the framework
//! overhead is typically well under 1% of total execution time.
//! See the [repository](https://github.com/swaits/dagx) for up-to-date benchmarks.
//!
//! ## Performance Tips
//!
@@ -653,12 +425,12 @@
//!
//! # async {
//! let mut dag = DagRunner::new();
//! let data = dag.add_task(FetchData).into();
//! let data = dag.add_task(FetchData);
//!
//! // All three tasks get efficient Arc-wrapped sharing automatically
//! let task1 = dag.add_task(ProcessData).depends_on(&data);
//! let task2 = dag.add_task(ProcessData).depends_on(&data);
//! let task3 = dag.add_task(ProcessData).depends_on(&data);
//! let task1 = dag.add_task(ProcessData).depends_on(data);
//! let task2 = dag.add_task(ProcessData).depends_on(data);
//! let task3 = dag.add_task(ProcessData).depends_on(data);
//!
//!let mut output = dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() }).await.unwrap();
//! # };
@@ -667,77 +439,20 @@
//! **How it works:**
//! - Your task outputs `T` (e.g., `Vec<String>`)
//! - Framework wraps it in `Arc<T>` internally
//! - For fan-out (1→N), Arc is cloned N times (just atomic pointer increments - O(1))
//! - For fan-out (1→N), Arc is cloned N times
//! - Each downstream task receives `&T` after extracting from Arc
//! - When you call `output.get()`, the Arc is cloned and inner value extracted
//!
//! **Performance characteristics:**
//! - **Heap types** (Vec, String, HashMap): Arc overhead is negligible (~few ns)
//! - **Copy types** (i32, usize): Small Arc overhead but usually still negligible
//! - See `cargo bench` for actual measurements on your hardware
//!
//! **Advanced - Zero-copy optimization:**
//! If you want true zero-copy sharing (no extraction), output `Arc<T>` explicitly:
//! ```
//! # use dagx::{task, Task};
//! # use std::sync::Arc;
//! struct ZeroCopyData;
//! #[task]
//! impl ZeroCopyData {
//! // Output Arc<T> directly
//! async fn run() -> Arc<Vec<String>> {
//! Arc::new(vec!["data".to_string(); 10_000])
//! }
//! }
//!
//! struct ZeroCopyProcess;
//! #[task]
//! impl ZeroCopyProcess {
//! // Receive &Arc<T> - just clones the Arc pointer, no data extraction
//! async fn run(data: &Arc<Vec<String>>) -> usize {
//! data.len()
//! }
//! }
//! ```
//! This becomes `Arc<Arc<T>>` internally, but ExtractInput unwraps one layer automatically.
//! Use this when you have many dependents AND want to avoid the clone() of inner data
//!
//! ### Other Tips
//!
//! 1. **Minimize task count**: Combine small operations into larger tasks
//! 2. **Use appropriate granularity**: Don't create tasks for trivial work (< 1µs)
//! 3. **Parallel execution**: dagx automatically maximizes parallelism
//!
//! Run `cargo bench` to see comprehensive benchmarks including basic operations, scaling
//! characteristics, common patterns (fan-out, diamond), and realistic workloads.
//!
//! # Optional Tracing Support
//!
//! dagx provides optional observability through the `tracing` crate with **zero runtime overhead
//! when disabled**. The tracing instrumentation is conditionally compiled using feature flags.
//!
//! ## Enabling Tracing
//!
//! Add the `tracing` feature to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! dagx = { version = "0.2", features = ["tracing"] }
//! tracing-subscriber = "0.3"
//! ```
//!
//! Then initialize a tracing subscriber in your application:
//!
//! ```no_run
//! use tracing_subscriber::{fmt, EnvFilter};
//!
//! fmt()
//! .with_env_filter(
//! EnvFilter::try_from_default_env()
//! .unwrap_or_else(|_| EnvFilter::new("dagx=info"))
//! )
//! .init();
//! ```
//! dagx provides optional observability through the `tracing` crate. It can be enabled with the `tracing` feature flag.
//!
//! ## Log Levels
//!
@@ -753,21 +468,9 @@
//! RUST_LOG=dagx=debug cargo run # Task and layer details
//! RUST_LOG=dagx=trace cargo run # All execution details
//! ```
//!
//! ## Zero-Cost Guarantee
//!
//! When the `tracing` feature is disabled (the default), there is **literally 0ns overhead**:
//! - Logging code is removed at compile time via `#[cfg(feature = "tracing")]`
//! - No branches, no function calls, nothing in the compiled binary
//! - The `tracing` crate isn't even linked
//! - Benchmarks verify identical performance with/without the feature
//!
//! This follows the same zero-cost pattern used by Tokio, Hyper, and other performance-critical
//! Rust async libraries.
extern crate self as dagx;
// Module declarations
mod builder;
mod deps;
mod error;
@@ -784,4 +487,5 @@ pub use runner::DagRunner;
pub use task::{Task, TaskInput};
// Re-export the procedural macro
#[cfg(feature = "derive")]
pub use dagx_macros::task;
+3 -8
View File
@@ -27,8 +27,6 @@ type ExecuteFuture =
///
/// This is the ONLY place where type erasure occurs - tasks are created with full type
/// information and only erased at this trait boundary.
///
/// This is an implementation detail and not part of the public API.
pub(crate) trait ExecutableNode: Send {
/// Execute the task with type-erased inputs and output
///
@@ -43,14 +41,14 @@ pub(crate) trait ExecutableNode: Send {
/// Fully-typed node storage for a single task.
///
/// Stores a task of type T. Implements ExecutableNode for type erasure,
/// allowing heterogeneous tasks to be stored in a single Vec.
/// allowing heterogeneous tasks to be stored together.
///
/// # Ownership Model
///
/// The task is owned directly (no Mutex needed) and consumed during execution.
/// Each task executes exactly once, taking ownership and producing an output.
///
/// The output is also returned from execute_with_deps for central storage.
/// The output is also returned from execute_with_deps to be passed to dependents and returned as an output
pub(crate) struct TypedNode<Input, T>
where
Input: Send + Sync + 'static,
@@ -84,14 +82,11 @@ where
) -> ExecuteFuture {
Box::pin(async move {
// Use the task's run method which has inline extraction logic
// generated by the #[task] macro. This allows ANY type to work without
// requiring ExtractInput trait implementations.
// generated by the #[task] macro.
let output = self.task.run(TaskInput::new(dependencies.iter())).await;
// Arc Wrapping Strategy:
// Wrap output in Arc ONCE for efficient fan-out sharing.
// For 1:N dependencies, this enables O(1) sharing (Arc clone = atomic increment)
// instead of O(N) cloning (N deep copies of output data).
// Downstream tasks receive &T after extraction from Arc.
let arc_output = Arc::new(output);
+3 -7
View File
@@ -12,13 +12,14 @@ impl DagOutput {
/// # Behavior
///
/// All task outputs are stored after execution and can be retrieved via get().
/// Each task's output can only be taken once, after which it will return `DagError::ResultNotFound`.
/// Each task's output can only be retrieved once, after which it will return `DagError::ResultNotFound`.
///
/// # Errors
///
/// Returns `DagError::ResultNotFound` if:
/// - The task hasn't been executed yet
/// - The handle is invalid
/// - The output has already been retrieved
///
/// # Examples
///
@@ -51,12 +52,7 @@ impl DagOutput {
/// assert_eq!(output.get(task).unwrap(), 42);
/// # };
/// ```
pub fn get<T: 'static + Send + Sync, H>(&mut self, handle: H) -> DagResult<T>
where
H: Into<TaskHandle<T>>,
{
let handle: TaskHandle<T> = handle.into();
pub fn get<T: 'static + Send + Sync>(&mut self, handle: TaskHandle<T>) -> DagResult<T> {
let arc_output = self
.outputs
.remove(&handle.id)
+18 -54
View File
@@ -2,8 +2,6 @@
//!
//! Provides DagRunner for building and executing directed acyclic graphs of async tasks
//! with compile-time type-safe dependencies.
//!
//! Uses Mutex for interior mutability to enable builder pattern (`&self` instead of `&mut self`).
use std::any::Any;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -23,6 +21,7 @@ use crate::error::{DagError, DagResult};
use crate::node::{ExecutableNode, TypedNode};
use crate::DagOutput;
/// Fast hasher using values as hashes
#[derive(Default, Clone)]
pub(crate) struct PassThroughHasher {
hash: u64,
@@ -82,7 +81,6 @@ pub(crate) type PassThroughHashMap<K, V> = HashMap<K, V, PassThroughHasher>;
/// async fn run(&mut self) -> i32 { self.value }
/// }
///
/// // Unit struct - no fields needed
/// struct Add;
///
/// #[task]
@@ -103,17 +101,12 @@ pub(crate) type PassThroughHashMap<K, V> = HashMap<K, V, PassThroughHasher>;
/// assert_eq!(output.get(sum).unwrap(), 5);
/// # };
/// ```
///
/// Uses Mutex for interior mutability to enable the builder pattern (`&self` not `&mut self`).
/// This allows fluent chaining of `add_task()` calls.
///
/// Nodes use Option to allow taking ownership during execution.
/// Outputs are Arc-wrapped and stored separately for retrieval via get().
/// Arc enables efficient sharing during fanout without cloning data.
pub struct DagRunner {
pub(crate) nodes: Vec<Option<Box<dyn ExecutableNode + Sync>>>,
pub(crate) edges: PassThroughHashMap<NodeId, Vec<NodeId>>, // node -> dependencies
pub(crate) dependents: PassThroughHashMap<NodeId, Vec<NodeId>>, // node -> tasks that depend on it
/// node -> dependencies
pub(crate) edges: PassThroughHashMap<NodeId, Vec<NodeId>>,
/// node -> tasks that depend on it
pub(crate) dependents: PassThroughHashMap<NodeId, Vec<NodeId>>,
}
impl Default for DagRunner {
@@ -143,8 +136,8 @@ impl DagRunner {
/// Add a task instance to the DAG, returning a node builder for wiring dependencies.
///
/// If the task has no dependencies, a [`crate::TaskHandle`] will be returned.
/// If not, the returned [`crate::TaskBuilder`] can be used to
/// specify dependencies via [`crate::TaskBuilder::depends_on`].
/// If not, dependencies should be specified for the returned [`crate::TaskBuilder`]
/// using [`crate::TaskBuilder::depends_on`].
///
/// # Examples
///
@@ -220,11 +213,9 @@ impl DagRunner {
/// Run the entire DAG to completion using the provided spawner.
///
/// This method:
/// - Executes tasks in topological order (respecting dependencies)
/// - Runs ready tasks with maximum parallelism (executor-limited)
/// - Executes each task at most once
/// - Waits for **all sinks** (tasks with no dependents) to complete
/// - Is runtime-agnostic via the spawner function
///
/// # Parameters
@@ -233,13 +224,11 @@ impl DagRunner {
/// and returns a handle to the task. This is the only way to run tasks on separate threads. Examples:
/// - Tokio: `|fut| { tokio::spawn(fut).await.unwrap() }`
/// - Smol: `|fut| { smol::spawn(fut) }`
/// - Async-std: `|fut| { async_std::task::spawn(fut) }`
/// - Single-threaded: `|fut| fut`
/// - For computationally light tasks, concurrency without parallelism can be significantly faster.
/// - Single-threaded on invoking runtime: `|fut| fut`
/// - Can be faster in situations where waiting time dominates
///
/// # Errors
///
/// - Returns `DagError::ConcurrentExecution` if the DAG is already executing
/// - Returns `DagError::TaskPanicked` if any task panics during execution
///
/// # Examples
@@ -298,36 +287,24 @@ impl DagRunner {
HashMap::with_capacity_and_hasher(total_tasks, PassThroughHasher::default());
let mut first_error = None;
// Panic handling is required to maintain behavioral consistency, as
// different async runtimes (Tokio, async-std, smol, embassy-rs) handle panics in
// spawned tasks differently.
//
// This ensures tasks behave in the same way whether executed inline or on the spawner
// across every runtime.
for layer in layers {
#[cfg(feature = "tracing")]
{
debug!(task_count = layer.len(), "executing layer");
}
// ============================================================================
// PERFORMANCE OPTIMIZATION: Inline execution for single-task layers
// ============================================================================
// Performance optimization: Inline execution for single-task layers
//
// When a layer contains exactly one task (common in deep chains, linear
// pipelines), we execute it inline rather than spawning it. This provides
// 10-100x performance improvements for sequential workloads by eliminating:
// - Task spawning overhead
// - Context switching to/from the runtime
//
// CRITICAL: Panic handling is required to maintain behavioral consistency.
// All async runtimes (Tokio, async-std, smol, embassy-rs) catch panics in
// spawned tasks and convert them to errors. We must do the same for inline
// execution to ensure tasks behave identically regardless of execution path.
//
// Without panic catching:
// - Spawned task panics → caught by runtime → becomes error (expected)
// - Inline task panics → bubbles up → crashes program (surprising!)
//
// With panic catching (current implementation):
// - Spawned task panics → caught by runtime → becomes error
// - Inline task panics → caught by us → becomes error (consistent!)
//
// This ensures the optimization is transparent to users.
// ============================================================================
if layer.len() == 1 {
let node_id = layer[0];
@@ -348,11 +325,6 @@ impl DagRunner {
.collect();
// Execute inline with panic handling.
//
// FutureExt::catch_unwind() ensures panics are caught and converted to
// DagError::TaskPanicked, matching the behavior of async runtimes when
// they spawn tasks. This guarantees consistent error handling whether
// a task executes inline (single-task layer) or spawned (multi-task layer).
let result = AssertUnwindSafe(node.execute_with_deps(dependencies))
.catch_unwind()
.await
@@ -460,14 +432,14 @@ impl DagRunner {
}
}
}
}
// Return first error if any
// Return first error if any, aborting execution after this layer
if let Some(err) = first_error {
#[cfg(feature = "tracing")]
error!(?err, "DAG execution failed");
return Err(err);
}
}
#[cfg(feature = "tracing")]
info!("DAG execution completed successfully");
@@ -475,7 +447,6 @@ impl DagRunner {
Ok(DagOutput::new(outputs))
}
#[inline]
fn compute_layers(&self) -> DagResult<Vec<Vec<NodeId>>> {
#[cfg(feature = "tracing")]
debug!("computing topological layers");
@@ -541,10 +512,6 @@ impl DagRunner {
//
// This eliminates the need for runtime cycle detection, providing both
// safety guarantees and performance benefits (no validation overhead).
// Compiler optimization barrier: ensures visited set is fully populated.
// This assertion compiles to nothing in release builds but guides the optimizer's
// understanding of the HashSet state, improving subsequent code generation.
debug_assert!(!visited.is_empty() || layers.is_empty());
#[cfg(feature = "tracing")]
@@ -554,8 +521,5 @@ impl DagRunner {
}
}
// Note: We cannot implement Default for DagRunner anymore since new() returns Arc<Self>.
// Users should call DagRunner::new() directly.
#[cfg(test)]
mod tests;
+16 -75
View File
@@ -7,72 +7,14 @@ use std::{any::Any, future::Future, marker::PhantomData, slice::Iter, sync::Arc}
/// A unit of async work with typed inputs and outputs.
///
/// Use the [`task`](crate::task) macro to implement this trait. The macro automatically
/// Use the [`crate::task`] macro to implement this trait. The macro automatically
/// derives `Input` and `Output` types from your `run()` method signature and generates
/// type-specific extraction logic in `run()`.
/// extraction logic in `run()`.
///
/// **Custom types work automatically!** As long as your output type implements
/// `Send + Sync + 'static`, the macro handles everything. No trait implementations needed!
/// `Send + Sync + 'static`, the macro handles everything.
///
/// # Task Patterns
///
/// dagx supports three main task patterns:
///
/// ## 1. Stateless Tasks (Pure Functions)
///
/// Tasks with no internal state - just transform inputs to outputs:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Add;
///
/// #[task]
/// impl Add {
/// async fn run(a: &i32, b: &i32) -> i32 {
/// a + b
/// }
/// }
/// ```
///
/// ## 2. Stateful Tasks with Shared State (`&self`)
///
/// Tasks that read from internal state but don't modify it:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Multiplier {
/// factor: i32,
/// }
///
/// #[task]
/// impl Multiplier {
/// async fn run(&self, input: &i32) -> i32 {
/// input * self.factor
/// }
/// }
/// ```
///
/// ## 3. Stateful Tasks with Mutable State (`&mut self`)
///
/// Tasks that modify internal state during execution:
///
/// ```
/// use dagx::{task, Task};
///
/// struct Counter {
/// count: i32,
/// }
///
/// #[task]
/// impl Counter {
/// async fn run(&mut self, increment: &i32) -> i32 {
/// self.count += increment;
/// self.count
/// }
/// }
/// ```
/// **Note:** When implementing this trait manually for a task with a single dependency, the Input type must be (T,) instead of T.
///
/// # Input Patterns
///
@@ -80,10 +22,6 @@ use std::{any::Any, future::Future, marker::PhantomData, slice::Iter, sync::Arc}
/// - `&T`: Single dependency
/// - `&A, &B, &C, ...`: Multiple dependencies (up to 8, order matters)
///
/// # The Underlying Trait
///
/// The `#[task]` macro generates an implementation of this trait:
///
/// # Output Wrapping
///
/// **Task outputs are automatically wrapped in `Arc<T>` internally** for efficient fan-out.
@@ -101,6 +39,17 @@ where
fn run(self, input: TaskInput<Input>) -> impl Future<Output = Self::Output> + Send;
}
/// Linear type providing a [`Task`] access to its dependencies.
///
/// The framework ensures that all dependencies will be present when this task runs, and
/// can be retrieved with [`TaskInput::next`]. The nth call to `next` consumes this TaskInput instances and returns a
/// (&T, TaskInput) tuple with the nth dependency specified in the call to [`crate::TaskBuilder::depends_on`] and a
/// TaskInput with the remaining dependencies.
///
/// No dependencies can be retrieved from an empty TaskInput (Input = ()).
///
/// **Note:** Calling [`std::mem::forget`] on a `TaskInput` instance will make it impossible to retrieve outputs of dependencies.
/// There's no reason to ever do this.
pub struct TaskInput<'inputs, Input: Send> {
inputs: Iter<'inputs, Arc<dyn Any + Send + Sync + 'static>>,
phantom: PhantomData<Input>,
@@ -118,6 +67,7 @@ impl<'inputs, Input: Send> TaskInput<'inputs, Input> {
macro_rules! impl_task_input {
($First:ident $(, $T:ident)*) => {
impl<'inputs, $First: 'static + Send, $($T: 'static + Send),*> TaskInput<'inputs, ($First, $($T,)*)> {
/// Get a reference to the next dependency from this TaskInput, and a [`TaskInput`] instance with the remaining dependencies.
#[must_use]
pub fn next(mut self) -> (&'inputs $First, TaskInput<'inputs, ($($T,)*)>) {
let value = self.inputs.next().and_then(|value| value.downcast_ref()).unwrap();
@@ -140,14 +90,5 @@ impl_task_input!(A, B, C, D, E, F);
impl_task_input!(A, B, C, D, E, F, G);
impl_task_input!(A, B, C, D, E, F, G, H);
impl TaskInput<'static, ()> {
pub fn empty() -> Self {
Self {
inputs: [].iter(),
phantom: PhantomData,
}
}
}
#[cfg(test)]
mod tests;
-6
View File
@@ -32,9 +32,3 @@ async fn test_custom_task_implementation() {
// Can't call run again since task was consumed
// This demonstrates the ownership model
}
#[test]
fn test_empty_task_input() {
let empty = TaskInput::empty();
assert_eq!(empty.inputs.len(), 0);
}
+6 -24
View File
@@ -4,22 +4,6 @@
Integration and performance tests for dagx. Unit tests live in `src/{module}/tests.rs`.
## Directory Structure
```
tests/
├── common/ # Shared test utilities
├── dependencies/ # Dependency resolution tests
├── execution/ # Task execution and DAG running
├── parallelism/ # Parallel execution verification
├── panic/ # Panic handling and isolation
├── performance/ # Stress tests and benchmarks
├── runtimes/ # Runtime compatibility tests
├── task_patterns/ # Common task pattern tests
├── type_safety/ # Type system validation
└── lib_tests.rs # Main test entry point
```
## Running Tests
```bash
@@ -56,19 +40,17 @@ cargo test --release -- --ignored
### Integration Tests (`tests/`)
- **execution**: End-to-end DAG execution
- **boundaries**: Edge cases (very large/very small graphs)
- **dependencies**: Complex dependency graphs
- **errors**: Error handling
- **execution**: End-to-end DAG execution
- **interleaving**: Layer interleaving patterns
- **parallelism**: Concurrent task execution
- **panic**: Error propagation and isolation
- **performance**: Large DAGs and stress tests
- **runtimes**: Tokio, async-std, smol compatibility
- **task_patterns**: Common usage patterns
- **type_safety**: Type system guarantees
- **runtimes**: Tokio, smol compatibility
- **tracing**: Execution observability
## Writing Tests
Integration tests use common utilities from `tests/common/mod.rs`.
Unit tests should:
- Test single units of functionality
+11 -11
View File
@@ -41,14 +41,14 @@ async fn test_multiple_parallel_deep_chains() -> DagResult<()> {
},
))
.depends_on((
&chain_ends[0],
&chain_ends[1],
&chain_ends[2],
&chain_ends[3],
&chain_ends[4],
&chain_ends[5],
&chain_ends[6],
&chain_ends[7],
chain_ends[0],
chain_ends[1],
chain_ends[2],
chain_ends[3],
chain_ends[4],
chain_ends[5],
chain_ends[6],
chain_ends[7],
));
let mut output = dag
@@ -120,7 +120,7 @@ async fn test_deep_binary_tree() -> DagResult<()> {
dag.add_task(task_fn::<(i32, i32), _, _>(move |(l, r): (&i32, &i32)| {
l + r
}))
.depends_on((&left, &right))
.depends_on((left, right))
}
}
@@ -152,7 +152,7 @@ async fn test_fibonacci_chain() -> DagResult<()> {
for _ in 2..50 {
let next = dag
.add_task(task_fn::<(i64, i64), _, _>(|(a, b): (&i64, &i64)| a + b))
.depends_on((&prev2, &prev1));
.depends_on((prev2, prev1));
prev2 = prev1;
prev1 = next;
@@ -196,7 +196,7 @@ async fn test_zigzag_dependencies() -> DagResult<()> {
// Final task depends on both chains
let final_task = dag
.add_task(task_fn::<(i32, i32), _, _>(|(a, b): (&i32, &i32)| a + b))
.depends_on((&chain_a, &chain_b));
.depends_on((chain_a, chain_b));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+1 -1
View File
@@ -267,7 +267,7 @@ async fn test_type_safety_prevents_invalid_graphs() {
let t2 = dag.add_task(Transform).depends_on(s2);
// Aggregate depends on transforms
let result = dag.add_task(Aggregate).depends_on((&t1, &t2));
let result = dag.add_task(Aggregate).depends_on((t1, t2));
// Execute and verify
let mut output = dag
+5 -5
View File
@@ -54,7 +54,7 @@ async fn test_four_dependencies() -> DagResult<()> {
.add_task(task_fn::<(i32, i32, i32, i32), _, _>(
|(a, b, c, d): (&i32, &i32, &i32, &i32)| a + b + c + d,
))
.depends_on((&deps[0], &deps[1], &deps[2], &deps[3]));
.depends_on((deps[0], deps[1], deps[2], deps[3]));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
@@ -76,7 +76,7 @@ async fn test_five_dependencies() -> DagResult<()> {
.add_task(task_fn::<(i32, i32, i32, i32, i32), _, _>(
|(a, b, c, d, e): (&i32, &i32, &i32, &i32, &i32)| a + b + c + d + e,
))
.depends_on((&deps[0], &deps[1], &deps[2], &deps[3], &deps[4]));
.depends_on((deps[0], deps[1], deps[2], deps[3], deps[4]));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
@@ -98,7 +98,7 @@ async fn test_six_dependencies() -> DagResult<()> {
.add_task(task_fn::<(i32, i32, i32, i32, i32, i32), _, _>(
|(a, b, c, d, e, f): (&i32, &i32, &i32, &i32, &i32, &i32)| a + b + c + d + e + f,
))
.depends_on((&deps[0], &deps[1], &deps[2], &deps[3], &deps[4], &deps[5]));
.depends_on((deps[0], deps[1], deps[2], deps[3], deps[4], deps[5]));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
@@ -123,7 +123,7 @@ async fn test_seven_dependencies() -> DagResult<()> {
},
))
.depends_on((
&deps[0], &deps[1], &deps[2], &deps[3], &deps[4], &deps[5], &deps[6],
deps[0], deps[1], deps[2], deps[3], deps[4], deps[5], deps[6],
));
let mut output = dag
@@ -149,7 +149,7 @@ async fn test_eight_dependencies() -> DagResult<()> {
},
))
.depends_on((
&deps[0], &deps[1], &deps[2], &deps[3], &deps[4], &deps[5], &deps[6], &deps[7],
deps[0], deps[1], deps[2], deps[3], deps[4], deps[5], deps[6], deps[7],
));
let mut output = dag
+1 -1
View File
@@ -53,7 +53,7 @@ async fn test_diamond_error_propagation() {
.depends_on(source);
dag.add_task(task_fn::<(i32, i32), _, _>(|(l, r): (&i32, &i32)| l + r))
.depends_on((&left, &right));
.depends_on((left, right));
assert!(matches!(
dag.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+1 -1
View File
@@ -54,7 +54,7 @@ async fn test_diamond_dependency() {
.depends_on(a);
let d = dag
.add_task(task_fn::<(i32, i32), _, _>(|(x, y): (&i32, &i32)| x + y))
.depends_on((&b, &c));
.depends_on((b, c));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+2 -2
View File
@@ -255,7 +255,7 @@ async fn test_layered_interleaving() -> DagResult<()> {
dag.add_task(Layer23Task {
active: layer_active.clone(),
})
.depends_on((&layer1[idx], &layer1[idx + 1]))
.depends_on((layer1[idx], layer1[idx + 1]))
})
.collect();
@@ -264,7 +264,7 @@ async fn test_layered_interleaving() -> DagResult<()> {
.add_task(Layer23Task {
active: layer_active.clone(),
})
.depends_on((&layer2[0], &layer2[1]));
.depends_on((layer2[0], layer2[1]));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })
+1 -1
View File
@@ -140,7 +140,7 @@ async fn test_layers_execute_in_parallel() -> DagResult<()> {
a + b + c
},
))
.depends_on((&layer1[0], &layer1[1], &layer1[2]))
.depends_on((layer1[0], layer1[1], layer1[2]))
};
let mut output = dag
+2 -2
View File
@@ -65,8 +65,8 @@ async fn test_tracing_inline_execution() {
// Create a linear chain (should trigger inline execution)
let a = dag.add_task(Value(1));
let b = dag.add_task(Add).depends_on((&a, &a));
let c = dag.add_task(Multiply).depends_on((&b, &b));
let b = dag.add_task(Add).depends_on((a, a));
let c = dag.add_task(Multiply).depends_on((b, b));
let mut output = dag
.run(|fut| async move { tokio::spawn(fut).await.unwrap() })