use async_nodes::{
Activation, BspEngine, BspError, BspGraph, BspNode, Condition, EngineResult, NodeOutcome,
RunContext, TypedNodeHandle, async_trait::async_trait,
};
use indexmap::IndexMap;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct LoopState {
count: u64,
suspended_once: bool,
}
struct CountNode {
name: &'static str,
suspend_at: Option<u64>,
}
#[async_trait]
impl BspNode<LoopState> for CountNode {
fn name(&self) -> &str {
self.name
}
async fn execute(&self, run: &mut RunContext<'_, LoopState>) -> Result<NodeOutcome, BspError> {
run.state.count += 1;
if self.suspend_at == Some(run.state.count) && !run.state.suspended_once {
run.state.suspended_once = true;
return Ok(NodeOutcome::Suspend {
payload: serde_json::json!({ "suspended_at": run.state.count }),
waiting_inputs: vec![],
});
}
Ok(NodeOutcome::Success(IndexMap::new()))
}
}
struct GateNode {
name: &'static str,
activation: Activation,
max_executions: Option<u64>,
}
impl GateNode {
fn any(name: &'static str) -> Self {
Self {
name,
activation: Activation::Any,
max_executions: None,
}
}
}
#[async_trait]
impl<S: Send> BspNode<S> for GateNode {
fn name(&self) -> &str {
self.name
}
fn activation(&self) -> Activation {
self.activation
}
fn max_executions(&self) -> Option<u64> {
self.max_executions
}
async fn execute(&self, _run: &mut RunContext<'_, S>) -> Result<NodeOutcome, BspError> {
Ok(NodeOutcome::Success(IndexMap::new()))
}
}
struct CounterGraph {
graph: BspGraph<LoopState>,
counter: TypedNodeHandle<CountNode>,
}
fn counter_graph(suspend_at: Option<u64>, loop_max_iterations: u64, target: u64) -> CounterGraph {
let mut graph = BspGraph::new();
let entry = graph.add_node(GateNode::any("entry"));
let counter = graph.add_node(CountNode {
name: "counter",
suspend_at,
});
let sink = graph.add_node(GateNode::any("sink"));
graph.add_edge(entry.id, counter.id);
graph.register_condition("more", move |s: &LoopState, _| s.count < target);
graph.register_condition("done", move |s: &LoopState, _| s.count >= target);
graph
.add_edge(counter.id, sink.id)
.when(Condition::Named("done".into()));
graph
.add_edge(counter.id, counter.id)
.when(Condition::Named("more".into()))
.with_max_iterations(loop_max_iterations);
CounterGraph { graph, counter }
}
#[tokio::test]
async fn test_counter_loop_runs_exactly_n_iterations() {
let CounterGraph { graph, counter } = counter_graph(None, 50, 10);
let engine = BspEngine::new(graph);
let EngineResult::Complete(state) = engine.run(LoopState::default()).await.unwrap() else {
panic!("expected completion");
};
assert_eq!(state.user.count, 10);
assert_eq!(state.execution_count(counter.id), 10);
let total_iterations: u64 = state.iterations().values().sum();
assert_eq!(total_iterations, 9);
let report = engine.validate_with_report();
assert!(report.is_ok());
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("no max_iterations budget"))
);
}
#[tokio::test]
async fn test_unbounded_loop_trips_step_limit() {
let mut graph = BspGraph::new();
let looper = graph.add_node(GateNode::any("looper"));
graph.add_edge(looper.id, looper.id); graph.add_entry(looper.id);
let engine: BspEngine<LoopState> = BspEngine::new(graph);
let outcome = engine
.with_max_steps(25)
.run(LoopState::default())
.await
.unwrap();
let EngineResult::StepLimitReached(state) = outcome else {
panic!("expected StepLimitReached, got {outcome:?}");
};
assert_eq!(state.step, 25);
assert_eq!(state.execution_count(looper.id), 25);
}
#[tokio::test]
async fn test_bounded_loop_raises_loop_bound_exceeded() {
let mut graph = BspGraph::new();
let entry = graph.add_node(GateNode::any("entry"));
let looper = graph.add_node(GateNode::any("looper"));
graph.add_edge(entry.id, looper.id);
graph.add_edge(looper.id, looper.id).with_max_iterations(3);
let engine: BspEngine<LoopState> = BspEngine::new(graph);
let err = engine.run(LoopState::default()).await.unwrap_err();
let BspError::LoopBoundExceeded { from, to, max, .. } = err else {
panic!("expected LoopBoundExceeded, got {err:?}");
};
assert_eq!((from, to), (looper.id, looper.id));
assert_eq!(max, 3);
}
#[tokio::test]
async fn test_suspend_mid_loop_resume_continues_counters() {
let CounterGraph { graph, counter } = counter_graph(Some(5), 50, 10);
#[derive(Default)]
struct SuspensionEvents {
suspended: std::sync::Mutex<Vec<usize>>,
}
impl async_nodes::BspInterceptor for SuspensionEvents {
fn on_suspended(&self, suspensions: &[async_nodes::PendingSuspension]) {
self.suspended.lock().unwrap().push(suspensions.len());
}
}
let events = std::sync::Arc::new(SuspensionEvents::default());
let engine = BspEngine::new(graph).with_interceptor(events.clone());
let EngineResult::Suspended(suspended) = engine.run(LoopState::default()).await.unwrap() else {
panic!("expected suspension");
};
assert_eq!(suspended.suspensions.len(), 1);
assert_eq!(suspended.suspensions[0].node_id, counter.id);
assert_eq!(
suspended.suspensions[0].payload,
serde_json::json!({ "suspended_at": 5 })
);
assert_eq!(suspended.state().user.count, 5);
let iterations_at_suspend: u64 = suspended.state().iterations().values().sum();
assert_eq!(iterations_at_suspend, 4);
let EngineResult::Complete(state) = engine.resume(*suspended, IndexMap::new()).await.unwrap()
else {
panic!("expected completion after resume");
};
assert_eq!(state.user.count, 10);
assert!(state.user.suspended_once);
let total_iterations: u64 = state.iterations().values().sum();
assert_eq!(total_iterations, 8);
assert_eq!(state.execution_count(counter.id), 10);
assert_eq!(events.suspended.lock().unwrap().as_slice(), [1]);
}
#[tokio::test]
async fn test_all_join_on_cycle_warns_and_deadlocks() {
let mut graph = BspGraph::new();
let entry = graph.add_node(GateNode::any("entry"));
let join = graph.add_node(GateNode {
activation: Activation::All,
..GateNode::any("join")
});
let body = graph.add_node(GateNode::any("body"));
graph.add_edge(entry.id, join.id);
graph.add_edge(join.id, body.id);
graph.add_edge(body.id, join.id).with_max_iterations(5);
graph.add_entry(join.id);
let engine: BspEngine<LoopState> = BspEngine::new(graph);
let report = engine.validate_with_report();
assert!(report.is_ok());
assert!(
report
.warnings
.iter()
.any(|w| w.contains("All-join on a cycle")),
"expected an All-join-on-cycle warning, got {:?}",
report.warnings
);
let err = engine.run(LoopState::default()).await.unwrap_err();
assert!(matches!(err, BspError::Deadlock), "got {err:?}");
}
#[tokio::test]
async fn test_max_executions_caps_poll_style_nodes() {
let mut graph = BspGraph::new();
let entry = graph.add_node(GateNode::any("entry"));
let poller = graph.add_node(GateNode {
max_executions: Some(3),
..GateNode::any("poller")
});
graph.add_edge(entry.id, poller.id);
graph.add_edge(poller.id, poller.id);
let engine: BspEngine<LoopState> = BspEngine::new(graph);
let err = engine.run(LoopState::default()).await.unwrap_err();
let BspError::MaxExecutionsExceeded { node_id, max } = err else {
panic!("expected MaxExecutionsExceeded, got {err:?}");
};
assert_eq!(node_id, poller.id);
assert_eq!(max, 3);
}
#[tokio::test]
async fn test_token_flood_protection() {
let mut graph = BspGraph::new();
let fast = graph.add_node(GateNode::any("fast"));
let slow = graph.add_node(GateNode::any("slow"));
let join = graph.add_node(GateNode {
activation: Activation::All,
..GateNode::any("join")
});
graph.add_edge(fast.id, fast.id); graph.add_edge(fast.id, join.id);
graph.add_edge(slow.id, join.id);
graph.add_entry(fast.id);
let engine: BspEngine<LoopState> = BspEngine::new(graph);
let err = engine
.with_max_pending_tokens(3)
.with_max_steps(1_000)
.run(LoopState::default())
.await
.unwrap_err();
assert!(
matches!(err, BspError::TokenLimitExceeded { cap: 3, .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn test_step_limit_is_a_first_class_result_on_stateless_runs() {
let mut graph: BspGraph = BspGraph::new();
let entry = graph.add_node(GateNode::any("entry"));
let looper = graph.add_node(GateNode::any("looper"));
graph.add_edge(entry.id, looper.id);
graph.add_edge(looper.id, looper.id);
let engine = BspEngine::new(graph).with_max_steps(4);
let outcome = engine.run(()).await.unwrap();
let EngineResult::StepLimitReached(state) = outcome else {
panic!("expected StepLimitReached, got {outcome:?}");
};
assert_eq!(state.step, 4);
}