Skip to content

Commit 027756d

Browse files
committed
Implement DAG
1 parent 955f520 commit 027756d

File tree

12 files changed

+1360
-9
lines changed

12 files changed

+1360
-9
lines changed

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["master"]
6+
pull_request_review:
7+
types: [submitted]
8+
branches: ["master"]
9+
10+
env:
11+
CARGO_TERM_COLOR: always
12+
13+
jobs:
14+
fmt:
15+
if: github.event_name == 'push' || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved')
16+
name: Latest Dependencies
17+
runs-on: ubuntu-latest
18+
continue-on-error: true
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: dtolnay/rust-toolchain@stable
22+
with:
23+
components: "rustfmt"
24+
- run: cargo fmt --check --verbose
25+
26+
build_and_test:
27+
if: github.event_name == 'push' || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved')
28+
name: Build and test
29+
runs-on: ${{ matrix.platform }}
30+
strategy:
31+
fail-fast: false
32+
matrix:
33+
toolchain: [stable, nightly]
34+
platform: [ubuntu-latest, macos-latest, windows-latest]
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: dtolnay/rust-toolchain@stable
38+
with:
39+
toolchain: ${{ matrix.toolchain }}
40+
components: "clippy"
41+
- uses: taiki-e/install-action@nextest
42+
- run: cargo clippy --verbose --all-features -- -D warnings
43+
- run: cargo build --verbose --all-features
44+
- run: cargo nextest run --verbose --all-features
45+
env: { RUST_BACKTRACE: 1 }
46+
- run: cargo test --verbose --doc
47+
env: { RUST_BACKTRACE: 1 }

.github/workflows/coverage.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: coverage
2+
3+
on:
4+
push:
5+
branches: ["master"]
6+
7+
env:
8+
CARGO_TERM_COLOR: always
9+
10+
jobs:
11+
cover:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: write
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: dtolnay/rust-toolchain@nightly
18+
- uses: taiki-e/install-action@cargo-llvm-cov
19+
- uses: taiki-e/install-action@nextest
20+
- run: rustup component add llvm-tools-preview --toolchain nightly-x86_64-unknown-linux-gnu
21+
- run: cargo +nightly llvm-cov clean --workspace
22+
23+
- run: cargo +nightly llvm-cov nextest --no-report
24+
- run: cargo +nightly llvm-cov --doc --no-report
25+
26+
- run: echo "COVERAGE=$(cargo +nightly llvm-cov report --summary-only --json | jq '.data[0].totals.lines.percent | . * 100 | round / 100')" >> $GITHUB_ENV
27+
- run: cargo +nightly llvm-cov report --html
28+
- run: mkdir -p ./public/badges
29+
- run: mv ./target/llvm-cov/html ./public/coverage_report
30+
- run: ./download_coverage_shield.sh ${COVERAGE} > public/badges/coverage.svg
31+
- uses: peaceiris/actions-gh-pages@v3
32+
with:
33+
github_token: ${{ secrets.GITHUB_TOKEN }}
34+
publish_dir: ./public

.gitignore

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# Generated by Cargo
2-
# will have compiled files and executables
31
debug
42
target
53

@@ -13,9 +11,9 @@ target
1311
# Contains mutation testing data
1412
**/mutants.out*/
1513

16-
# RustRover
17-
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
18-
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
19-
# and can be added to the global gitignore or merged into this file. For a more nuclear
20-
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
21-
#.idea/
14+
# Added by cargo
15+
/target
16+
17+
Cargo.lock
18+
19+
lcov.info

.vscode/settings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"rust-analyzer.linkedProjects": [
3+
"./Cargo.toml",
4+
],
5+
"rust-analyzer.cargo.features": "all",
6+
"rust-analyzer.check.command": "check",
7+
"rust-analyzer.workspace.symbol.search.kind": "all_symbols",
8+
"rust-analyzer.workspace.symbol.search.scope": "workspace",
9+
}

Cargo.toml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[package]
2+
name = "futures-dag"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
rust-version = "1.85"
7+
license = "MIT"
8+
9+
authors = ["Pavel Kurdikov <riberk32@gmail.com>"]
10+
11+
repository = "https://github.com/riberk/futures-dag"
12+
homepage = "https://github.com/riberk/futures-dag"
13+
14+
keywords = [
15+
"async",
16+
"futures",
17+
"dag",
18+
"dependencies",
19+
"stream",
20+
]
21+
22+
categories = [
23+
"asynchronous",
24+
"concurrency",
25+
"data-structures",
26+
]
27+
28+
description = """
29+
A dynamic DAG scheduler for async futures.
30+
31+
Provides a Stream-based API that executes futures only after all
32+
their dependencies have completed. Nodes can be inserted while
33+
the DAG is running, missing dependencies are handled via placeholders,
34+
and ready tasks are executed concurrently.
35+
"""
36+
readme = "README.md"
37+
38+
[dependencies]
39+
futures = "0.3"
40+
pin-project-lite = "0.2"
41+
42+
[dev-dependencies]
43+
tokio = { version = "1.48", features = ["full"] }
44+
tokio-test = { version = "0.4.4" }

README.md

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,151 @@
1-
# dag-stream
1+
# futures-dag
2+
3+
[![Build Status](https://github.com/riberk/futures-dag/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/riberk/futures-dag/actions/workflows/ci.yml)
4+
[![crates.io](https://img.shields.io/crates/v/futures-dag.svg)](https://crates.io/crates/futures-dag)
5+
[![Coverage](https://riberk.github.io/futures-dag/badges/coverage.svg)](https://riberk.github.io/futures-dag/coverage_report/index.html)
6+
7+
A dynamic DAG scheduler for async futures with a `Stream`-based API.
8+
9+
This crate executes futures according to a directed acyclic graph (DAG) of
10+
dependencies and yields their results as a stream. A future starts executing
11+
**only after all of its dependencies have completed**.
12+
13+
The graph can be extended while it is running, and missing dependencies are
14+
handled automatically.
15+
16+
---
17+
18+
## Features
19+
20+
* **Dependency-aware execution**
21+
Futures run only when all declared dependencies are satisfied.
22+
23+
* **Dynamic graph**
24+
Nodes and dependencies can be inserted while the DAG is already running.
25+
26+
* **Lazy by design**
27+
No future is polled until the stream itself is polled.
28+
29+
* **Concurrent execution**
30+
Independent nodes run concurrently using `FuturesUnordered`.
31+
32+
* **Stream-based API**
33+
Results are yielded as `(Key, Output)` pairs as soon as futures complete.
34+
35+
* **Placeholder dependencies**
36+
Dependencies may be referenced before their futures are inserted.
37+
38+
* **Cycle detection**
39+
If execution stalls and the graph contains a cycle, polling the stream panics.
40+
41+
* **Thread-safe insertion**
42+
A mutex-backed wrapper allows inserting nodes from multiple tasks or threads.
43+
44+
---
45+
46+
## Basic usage
47+
48+
```rust
49+
use futures::StreamExt;
50+
use std::future::ready;
51+
use crate::FuturesDag;
52+
53+
let mut dag = FuturesDag::new();
54+
55+
dag.insert(1, [2, 3].into(), ready("node 1")).unwrap();
56+
dag.insert(2, [3].into(), ready("node 2")).unwrap();
57+
dag.insert(3, [].into(), ready("node 3")).unwrap();
58+
59+
let results = dag.collect::<Vec<_>>().await;
60+
61+
assert_eq!(
62+
results,
63+
vec![
64+
(3, "node 3"),
65+
(2, "node 2"),
66+
(1, "node 1"),
67+
]
68+
);
69+
```
70+
71+
The order of yielded items reflects **completion order**, not insertion order.
72+
73+
---
74+
75+
## Dynamic insertion
76+
77+
Nodes may depend on keys that are not yet present in the DAG.
78+
79+
```rust
80+
use futures::{StreamExt, FutureExt};
81+
use futures_dag::BoxFuturesDag;
82+
83+
let mut dag = BoxFuturesDag::default();
84+
85+
dag.insert_box(1, [2].into(), async {}).unwrap();
86+
87+
assert!(dag.next().now_or_never().is_none());
88+
89+
dag.insert_box(2, [].into(), async {}).unwrap();
90+
91+
assert_eq!(dag.next().await.unwrap().0, 2);
92+
assert_eq!(dag.next().await.unwrap().0, 1);
93+
assert_eq!(dag.next().await, None);
94+
```
95+
96+
Missing dependencies are tracked as *placeholders* and automatically resolved
97+
once their futures are inserted and completed.
98+
99+
---
100+
101+
## Thread-safe insertion
102+
103+
If nodes need to be inserted from multiple tasks or threads while another task
104+
drives the stream, use `FuturesMutexDag`.
105+
106+
```rust
107+
use futures::StreamExt;
108+
use futures_dag::{BoxFuturesDag, FuturesMutexDag};
109+
110+
let dag = FuturesMutexDag::new(BoxFuturesDag::default());
111+
112+
let dag_clone = dag.clone();
113+
tokio::spawn(async move {
114+
dag_clone.insert_box(2, [].into(), async {}).unwrap();
115+
});
116+
117+
dag.insert_box(1, [2].into(), async {}).unwrap();
118+
119+
let results = dag.map(|v| v.0).collect::<Vec<_>>().await;
120+
assert_eq!(results, vec![2, 1]);
121+
```
122+
123+
`FuturesMutexDag` stores the DAG in `Arc<Mutex<_>>`.
124+
125+
---
126+
127+
## Execution model
128+
129+
* Futures are **not** spawned on a runtime.
130+
* All polling happens when the stream is polled.
131+
* Ready futures are driven concurrently via `FuturesUnordered`.
132+
* The DAG completes when all nodes have completed and yielded their outputs.
133+
134+
If the DAG cannot make progress and not all nodes are completed, the graph is
135+
checked for cycles and polling panics if a cycle is found.
136+
137+
---
138+
## When to use this crate
139+
140+
This crate is a good fit when you need:
141+
142+
* dependency-aware async execution,
143+
* incremental graph construction,
144+
* streaming of completion results,
145+
* a lightweight alternative to a full task scheduler.
146+
147+
---
148+
149+
## License
150+
151+
MIT

cov.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/bash
2+
set -e
3+
4+
cargo +nightly llvm-cov clean --workspace
5+
cargo +nightly llvm-cov nextest --all-features --workspace --no-report
6+
cargo +nightly llvm-cov --all-features --workspace --doc --no-report
7+
8+
cargo llvm-cov report --lcov > lcov.info
9+
cargo llvm-cov report --html
10+
cargo llvm-cov report --summary-only

download_coverage_shield.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#! /bin/bash
2+
get_color() (
3+
local input=$1
4+
5+
if ! [[ $input =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
6+
echo "Error: Input is not a number"
7+
return 1
8+
fi
9+
10+
if (( $(echo "$input > 100" | bc -l) )); then
11+
echo "Error: Number is greater than 100"
12+
return 1
13+
fi
14+
15+
if (( $(echo "$input < 50" | bc -l) )); then
16+
echo "e05d44"
17+
elif (( $(echo "$input < 60" | bc -l) )); then
18+
echo "fe7d37"
19+
elif (( $(echo "$input < 65" | bc -l) )); then
20+
echo "dfb317"
21+
elif (( $(echo "$input < 75" | bc -l) )); then
22+
echo "a4a61d"
23+
elif (( $(echo "$input < 85" | bc -l) )); then
24+
echo "97ca00"
25+
else
26+
echo "40c010"
27+
fi
28+
29+
)
30+
31+
input="${1}"
32+
color=$(get_color "$input")
33+
34+
url="https://img.shields.io/badge/Coverage-${input}%25-${color}"
35+
36+
curl $url

0 commit comments

Comments
 (0)