289 lines
8.1 KiB
Rust
289 lines
8.1 KiB
Rust
//! Tests demonstrating that the type system prevents cycles at compile-time.
|
|
//!
|
|
//! These tests show that even if you try to create cycles, the Rust compiler
|
|
//! prevents it through the type-state pattern.
|
|
|
|
use dagx::{task, DagRunner, TaskHandle};
|
|
|
|
/// Demonstrates that TaskBuilder is consumed by depends_on,
|
|
/// preventing you from wiring the same task multiple times
|
|
#[test]
|
|
fn test_builder_consumed_by_depends_on() {
|
|
struct Source;
|
|
#[task]
|
|
impl Source {
|
|
async fn run(&self) -> i32 {
|
|
42
|
|
}
|
|
}
|
|
|
|
struct Consumer;
|
|
#[task]
|
|
impl Consumer {
|
|
async fn run(&self, input: &i32) -> i32 {
|
|
*input * 2
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let source = dag.add_task(Source);
|
|
let consumer_builder = dag.add_task(Consumer);
|
|
|
|
// This consumes consumer_builder and returns a TaskHandle
|
|
let _consumer_handle = consumer_builder.depends_on(&source);
|
|
|
|
// We CANNOT use consumer_builder again because it was moved
|
|
// This would fail to compile:
|
|
// let _consumer2 = consumer_builder.depends_on(&source); // ERROR: use of moved value
|
|
}
|
|
|
|
/// Demonstrates that you can only get a TaskHandle by finalizing a builder,
|
|
/// and handles are immutable references with no methods to add dependencies
|
|
#[test]
|
|
fn test_handle_is_immutable_reference() {
|
|
struct NoInput;
|
|
#[task]
|
|
impl NoInput {
|
|
async fn run(&self) -> i32 {
|
|
100
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let builder = dag.add_task(NoInput);
|
|
|
|
// Convert to handle (only works because Input = ())
|
|
let handle: TaskHandle<i32> = builder.into();
|
|
|
|
// TaskHandle is Copy/Clone - just an ID wrapper
|
|
let _copy1 = handle;
|
|
let _copy2 = handle;
|
|
let _copy3 = handle;
|
|
|
|
// But TaskHandle has NO methods to modify the DAG
|
|
// There's no way to add dependencies to it
|
|
// This would fail to compile:
|
|
// handle.depends_on(&something); // ERROR: no method named `depends_on`
|
|
}
|
|
|
|
/// Documents the ordering constraint that prevents cycles
|
|
#[test]
|
|
fn test_ordering_prevents_cycles() {
|
|
struct TaskA;
|
|
#[task]
|
|
impl TaskA {
|
|
async fn run(&self) -> i32 {
|
|
42
|
|
}
|
|
}
|
|
|
|
struct TaskB;
|
|
#[task]
|
|
impl TaskB {
|
|
async fn run(&self, input: &i32) -> i32 {
|
|
input + 2
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let a_builder = dag.add_task(TaskA);
|
|
let b_builder = dag.add_task(TaskB);
|
|
|
|
// To make A depend on B:
|
|
// 1. Need B as a TaskHandle
|
|
// 2. But B is currently a TaskBuilder
|
|
// 3. To get B as TaskHandle, must call b_builder.depends_on(...)
|
|
// 4. But then B depends on something else, not A!
|
|
|
|
// To make B depend on A:
|
|
// 1. Need A as a TaskHandle
|
|
// 2. But if we finalize A first, we can't make it depend on B later
|
|
|
|
// This creates an impossible ordering:
|
|
// - Can't finalize A before B (need B first for A's dependency)
|
|
// - Can't finalize B before A (need A first for B's dependency)
|
|
|
|
// The ONLY valid ordering is a strict topological order
|
|
// So we can only create DAGs, never cycles!
|
|
|
|
// Let's create a valid DAG instead: A→B (B depends on A)
|
|
let a_handle: TaskHandle<i32> = a_builder.into(); // A has no deps (unit input)
|
|
let _b_handle = b_builder.depends_on(a_handle);
|
|
// Now we could try: a_handle.depends_on(&b_handle)?
|
|
// But TaskHandle has no such method!
|
|
}
|
|
|
|
/// Shows that even with unit-type tasks, cycles are prevented
|
|
#[test]
|
|
fn test_unit_tasks_cannot_create_cycles() {
|
|
struct Task1;
|
|
#[task]
|
|
impl Task1 {
|
|
async fn run(&self) -> i32 {
|
|
1
|
|
}
|
|
}
|
|
|
|
struct Task2;
|
|
#[task]
|
|
impl Task2 {
|
|
async fn run(&self) -> i32 {
|
|
2
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let task1 = dag.add_task(Task1);
|
|
let task2 = dag.add_task(Task2);
|
|
|
|
// Both have unit input, so we can convert to handles
|
|
let _handle1: TaskHandle<i32> = task1.into();
|
|
let _handle2: TaskHandle<i32> = task2.into();
|
|
|
|
// Now we have two handles, but...
|
|
// TaskHandle has no methods to add dependencies!
|
|
// The ONLY way to add dependencies is via TaskBuilder::depends_on()
|
|
// And once you've converted to TaskHandle, you can't go back
|
|
|
|
// This would fail to compile:
|
|
// _handle1.depends_on(&_handle2); // ERROR: no method named `depends_on`
|
|
}
|
|
|
|
/// Demonstrates the catch-22 of three-way cycles
|
|
#[test]
|
|
fn test_three_way_cycle_impossible() {
|
|
struct A;
|
|
#[task]
|
|
impl A {
|
|
async fn run(&self, c: &i32) -> i32 {
|
|
*c
|
|
}
|
|
}
|
|
|
|
struct B;
|
|
#[task]
|
|
impl B {
|
|
async fn run(&self, a: &i32) -> i32 {
|
|
*a
|
|
}
|
|
}
|
|
|
|
struct C;
|
|
#[task]
|
|
impl C {
|
|
async fn run(&self, b: &i32) -> i32 {
|
|
*b
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let _a_builder = dag.add_task(A);
|
|
let _b_builder = dag.add_task(B);
|
|
let _c_builder = dag.add_task(C);
|
|
|
|
// To create A→C→B→A:
|
|
// 1. a_builder.depends_on(&c_handle) // Need c_handle
|
|
// 2. c_builder.depends_on(&b_handle) // Need b_handle
|
|
// 3. b_builder.depends_on(&a_handle) // Need a_handle (from step 1!)
|
|
//
|
|
// But step 3 needs a_handle which doesn't exist until step 1 completes.
|
|
// And step 1 needs c_handle which doesn't exist until step 2 completes.
|
|
// And step 2 needs b_handle which doesn't exist until step 3 completes.
|
|
//
|
|
// This creates a circular dependency in the COMPILATION order,
|
|
// not just the runtime order. The compiler won't allow it!
|
|
}
|
|
|
|
/// Shows that self-loops are prevented by the move semantics
|
|
#[test]
|
|
fn test_self_loop_prevented() {
|
|
struct SelfReferential;
|
|
#[task]
|
|
impl SelfReferential {
|
|
async fn run(&self, input: &i32) -> i32 {
|
|
input + 1
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
let _builder = dag.add_task(SelfReferential);
|
|
|
|
// To create A→A (self-loop):
|
|
// builder.depends_on(&builder)
|
|
//
|
|
// But this would fail because:
|
|
// 1. depends_on() takes `self` by value (moves builder)
|
|
// 2. So builder is moved in the function call
|
|
// 3. We can't also use &builder as an argument because it's being moved
|
|
//
|
|
// This would fail to compile:
|
|
// let _handle = builder.depends_on(&builder); // ERROR: use of moved value
|
|
}
|
|
|
|
/// Comprehensive test showing all the guardrails
|
|
#[tokio::test]
|
|
async fn test_type_safety_prevents_invalid_graphs() {
|
|
// The type system enforces:
|
|
// 1. Tasks with dependencies must call depends_on()
|
|
// 2. depends_on() consumes the builder (can only be called once)
|
|
// 3. TaskHandle is immutable (no way to modify after finalization)
|
|
// 4. You need a TaskHandle to wire a dependency
|
|
// 5. Getting a TaskHandle consumes the TaskBuilder
|
|
//
|
|
// Together, these rules make it IMPOSSIBLE to create cycles!
|
|
|
|
struct Source;
|
|
#[task]
|
|
impl Source {
|
|
async fn run(&self) -> i32 {
|
|
10
|
|
}
|
|
}
|
|
|
|
struct Transform;
|
|
#[task]
|
|
impl Transform {
|
|
async fn run(&self, x: &i32) -> i32 {
|
|
x * 2
|
|
}
|
|
}
|
|
|
|
struct Aggregate;
|
|
#[task]
|
|
impl Aggregate {
|
|
async fn run(&self, a: &i32, b: &i32) -> i32 {
|
|
a + b
|
|
}
|
|
}
|
|
|
|
let dag = DagRunner::new();
|
|
|
|
// Build a valid DAG: two sources, two transforms, one aggregator
|
|
let s1 = dag.add_task(Source);
|
|
let s2 = dag.add_task(Source);
|
|
|
|
// Transform depends on sources
|
|
let t1 = dag.add_task(Transform).depends_on(&s1);
|
|
let t2 = dag.add_task(Transform).depends_on(&s2);
|
|
|
|
// Aggregate depends on transforms
|
|
let result = dag.add_task(Aggregate).depends_on((&t1, &t2));
|
|
|
|
// Execute and verify
|
|
dag.run(|fut| {
|
|
tokio::spawn(fut);
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(dag.get(result).unwrap(), 40); // (10*2) + (10*2)
|
|
|
|
// Now if we wanted to add a cycle, we'd need to:
|
|
// - Make one of the Source tasks depend on the Aggregate result
|
|
// - But the Source builders were already consumed by .into() conversions
|
|
// - And even if we had them, Aggregate is already finalized as a TaskHandle
|
|
// - And TaskHandle has no method to add it as a dependency
|
|
//
|
|
// The API makes cycles structurally impossible!
|
|
}
|