use dataflow_rs::engine::message::Message;
use dataflow_rs::{Engine, ExecutionStep, ExecutionTrace, Workflow};
use serde_json::json;
mod common;
use common::{FailingTask, dv};
fn tracing_engine(workflow_json: &str) -> Engine {
Engine::builder()
.with_workflow(Workflow::from_json(workflow_json).unwrap())
.register("fail", FailingTask)
.build()
.unwrap()
}
fn step_ids(trace: &ExecutionTrace) -> Vec<(&str, Option<&str>)> {
trace
.steps
.iter()
.map(|s| (s.workflow_id.as_str(), s.task_id.as_deref()))
.collect()
}
#[tokio::test]
async fn tracing_retains_steps_when_async_task_fails() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err(), "the engine must still stop early");
assert_eq!(step_ids(&trace), vec![("wf", Some("step_ok"))]);
assert_eq!(trace.executed_count(), 1);
assert_eq!(message.audit_trail().len(), 2);
}
#[tokio::test]
async fn tracing_retains_steps_when_sync_stretch_fails() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.n", "logic": 7 } ] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "parse_xml",
"input": { "source": "data.n", "target": "parsed" } } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err(), "parse_xml on a non-string must error");
assert_eq!(step_ids(&trace), vec![("wf", Some("step_ok"))]);
}
#[tokio::test]
async fn tracing_retains_earlier_workflow_steps_across_a_shared_arena_run() {
let wf_a = Workflow::from_json(
r#"{
"id": "wf_a",
"name": "wf_a",
"priority": 0,
"condition": true,
"tasks": [
{ "id": "a_map", "name": "a_map", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } }
]
}"#,
)
.unwrap();
let wf_b = Workflow::from_json(
r#"{
"id": "wf_b",
"name": "wf_b",
"priority": 1,
"condition": true,
"tasks": [
{ "id": "b_map", "name": "b_map", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.b", "logic": 2 } ] } } },
{ "id": "b_boom", "name": "b_boom", "function": {
"name": "parse_xml",
"input": { "source": "data.b", "target": "parsed" } } }
]
}"#,
)
.unwrap();
let engine = Engine::builder()
.with_workflows(vec![wf_a, wf_b])
.build()
.unwrap();
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err());
assert_eq!(
step_ids(&trace),
vec![("wf_a", Some("a_map")), ("wf_b", Some("b_map"))],
"the successful workflow's steps must survive the later failure"
);
}
#[tokio::test]
async fn tracing_retains_skipped_steps_before_a_failure() {
let wf_skipped = Workflow::from_json(
r#"{
"id": "wf_skipped",
"name": "wf_skipped",
"priority": 0,
"condition": { "==": [1, 2] },
"tasks": [
{ "id": "never", "name": "never", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.never", "logic": 1 } ] } } }
]
}"#,
)
.unwrap();
let wf_main = Workflow::from_json(
r#"{
"id": "wf_main",
"name": "wf_main",
"priority": 1,
"condition": true,
"tasks": [
{ "id": "task_skipped", "name": "task_skipped",
"condition": { "==": [1, 2] },
"function": {
"name": "map",
"input": { "mappings": [ { "path": "data.skip", "logic": 1 } ] } } },
{ "id": "task_ok", "name": "task_ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.ok", "logic": 1 } ] } } },
{ "id": "task_boom", "name": "task_boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
)
.unwrap();
let engine = Engine::builder()
.with_workflows(vec![wf_skipped, wf_main])
.register("fail", FailingTask)
.build()
.unwrap();
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err());
assert_eq!(
step_ids(&trace),
vec![
("wf_skipped", None),
("wf_main", Some("task_skipped")),
("wf_main", Some("task_ok")),
]
);
assert_eq!(trace.skipped_count(), 2);
assert_eq!(trace.executed_count(), 1);
}
#[tokio::test]
async fn tracing_retains_mapping_contexts_before_a_failure() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "two_mappings", "name": "two_mappings", "function": {
"name": "map",
"input": { "mappings": [
{ "path": "data.first", "logic": 1 },
{ "path": "data.second", "logic": 2 }
] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err());
assert_eq!(trace.steps.len(), 1);
let contexts = trace.steps[0]
.mapping_contexts
.as_ref()
.expect("map task in trace mode must carry per-mapping snapshots");
assert_eq!(contexts.len(), 2, "one snapshot per mapping");
}
#[tokio::test]
async fn tracing_appends_to_an_existing_trace() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
);
let mut trace = ExecutionTrace::new();
trace.add_step(ExecutionStep::workflow_skipped("preexisting"));
let mut message = Message::from_value(&json!({}));
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err());
assert_eq!(
step_ids(&trace),
vec![("preexisting", None), ("wf", Some("step_ok"))],
"the pre-existing step is kept and new steps are appended after it"
);
}
#[tokio::test]
async fn tracing_stamps_processing_metadata_even_when_the_run_fails() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_err());
let metadata = &message.context["metadata"];
assert!(
metadata.get("processed_at").is_some(),
"processed_at must be stamped on a failing tracing run"
);
assert!(
metadata.get("engine_version").is_some(),
"engine_version must be stamped on a failing tracing run"
);
}
#[tokio::test]
async fn channel_tracing_stamps_channel_metadata_and_retains_steps() {
let wf = Workflow::from_json(
r#"{
"id": "wf_ch",
"name": "wf_ch",
"channel": "payments",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
)
.unwrap();
let engine = Engine::builder()
.with_workflow(wf)
.register("fail", FailingTask)
.build()
.unwrap();
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_for_channel_tracing("payments", &mut message, &mut trace)
.await;
assert!(result.is_err());
assert_eq!(step_ids(&trace), vec![("wf_ch", Some("step_ok"))]);
assert_eq!(
message.context["metadata"]["channel"],
dv(json!("payments"))
);
}
#[tokio::test]
async fn channel_tracing_on_an_unknown_channel_is_a_noop() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_for_channel_tracing("nope", &mut message, &mut trace)
.await;
assert!(
result.is_ok(),
"an unknown channel is a no-op, not an error"
);
assert!(trace.steps.is_empty(), "the trace must be left untouched");
}
#[tokio::test]
async fn tracing_records_the_full_trace_on_a_filter_halt() {
let engine = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } },
{ "id": "gate", "name": "gate", "function": {
"name": "filter", "input": { "condition": false } } },
{ "id": "never", "name": "never", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.never", "logic": 1 } ] } } }
]
}"#,
);
let mut message = Message::from_value(&json!({}));
let mut trace = ExecutionTrace::new();
let result = engine
.process_message_tracing(&mut message, &mut trace)
.await;
assert!(result.is_ok(), "a filter halt is not an error");
assert_eq!(
step_ids(&trace),
vec![("wf", Some("step_ok")), ("wf", Some("gate"))],
"the halting task is recorded; the task after it never runs"
);
}
#[tokio::test]
async fn with_trace_wrappers_are_unchanged_by_the_tracing_refactor() {
let ok_json = r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } }
]
}"#;
let engine = tracing_engine(ok_json);
let mut message = Message::from_value(&json!({}));
let returned = engine
.process_message_with_trace(&mut message)
.await
.expect("a clean run still returns its trace");
let mut borrowed = ExecutionTrace::new();
let mut message2 = Message::from_value(&json!({}));
engine
.process_message_tracing(&mut message2, &mut borrowed)
.await
.unwrap();
assert_eq!(step_ids(&returned), step_ids(&borrowed));
assert_eq!(returned.executed_count(), borrowed.executed_count());
let failing = tracing_engine(
r#"{
"id": "wf",
"name": "wf",
"condition": true,
"tasks": [
{ "id": "step_ok", "name": "ok", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.a", "logic": 1 } ] } } },
{ "id": "step_boom", "name": "boom", "function": {
"name": "fail", "input": {} } }
]
}"#,
);
let mut message3 = Message::from_value(&json!({}));
assert!(
failing
.process_message_with_trace(&mut message3)
.await
.is_err(),
"callers of the by-value method see no behaviour change"
);
}