use async_trait::async_trait;
use dataflow_rs::engine::functions::AsyncFunctionHandler;
use dataflow_rs::engine::message::Message;
use dataflow_rs::{
Engine, ExecutionObserver, IssueCode, Result, TaskContext, TaskEvent, TaskOutcome, Workflow,
WorkflowFinished,
};
use datavalue::OwnedDataValue;
use serde_json::{Value, json};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
mod common;
use common::dv;
#[derive(Debug, Default)]
struct Visit {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl AsyncFunctionHandler for Visit {
type Input = Value;
async fn execute(&self, ctx: &mut TaskContext<'_>, _input: &Value) -> Result<TaskOutcome> {
self.calls.fetch_add(1, Ordering::SeqCst);
let item = Value::from(&ctx.message().context["temp_data"]["item"]);
let counter = ctx.loop_counter();
let mut seen = Value::from(&ctx.message().context["data"]["calls"]);
if !seen.is_array() {
seen = json!([]);
}
seen.as_array_mut()
.expect("just made an array")
.push(json!({"item": item, "i": counter}));
ctx.set("data.calls", OwnedDataValue::from(&seen));
Ok(TaskOutcome::Success)
}
}
const BATCH_WORKFLOW: &str = r#"{
"id": "batch", "name": "Batch",
"loop": {
"setup": [
{ "id": "claim", "name": "claim",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.claimed", "logic": true } ] } } },
{ "id": "read", "name": "read",
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.batch", "logic": { "var": "data.input" } } ] } } }
],
"over": { "var": "temp_data.batch.items" },
"as": "item",
"counter": "i",
"max": 64,
"scratch": "it"
},
"tasks": [
{ "id": "mark", "name": "mark",
"condition": { "==": [{ "var": "temp_data.item.retry" }, true] },
"function": { "name": "map", "input": { "mappings": [
{ "path": "temp_data.it.retrying", "logic": true } ] } } },
{ "id": "visit", "name": "visit", "function": { "name": "visit", "input": {} } },
{ "id": "record", "name": "record",
"function": { "name": "map", "input": { "mappings": [
{ "path": { "cat": ["data.retrying.#", { "var": "temp_data.i" }] },
"logic": { "!!": [{ "var": "temp_data.it.retrying" }] } } ] } } }
]
}"#;
fn batch_builder(calls: &Arc<AtomicUsize>) -> dataflow_rs::EngineBuilder {
Engine::builder()
.with_workflows(vec![Workflow::from_json(BATCH_WORKFLOW).unwrap()])
.register(
"visit",
Visit {
calls: Arc::clone(calls),
},
)
}
fn batch_message() -> Message {
Message::builder()
.data(dv(json!({
"input": { "items": [
{ "id": "a" }, { "id": "b", "retry": true }, { "id": "c" } ] }
})))
.build()
}
#[tokio::test]
async fn the_batch_pattern_needs_no_guard_no_halt_filter_no_pick_and_no_clear() {
let calls = Arc::new(AtomicUsize::new(0));
let engine = batch_builder(&calls).build().expect("engine should build");
let mut message = batch_message();
engine.process_message(&mut message).await.expect("ok");
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"the async body ran once per element"
);
assert_eq!(
Value::from(&message.context["data"]["calls"]),
json!([
{"item": {"id": "a"}, "i": 0},
{"item": {"id": "b", "retry": true}, "i": 1},
{"item": {"id": "c"}, "i": 2}
]),
"each iteration saw its own element and index"
);
assert_eq!(
Value::from(&message.context["data"]["retrying"]),
json!({"0": false, "1": true, "2": false}),
"the scratch flag written for `b` did not leak into `c`"
);
assert_eq!(
Value::from(&message.context["temp_data"]["claimed"]),
json!(true),
"setup ran"
);
let stamps: Vec<Option<i64>> = message
.audit_trail()
.iter()
.map(|e| e.loop_counter)
.collect();
assert_eq!(
stamps,
vec![
None,
None,
Some(0),
Some(0),
Some(1),
Some(1),
Some(1),
Some(2),
Some(2)
]
);
assert!(message.errors().is_empty());
}
#[tokio::test]
async fn an_over_loop_survives_a_hot_reload() {
let calls = Arc::new(AtomicUsize::new(0));
let engine = batch_builder(&calls).build().unwrap();
let mut first = batch_message();
engine.process_message(&mut first).await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 3);
let reloaded = engine
.with_new_workflows(vec![Workflow::from_json(BATCH_WORKFLOW).unwrap()])
.unwrap();
let mut second = batch_message();
reloaded.process_message(&mut second).await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 6);
assert!(second.errors().is_empty());
}
#[tokio::test]
async fn trace_steps_stamp_setup_without_a_loop_counter() {
let calls = Arc::new(AtomicUsize::new(0));
let engine = batch_builder(&calls).build().unwrap();
let mut message = batch_message();
let trace = engine
.process_message_with_trace(&mut message)
.await
.unwrap();
let steps: Vec<(Option<&str>, Option<i64>)> = trace
.steps
.iter()
.map(|s| (s.task_id.as_deref(), s.loop_counter))
.collect();
assert_eq!(&steps[..2], &[(Some("claim"), None), (Some("read"), None)]);
assert!(
steps[2..].iter().all(|(_, c)| c.is_some()),
"every body step carries its iteration"
);
assert_eq!(
steps[2],
(Some("mark"), Some(0)),
"a skipped body step is stamped too"
);
}
#[derive(Default)]
struct SpanObserver {
tasks: Mutex<Vec<String>>,
finished: Mutex<Vec<(String, u32, bool)>>,
}
impl ExecutionObserver for SpanObserver {
fn task_finished(&self, event: &TaskEvent<'_>) {
self.tasks.lock().unwrap().push(event.task_id.to_string());
}
fn workflow_finished(&self, event: &WorkflowFinished<'_>) {
self.finished.lock().unwrap().push((
event.workflow_id.to_string(),
event.sweeps,
event.halted,
));
}
}
#[tokio::test]
async fn an_observer_sees_setup_task_events_and_counts_iterations_only() {
let calls = Arc::new(AtomicUsize::new(0));
let observer = Arc::new(SpanObserver::default());
let engine = batch_builder(&calls)
.with_observer(Arc::clone(&observer) as Arc<dyn ExecutionObserver>)
.build()
.unwrap();
let mut message = batch_message();
engine.process_message(&mut message).await.unwrap();
let tasks = observer.tasks.lock().unwrap().clone();
assert_eq!(
&tasks[..2],
&["claim", "read"],
"setup tasks emit task events"
);
assert_eq!(
tasks.len(),
2 + 3 + 3 + 1,
"then visit and record per element, plus mark once"
);
assert_eq!(
observer.finished.lock().unwrap().clone(),
vec![("batch".to_string(), 3, false)],
"one span for the whole loop; the setup pass is not a sweep"
);
}
#[tokio::test]
async fn build_refuses_an_unknown_function_in_setup() {
let workflow = Workflow::from_json(
r#"{ "id": "w", "name": "w",
"loop": { "max": 3, "setup": [
{ "id": "s", "name": "s", "function": { "name": "no_such_handler", "input": {} } } ] },
"tasks": [ { "id": "t", "name": "t",
"function": { "name": "map", "input": { "mappings": [] } } } ] }"#,
)
.unwrap();
let err = Engine::builder()
.with_workflows(vec![workflow])
.build()
.err()
.expect("a setup step is resolved like a body step");
assert!(err.to_string().contains("no_such_handler"), "got: {err}");
}
#[tokio::test]
async fn check_workflow_reports_setup_steps_and_a_secret_in_over() {
let workflow = Workflow::from_json(
r#"{ "id": "w", "name": "w",
"loop": { "max": 3, "as": "item",
"over": { "secret": "batch" },
"setup": [ { "id": "s", "name": "s",
"function": { "name": "http_call", "input": { "connector": "api" } } } ] },
"tasks": [ { "id": "t", "name": "t",
"function": { "name": "map", "input": { "mappings": [] } } } ] }"#,
)
.unwrap();
let issues = Engine::builder()
.with_secrets_json(&json!({"batch": "x"}))
.check_workflow(&workflow);
let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
assert!(
codes.contains(&IssueCode::MissingHandler),
"http_call in setup: {codes:?}"
);
let over = issues
.iter()
.find(|i| i.code == IssueCode::SecretInMessageWrite)
.unwrap_or_else(|| {
panic!("over's result lands in temp_data, so it may not read a secret: {codes:?}")
});
assert_eq!(over.path.as_deref(), Some("loop.over"));
}