use crate::engine::error::{DataflowError, ErrorInfo, Result, service_error_code};
use crate::engine::executor::{
ArenaContext, evaluate_condition, evaluate_condition_in_arena, with_arena,
};
use crate::engine::functions::BoxedFunctionHandler;
use crate::engine::message::{AuditTrail, Change, Message};
use crate::engine::observer::{ExecutionObserver, TaskEvent};
use crate::engine::task::Task;
use crate::engine::task_executor::TaskExecutor;
use crate::engine::task_outcome::TaskOutcome;
use crate::engine::trace::{ExecutionStep, ExecutionTrace, StepTiming, duration_us_between};
use crate::engine::utils::{compute_path_parts, set_nested_value, set_nested_value_parts};
use crate::engine::workflow::{LoopConfig, Workflow};
use chrono::{DateTime, Utc};
use core::time::Duration;
use datalogic_rs::Engine;
use datavalue::OwnedDataValue;
use log::{debug, error, info, warn};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
enum TaskControlFlow {
Continue,
HaltWorkflow,
}
#[derive(Clone, Copy)]
struct PassCtx {
now: DateTime<Utc>,
loop_counter: Option<i64>,
}
impl PassCtx {
#[inline]
fn once(now: DateTime<Utc>) -> Self {
Self {
now,
loop_counter: None,
}
}
}
enum PassOutcome {
ConditionFalse,
Completed,
Halted,
}
fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
let mut i = start;
while i < tasks.len() && tasks[i].function.is_sync_builtin() {
i += 1;
}
i
}
fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
debug!("Skipping workflow {} - {}", workflow_id, reason);
if let Some(t) = trace {
t.add_step(ExecutionStep::workflow_skipped(workflow_id));
}
}
fn note_task_skip(
trace: Option<&mut ExecutionTrace>,
workflow_id: &str,
task_id: &str,
loop_counter: Option<i64>,
) {
debug!("Skipping task {} - condition not met", task_id);
if let Some(t) = trace {
t.add_step(
ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
);
}
}
fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
match workflow.rollout {
None => true,
Some(r) => match message.routing_bucket() {
None => true,
Some(b) => r.accepts(b),
},
}
}
fn joins_sync_run(workflow: &Workflow) -> bool {
workflow.fully_sync && workflow.loop_config.is_none()
}
fn resolve_counter_parts(config: &LoopConfig) -> Arc<[Arc<str>]> {
match &config.counter {
Some(counter) if config.counter_parts.is_empty() => {
compute_path_parts("temp_data", counter)
}
_ => Arc::clone(&config.counter_parts),
}
}
fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
OwnedDataValue::Object(vec![
(
"workflow_id".to_string(),
OwnedDataValue::String(workflow_id.to_string()),
),
(
"task_id".to_string(),
OwnedDataValue::String(task_id.to_string()),
),
(
"status_code".to_string(),
OwnedDataValue::from(u64::from(status)),
),
])
}
fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
match slot {
OwnedDataValue::String(existing) => {
if existing != value {
existing.clear();
existing.push_str(value);
}
}
_ => *slot = OwnedDataValue::String(value.to_string()),
}
}
fn overwrite_progress_in_place(
fields: &mut [(String, OwnedDataValue)],
workflow_id: &str,
task_id: &str,
status: u16,
) -> bool {
if fields.len() != 3 {
return false;
}
let mut matched = 0;
for (k, v) in fields.iter_mut() {
match k.as_str() {
"workflow_id" => {
overwrite_str_in_place(v, workflow_id);
matched += 1;
}
"task_id" => {
overwrite_str_in_place(v, task_id);
matched += 1;
}
"status_code" => {
*v = OwnedDataValue::from(u64::from(status));
matched += 1;
}
_ => {}
}
}
matched == 3
}
fn write_progress_metadata(
context: &mut OwnedDataValue,
workflow_id: &str,
task_id: &str,
status: u16,
) {
if let OwnedDataValue::Object(top) = context {
if let Some((_, OwnedDataValue::Object(meta))) =
top.iter_mut().find(|(k, _)| k == "metadata")
{
match meta.iter_mut().find(|(k, _)| k == "progress") {
Some((_, slot)) => {
if let OwnedDataValue::Object(fields) = slot {
if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
return;
}
}
*slot = new_progress_object(workflow_id, task_id, status);
}
None => {
meta.push((
"progress".to_string(),
new_progress_object(workflow_id, task_id, status),
));
}
}
return;
}
}
set_nested_value(
context,
"metadata.progress",
new_progress_object(workflow_id, task_id, status),
);
}
pub struct WorkflowExecutor {
task_executor: Arc<TaskExecutor>,
engine: Arc<Engine>,
observer: Option<Arc<dyn ExecutionObserver>>,
}
impl WorkflowExecutor {
pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
Self {
task_executor,
engine,
observer: None,
}
}
pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
self.observer = Some(observer);
self
}
pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
self.observer.as_ref()
}
#[inline]
fn emit_task_event(
&self,
workflow: &Workflow,
task: &Task,
result: &Result<(TaskOutcome, Vec<Change>)>,
started_at: Option<DateTime<Utc>>,
) {
if let Some(observer) = self.observer.as_ref() {
let status = match result {
Ok((outcome, _)) => outcome.audit_status(),
Err(_) => Some(500),
};
let duration = started_at
.map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
.unwrap_or_default();
observer.task_finished(&TaskEvent {
workflow_id: &workflow.id,
task_id: &task.id,
function: task.function.function_name(),
status,
duration,
});
}
}
#[inline]
fn observer_clock(&self) -> Option<DateTime<Utc>> {
self.observer.as_ref().map(|_| Utc::now())
}
pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
self.task_executor.task_functions()
}
pub async fn execute(
&self,
workflow: &Workflow,
message: &mut Message,
now: DateTime<Utc>,
) -> Result<bool> {
self.execute_inner(workflow, message, None, now).await
}
pub async fn execute_with_trace(
&self,
workflow: &Workflow,
message: &mut Message,
trace: &mut ExecutionTrace,
now: DateTime<Utc>,
) -> Result<bool> {
self.execute_inner(workflow, message, Some(trace), now)
.await
}
async fn execute_inner(
&self,
workflow: &Workflow,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<bool> {
if !rollout_admits(workflow, message) {
note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
return Ok(false);
}
if let Some(loop_config) = workflow.loop_config.as_ref() {
return self
.execute_loop(workflow, loop_config, message, trace, now)
.await;
}
match self
.execute_pass(workflow, message, trace.as_deref_mut(), PassCtx::once(now))
.await
{
Ok(PassOutcome::ConditionFalse) => {
note_workflow_skip(trace, &workflow.id, "condition not met");
Ok(false)
}
Ok(_) => {
info!("Successfully completed workflow: {}", workflow.id);
Ok(true)
}
Err(e) => {
if self.record_workflow_error(workflow, message, &e) {
Err(e)
} else {
Ok(true)
}
}
}
}
async fn execute_loop(
&self,
workflow: &Workflow,
config: &LoopConfig,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<bool> {
let mut counter = config.init;
let mut sweeps_run: u32 = 0;
let counter_parts = resolve_counter_parts(config);
loop {
set_nested_value_parts(
&mut message.context,
&counter_parts,
OwnedDataValue::from_i64(counter),
);
if counter >= config.max {
if workflow.compiled_condition.is_some() {
warn!(
"Workflow {} stopped at its loop bound (max {}) with the condition \
still true after {} sweep(s)",
workflow.id, config.max, sweeps_run
);
}
break;
}
let pass = PassCtx {
now,
loop_counter: Some(counter),
};
match self
.execute_pass(workflow, message, trace.as_deref_mut(), pass)
.await
{
Ok(PassOutcome::ConditionFalse) => {
if sweeps_run == 0 {
note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
} else {
debug!(
"Workflow {} loop exited at counter {} - condition no longer met",
workflow.id, counter
);
}
break;
}
Ok(PassOutcome::Halted) => {
sweeps_run += 1;
debug!(
"Workflow {} loop halted at counter {}",
workflow.id, counter
);
break;
}
Ok(PassOutcome::Completed) => {
sweeps_run += 1;
}
Err(e) => {
sweeps_run += 1;
if self.record_workflow_error(workflow, message, &e) {
return Err(e);
}
}
}
counter = counter.saturating_add(config.increment);
}
if sweeps_run > 0 {
info!(
"Successfully completed workflow: {} ({} loop sweep(s))",
workflow.id, sweeps_run
);
}
Ok(sweeps_run > 0)
}
async fn execute_pass(
&self,
workflow: &Workflow,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<PassOutcome> {
enum FirstStretch {
Skipped,
Halted,
Continue,
}
let tasks = &workflow.tasks;
let first_boundary = next_async_boundary(tasks, 0);
let first: Result<FirstStretch> =
if workflow.compiled_condition.is_none() && first_boundary == 0 {
Ok(FirstStretch::Continue)
} else {
with_arena(|arena| -> Result<FirstStretch> {
let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
let should_execute = match workflow.compiled_condition.as_ref() {
None => true,
Some(compiled) => evaluate_condition_in_arena(
&self.engine,
Some(compiled),
arena_ctx.as_data_value(),
arena,
)?,
};
if !should_execute {
return Ok(FirstStretch::Skipped);
}
if first_boundary == 0 {
return Ok(FirstStretch::Continue);
}
let halted = self.run_tasks_slice_in_arena(
&tasks[..first_boundary],
workflow,
message,
&mut arena_ctx,
trace.as_deref_mut(),
pass,
)?;
Ok(if halted {
FirstStretch::Halted
} else {
FirstStretch::Continue
})
})
};
match first? {
FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
FirstStretch::Halted => Ok(PassOutcome::Halted),
FirstStretch::Continue => {
let halted = self
.execute_tasks(workflow, message, trace, pass, first_boundary)
.await?;
Ok(if halted {
PassOutcome::Halted
} else {
PassOutcome::Completed
})
}
}
}
fn record_workflow_error(
&self,
workflow: &Workflow,
message: &mut Message,
e: &DataflowError,
) -> bool {
message.errors.push(
ErrorInfo::builder(
"WORKFLOW_ERROR",
format!("Workflow {} error: {}", workflow.id, e),
)
.workflow_id(&workflow.id)
.build(),
);
if workflow.continue_on_error {
warn!(
"Workflow {} encountered error but continuing: {:?}",
workflow.id, e
);
false
} else {
error!("Workflow {} failed: {:?}", workflow.id, e);
true
}
}
async fn execute_tasks(
&self,
workflow: &Workflow,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
start: usize,
) -> Result<bool> {
let tasks = &workflow.tasks;
let mut idx = start;
while idx < tasks.len() {
let stretch_end = next_async_boundary(tasks, idx);
if stretch_end > idx {
let halt = self.run_sync_stretch(
&tasks[idx..stretch_end],
workflow,
message,
trace.as_deref_mut(),
pass,
)?;
if halt {
return Ok(true);
}
idx = stretch_end;
}
if idx < tasks.len() {
let task = &tasks[idx];
let should_execute = evaluate_condition(
&self.engine,
task.compiled_condition.as_ref(),
&message.context,
)?;
if !should_execute {
note_task_skip(
trace.as_deref_mut(),
&workflow.id,
&task.id,
pass.loop_counter,
);
idx += 1;
continue;
}
let trace_start = if trace.is_some() {
Some(Utc::now())
} else {
None
};
let obs_start = trace_start.or_else(|| self.observer_clock());
let result = self.task_executor.execute(task, message).await;
self.emit_task_event(workflow, task, &result, obs_start);
let control_flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
task.continue_on_error,
message,
pass,
)?;
if let Some(t) = trace.as_deref_mut() {
let started_at = trace_start.unwrap_or(pass.now);
t.add_executed_step(
&workflow.id,
&task.id,
message,
StepTiming {
started_at,
duration_us: duration_us_between(started_at, Utc::now()),
},
None,
pass.loop_counter,
);
}
if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
return Ok(true);
}
idx += 1;
}
}
Ok(false)
}
fn run_sync_stretch(
&self,
tasks: &[Task],
workflow: &Workflow,
message: &mut Message,
trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<bool> {
with_arena(|arena| -> Result<bool> {
let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
self.run_tasks_slice_in_arena(tasks, workflow, message, &mut arena_ctx, trace, pass)
})
}
fn run_tasks_slice_in_arena<'arena>(
&self,
tasks: &'arena [Task],
workflow: &Workflow,
message: &mut Message,
arena_ctx: &mut ArenaContext<'arena>,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<bool> {
let arena = arena_ctx.arena();
for task in tasks {
let should_execute = match task.compiled_condition.as_ref() {
None => true,
Some(compiled) => evaluate_condition_in_arena(
&self.engine,
Some(compiled),
arena_ctx.as_data_value(),
arena,
)?,
};
if !should_execute {
note_task_skip(
trace.as_deref_mut(),
&workflow.id,
&task.id,
pass.loop_counter,
);
continue;
}
let mut mapping_snapshots: Vec<Value> = Vec::new();
let want_mapping_contexts = trace
.as_deref()
.is_some_and(|t| t.options().mapping_contexts);
let mapping_snapshots_buf = if want_mapping_contexts {
Some(&mut mapping_snapshots)
} else {
None
};
let trace_start = if trace.is_some() {
Some(Utc::now())
} else {
None
};
let obs_start = trace_start.or_else(|| self.observer_clock());
let result =
self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
self.emit_task_event(workflow, task, &result, obs_start);
let control_flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
task.continue_on_error,
message,
pass,
)?;
arena_ctx.refresh_for_path(&message.context, "metadata.progress");
if let Some(t) = trace.as_deref_mut() {
let started_at = trace_start.unwrap_or(pass.now);
let mapping_contexts = if mapping_snapshots.is_empty() {
None
} else {
Some(mapping_snapshots)
};
t.add_executed_step(
&workflow.id,
&task.id,
message,
StepTiming {
started_at,
duration_us: duration_us_between(started_at, Utc::now()),
},
mapping_contexts,
pass.loop_counter,
);
}
if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
return Ok(true);
}
}
Ok(false)
}
pub async fn run_all(
&self,
workflows: &[&Workflow],
message: &mut Message,
trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<()> {
self.run_all_borrowed(workflows, message, trace, now).await
}
pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
&self,
workflows: &[W],
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<()> {
let mut i = 0;
while i < workflows.len() {
if joins_sync_run(workflows[i].borrow()) {
let mut j = i + 1;
while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
j += 1;
}
self.execute_sync_workflow_run(
&workflows[i..j],
message,
trace.as_deref_mut(),
now,
)?;
i = j;
} else {
self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
.await?;
i += 1;
}
}
Ok(())
}
fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
&self,
workflows: &[W],
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<()> {
debug_assert!(
workflows.iter().all(|w| joins_sync_run(w.borrow())),
"only non-looping fully-sync workflows may join a shared-arena run"
);
let pass = PassCtx::once(now);
with_arena(|arena| -> Result<()> {
let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
for workflow in workflows {
let workflow: &Workflow = workflow.borrow();
if !rollout_admits(workflow, message) {
note_workflow_skip(
trace.as_deref_mut(),
&workflow.id,
"outside rollout bucket",
);
continue;
}
let should_execute = match workflow.compiled_condition.as_ref() {
None => true,
Some(compiled) => evaluate_condition_in_arena(
&self.engine,
Some(compiled),
arena_ctx.as_data_value(),
arena,
)?,
};
if !should_execute {
note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
continue;
}
match self.run_tasks_slice_in_arena(
&workflow.tasks,
workflow,
message,
&mut arena_ctx,
trace.as_deref_mut(),
pass,
) {
Ok(_halted) => {
info!("Successfully completed workflow: {}", workflow.id);
}
Err(e) => {
if self.record_workflow_error(workflow, message, &e) {
return Err(e);
}
}
}
}
Ok(())
})
}
fn execute_sync_task_in_arena<'arena>(
&self,
task: &'arena Task,
message: &mut Message,
arena_ctx: &mut ArenaContext<'arena>,
mapping_snapshots: Option<&mut Vec<Value>>,
) -> Result<(TaskOutcome, Vec<Change>)> {
debug!(
"Executing sync task in arena: {} ({})",
task.id,
task.function.function_name()
);
debug_assert!(
task.function.is_sync_builtin(),
"execute_sync_task_in_arena called with non-sync-builtin task: {}",
task.function.function_name()
);
task.function
.try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
.ok_or_else(|| {
DataflowError::Task(format!(
"execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
(engine bug — sync-stretch should only contain sync-builtin tasks)",
task.function.function_name()
))
})?
}
fn handle_task_result(
&self,
result: Result<(TaskOutcome, Vec<Change>)>,
workflow_id_arc: &Arc<str>,
task_id_arc: &Arc<str>,
continue_on_error: bool,
message: &mut Message,
pass: PassCtx,
) -> Result<TaskControlFlow> {
let workflow_id: &str = workflow_id_arc;
let task_id: &str = task_id_arc;
match result {
Ok((TaskOutcome::Skip, _)) => {
debug!("Task {} signaled skip", task_id);
Ok(TaskControlFlow::Continue)
}
Ok((outcome, changes)) => {
let status = outcome
.audit_status()
.expect("Skip handled above; remaining variants emit audit status");
let halt = outcome.halts_workflow();
message.audit_trail.push(AuditTrail {
timestamp: pass.now,
workflow_id: Arc::clone(workflow_id_arc),
task_id: Arc::clone(task_id_arc),
status: status as usize,
changes,
loop_counter: pass.loop_counter,
});
write_progress_metadata(&mut message.context, workflow_id, task_id, status);
if halt {
info!("Task {} halted workflow {}", task_id, workflow_id);
return Ok(TaskControlFlow::HaltWorkflow);
}
if (400..500).contains(&status) {
warn!("Task {} returned client error status: {}", task_id, status);
} else if status >= 500 {
error!("Task {} returned server error status: {}", task_id, status);
message.errors.push(
ErrorInfo::builder(
"TASK_STATUS_ERROR",
format!("Task {} returned status {}", task_id, status),
)
.workflow_id(workflow_id)
.task_id(task_id)
.build(),
);
if !continue_on_error {
return Err(DataflowError::Task(format!(
"Task {} failed with status {}",
task_id, status
)));
}
}
Ok(TaskControlFlow::Continue)
}
Err(e) => {
error!("Task {} failed: {:?}", task_id, e);
message.audit_trail.push(AuditTrail {
timestamp: pass.now,
workflow_id: Arc::clone(workflow_id_arc),
task_id: Arc::clone(task_id_arc),
status: 500,
changes: vec![],
loop_counter: pass.loop_counter,
});
write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
let mut info = ErrorInfo::builder(
service_error_code(&e),
format!("Task {} error: {}", task_id, e),
)
.workflow_id(workflow_id)
.task_id(task_id);
if let Some(detail) = e.detail() {
info = info.detail(detail);
}
message.errors.push(info.build());
if !continue_on_error {
Err(e)
} else {
Ok(TaskControlFlow::Continue)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::compiler::LogicCompiler;
use serde_json::json;
use std::collections::HashMap;
fn dv(v: serde_json::Value) -> OwnedDataValue {
OwnedDataValue::from(&v)
}
fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
let compiler = LogicCompiler::new();
let workflow = Workflow::from_json(json).expect("workflow should parse");
let compiled = compiler
.compile_workflows(vec![workflow])
.expect("workflow should compile");
(
compiled.into_iter().next().expect("one workflow"),
compiler.into_engine(),
)
}
fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
let task_executor = Arc::new(TaskExecutor::new(
Arc::new(HashMap::new()),
Arc::clone(&engine),
));
WorkflowExecutor::new(task_executor, engine)
}
fn counters(message: &Message) -> Vec<Option<i64>> {
message
.audit_trail
.iter()
.map(|entry| entry.loop_counter)
.collect()
}
const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
#[tokio::test]
async fn loop_without_a_condition_runs_exactly_max_sweeps() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
"tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert!(executed);
assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
}
#[tokio::test]
async fn loop_exits_early_when_the_condition_goes_false() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
"loop": {{"counter": "i", "max": 10}},
"tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
}
#[tokio::test]
async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w", "condition": false,
"loop": {{"counter": "i", "max": 5}},
"tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("a skip is not an error");
assert!(!executed, "a never-entered loop reports as skipped");
assert!(message.audit_trail.is_empty());
}
#[tokio::test]
async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
"tasks": [
{"id": "gate", "name": "gate", "function": {"name": "filter",
"input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
"on_reject": "halt"}}},
{"id": "body", "name": "body", "function": {"name": "map",
"input": {"mappings": [
{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("a halt is not an error");
let ids: Vec<&str> = message
.audit_trail
.iter()
.map(|entry| entry.task_id.as_ref())
.collect();
assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
assert_eq!(
counters(&message),
vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
);
}
#[tokio::test]
async fn init_and_increment_drive_the_counter() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
"tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
}
#[tokio::test]
async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"max": 2},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(counters(&message), vec![Some(0), Some(1)]);
assert_eq!(message.context["temp_data"], dv(json!({})));
}
#[tokio::test]
async fn a_non_looping_workflow_records_no_loop_counter() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w",
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("should complete");
assert_eq!(counters(&message), vec![None]);
}
#[tokio::test]
async fn progress_metadata_is_written_on_every_sweep() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
"tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
let progress = message.context["metadata"]
.get("progress")
.expect("progress must be written");
assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
}
#[tokio::test]
async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
"tasks": [{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(
counters(&message),
vec![Some(0), Some(1), Some(2)],
"the body's write must not stall or skew the loop"
);
}
async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "init": {init},
"increment": {increment}, "max": {max}}},
"tasks": [{{"id": "t", "name": "t",
"function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
));
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
counters(&message)
}
#[tokio::test]
async fn counter_sequence_matrix_over_init_increment_and_max() {
let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
(0, 1, 1, vec![0]),
(0, 1, 2, vec![0, 1]),
(0, 1, 5, vec![0, 1, 2, 3, 4]),
(0, 2, 6, vec![0, 2, 4]),
(0, 3, 10, vec![0, 3, 6, 9]),
(0, 5, 3, vec![0]),
(0, 100, 1, vec![0]),
(10, 5, 25, vec![10, 15, 20]),
(3, 1, 6, vec![3, 4, 5]),
(-3, 1, 2, vec![-3, -2, -1, 0, 1]),
(-4, 2, 1, vec![-4, -2, 0]),
(-10, 5, -5, vec![-10]),
];
for (init, increment, max, expected) in cases {
let got = counter_sequence(init, increment, max).await;
let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
assert_eq!(got, expected, "init={init} increment={increment} max={max}");
}
}
#[tokio::test]
async fn the_counter_advance_saturates_instead_of_overflowing() {
assert_eq!(
counter_sequence(0, i64::MAX, 5).await,
vec![Some(0)],
"one sweep, then the advance saturates past max"
);
assert_eq!(
counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
vec![Some(i64::MAX - 1)],
"the last representable sweep still terminates"
);
assert_eq!(
counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
vec![Some(i64::MAX - 2)]
);
}
#[tokio::test]
async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
"tasks": [
{"id": "evens", "name": "evens",
"condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
"function": {"name": "map", "input": {"mappings": []}}},
{"id": "always", "name": "always",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
let entries: Vec<(&str, Option<i64>)> = message
.audit_trail
.iter()
.map(|e| (e.task_id.as_ref(), e.loop_counter))
.collect();
assert_eq!(
entries,
[
("evens", Some(0)),
("always", Some(0)),
("always", Some(1)),
("evens", Some(2)),
("always", Some(2)),
("always", Some(3)),
],
"the gated task runs only on even counters"
);
}
#[tokio::test]
async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
"tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
"input": {"condition": false, "on_reject": "skip"}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("skip is not an error");
assert!(executed, "sweeps ran even though every task skipped");
assert!(message.audit_trail.is_empty(), "Skip records no entry");
assert_eq!(
message.context["temp_data"].get("i"),
Some(&dv(json!(3))),
"the loop still ran to its bound"
);
}
#[tokio::test]
async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
"tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
"input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("a 4xx does not stop the workflow");
assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
assert!(
message.audit_trail.iter().all(|e| e.status == 400),
"every sweep recorded the 4xx"
);
}
#[tokio::test]
async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w",
"rollout": {"bucket_start": 0, "bucket_end": 50},
"loop": {"counter": "i", "max": 5},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::builder().routing_bucket(75).build();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("an excluded workflow is not an error");
assert!(!executed);
assert!(message.audit_trail.is_empty());
assert_eq!(
message.context["temp_data"].get("i"),
None,
"no counter is written for an excluded workflow"
);
}
#[tokio::test]
async fn a_nested_counter_path_is_created_and_advanced() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w",
"loop": {"counter": "cursor.index", "max": 3},
"tasks": [{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [
{"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
);
let mut message = Message::from_value(&json!({}));
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(
message.context["temp_data"]["cursor"].get("index"),
Some(&dv(json!(3)))
);
assert_eq!(
message.context["data"].get("seen"),
Some(&dv(json!(2))),
"the body read the nested counter"
);
}
#[tokio::test]
async fn writing_the_counter_preserves_unrelated_temp_data() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::builder()
.temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
.build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(
message.context["temp_data"].get("keep"),
Some(&dv(json!("me")))
);
assert_eq!(
message.context["temp_data"]["nested"].get("a"),
Some(&dv(json!(1)))
);
assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
}
#[tokio::test]
async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#,
);
let mut message = Message::builder()
.temp_data(dv(json!({"i": "not a number"})))
.build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(counters(&message), vec![Some(5), Some(6)]);
assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
}
#[tokio::test]
async fn a_loop_records_audit_entries_with_capture_changes_off() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
"tasks": [{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [
{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
);
let mut message = Message::builder().capture_changes(false).build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(counters(&message), vec![Some(0), Some(1)]);
assert!(
message.audit_trail.iter().all(|e| e.changes.is_empty()),
"no diffs captured, but the entries are still there"
);
}
#[tokio::test]
async fn two_loops_sharing_a_counter_name_do_not_interfere() {
let first = r#"{ "id": "a", "name": "a", "priority": 0,
"loop": {"counter": "i", "max": 2},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#;
let second = r#"{ "id": "b", "name": "b", "priority": 1,
"loop": {"counter": "i", "init": 10, "max": 12},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": []}}}] }"#;
let compiler = LogicCompiler::new();
let workflows = compiler
.compile_workflows(vec![
Workflow::from_json(first).unwrap(),
Workflow::from_json(second).unwrap(),
])
.expect("should compile");
let exec = executor(compiler.into_engine());
let mut message = Message::from_value(&json!({}));
exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
.await
.expect("both loops should complete");
let per_workflow: Vec<(&str, Option<i64>)> = message
.audit_trail
.iter()
.map(|e| (e.workflow_id.as_ref(), e.loop_counter))
.collect();
assert_eq!(
per_workflow,
[
("a", Some(0)),
("a", Some(1)),
("b", Some(10)),
("b", Some(11)),
]
);
}
#[tokio::test]
async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
let sync_wf = |id: &str, priority: u32| {
format!(
r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
"tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
"input": {{"mappings": [
{{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
)
};
let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
"loop": {"counter": "i", "max": 2},
"tasks": [{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
let compiler = LogicCompiler::new();
let workflows = compiler
.compile_workflows(vec![
Workflow::from_json(&sync_wf("before", 0)).unwrap(),
Workflow::from_json(loop_wf).unwrap(),
Workflow::from_json(&sync_wf("after", 2)).unwrap(),
])
.expect("should compile");
assert!(workflows.iter().all(|w| w.fully_sync));
assert!(!joins_sync_run(&workflows[1]));
let exec = executor(compiler.into_engine());
let mut message = Message::from_value(&json!({}));
exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
.await
.expect("all three should run");
for id in ["before", "mid", "after"] {
assert_eq!(
message.context["data"].get(id),
Some(&dv(json!(true))),
"workflow {id} must have run"
);
}
let order: Vec<(&str, Option<i64>)> = message
.audit_trail
.iter()
.map(|e| (e.workflow_id.as_ref(), e.loop_counter))
.collect();
assert_eq!(
order,
[
("before", None),
("mid", Some(0)),
("mid", Some(1)),
("after", None),
],
"priority order is preserved across the split"
);
}
#[tokio::test]
async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
let compiler = LogicCompiler::new();
let workflows = compiler
.compile_workflows(vec![
Workflow::from_json(
r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": [
{"path": "data.a", "logic": 1}]}}}] }"#,
)
.unwrap(),
Workflow::from_json(
r#"{ "id": "b", "name": "b", "priority": 1,
"condition": {"==": [{"var": "data.a"}, 1]},
"tasks": [{"id": "t", "name": "t",
"function": {"name": "map", "input": {"mappings": [
{"path": "data.b", "logic": 2}]}}}] }"#,
)
.unwrap(),
])
.expect("should compile");
assert!(workflows.iter().all(joins_sync_run));
let exec = executor(compiler.into_engine());
let mut message = Message::from_value(&json!({}));
exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
.await
.expect("both should run");
assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
assert_eq!(counters(&message), vec![None, None]);
}
#[tokio::test]
async fn a_loop_body_can_index_an_array_by_its_counter() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
"tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
"input": {"mappings": [
{"path": "data.picked",
"logic": {"merge": [{"var": "data.picked"},
[{"val": [["data", "items",
{"var": "temp_data.i"}]]}]]}}]}}}] }"#,
);
let mut message = Message::builder()
.data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
.build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("loop should complete");
assert_eq!(
serde_json::Value::from(&message.context["data"]["picked"]),
json!(["a", "b", "c"]),
"each sweep appended the item at its own index"
);
}
#[tokio::test]
async fn test_workflow_executor_skip_condition() {
let workflow_json = r#"{
"id": "test_workflow",
"name": "Test Workflow",
"condition": false,
"tasks": [{
"id": "dummy_task",
"name": "Dummy Task",
"function": {
"name": "map",
"input": {"mappings": []}
}
}]
}"#;
let compiler = LogicCompiler::new();
let mut workflow = Workflow::from_json(workflow_json).unwrap();
let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
workflow = compiled_workflow.clone();
}
let engine = compiler.into_engine();
let task_executor = Arc::new(TaskExecutor::new(
Arc::new(HashMap::new()),
Arc::clone(&engine),
));
let workflow_executor = WorkflowExecutor::new(task_executor, engine);
let mut message = Message::from_value(&json!({}));
let executed = workflow_executor
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(!executed);
assert_eq!(message.audit_trail.len(), 0);
}
#[tokio::test]
async fn test_workflow_executor_execute_success() {
let workflow_json = r#"{
"id": "test_workflow",
"name": "Test Workflow",
"condition": true,
"tasks": [{
"id": "dummy_task",
"name": "Dummy Task",
"function": {
"name": "map",
"input": {"mappings": []}
}
}]
}"#;
let compiler = LogicCompiler::new();
let mut workflow = Workflow::from_json(workflow_json).unwrap();
let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
workflow = compiled_workflow.clone();
}
let engine = compiler.into_engine();
let task_executor = Arc::new(TaskExecutor::new(
Arc::new(HashMap::new()),
Arc::clone(&engine),
));
let workflow_executor = WorkflowExecutor::new(task_executor, engine);
let mut message = Message::from_value(&json!({}));
let executed = workflow_executor
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
}
}