use crate::engine::error::{
DataflowError, ErrorContextConfig, ErrorInfo, Result, from_datalogic_eval, service_error_code,
};
use crate::engine::executor::{
ArenaContext, eval_to_owned, evaluate_condition, evaluate_condition_in_arena, with_arena,
};
use crate::engine::for_each::ForEach;
use crate::engine::functions::BoxedFunctionHandler;
use crate::engine::message::{AuditTrail, Change, Message};
use crate::engine::observer::{
ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
WorkflowStarted,
};
use crate::engine::task::{HaltOn, Task};
use crate::engine::task_context::{CallStamp, TaskIdentity};
use crate::engine::task_executor::TaskExecutor;
use crate::engine::task_outcome::TaskOutcome;
use crate::engine::trace::{
ExecutionStep, ExecutionTrace, StepStamp, StepTiming, duration_us_between,
};
use crate::engine::utils::{
compute_path_parts, describe_kind, get_nested_value_parts, remove_nested_value,
set_nested_value, set_nested_value_parts, strip_hash_prefix,
};
use crate::engine::workflow::{LoopConfig, Workflow};
use chrono::{DateTime, Utc};
use core::time::Duration;
use datalogic_rs::{Engine, Logic};
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(Default)]
struct WorkflowSpan {
started_at: Option<DateTime<Utc>>,
sweeps: u32,
}
#[derive(Clone, Copy)]
struct PassCtx {
now: DateTime<Utc>,
loop_counter: Option<i64>,
element_index: Option<usize>,
}
impl PassCtx {
#[inline]
fn once(now: DateTime<Utc>) -> Self {
Self {
now,
loop_counter: None,
element_index: None,
}
}
fn note_executed(
self,
trace: Option<&mut ExecutionTrace>,
workflow_id: &str,
task_id: &str,
message: &Message,
clocks: TaskClocks,
mapping_contexts: Option<Vec<Value>>,
) {
let Some(t) = trace else {
return;
};
let started_at = clocks.trace_start.unwrap_or(self.now);
t.add_executed_step(
workflow_id,
task_id,
message,
StepTiming {
started_at,
duration_us: duration_us_between(started_at, Utc::now()),
},
mapping_contexts,
StepStamp {
loop_counter: self.loop_counter,
element_index: self.element_index,
},
);
}
}
impl PassCtx {
fn note_executed_timed(
self,
trace: Option<&mut ExecutionTrace>,
workflow_id: &str,
task_id: &str,
message: &Message,
timing: StepTiming,
) {
if let Some(t) = trace {
t.add_executed_step(
workflow_id,
task_id,
message,
timing,
None,
StepStamp {
loop_counter: self.loop_counter,
element_index: self.element_index,
},
);
}
}
}
#[derive(Clone, Copy)]
struct TaskClocks {
trace_start: Option<DateTime<Utc>>,
obs_start: Option<DateTime<Utc>>,
errors_before: usize,
}
#[derive(Clone, Copy)]
struct PassList<'a> {
workflow: &'a Workflow,
tasks: &'a [Task],
}
impl<'a> PassList<'a> {
#[inline]
fn body(workflow: &'a Workflow) -> Self {
Self {
workflow,
tasks: &workflow.tasks,
}
}
}
enum Admission {
Halt,
Jump(usize),
Skip,
Run,
}
fn admit_task(
list: PassList<'_>,
task: &Task,
abs: usize,
gate: &mut GroupGate,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
mut eval: impl FnMut(Option<&Arc<Logic>>) -> Result<bool>,
) -> Result<Admission> {
if gate.close_through(abs) {
return Ok(Admission::Halt);
}
if let Some(target) = gate.enter(task, &mut eval)? {
note_group_skip(trace.as_deref_mut(), list, abs, target, pass.loop_counter);
return Ok(Admission::Jump(target));
}
if !eval(task.compiled_condition.as_ref())? {
note_task_skip(trace, &list.workflow.id, &task.id, pass.loop_counter);
return Ok(Admission::Skip);
}
Ok(Admission::Run)
}
#[derive(Clone, Copy)]
struct TaskPass {
continue_on_error: bool,
terminal: bool,
halt_on: HaltOn,
errors_before: usize,
}
impl TaskPass {
#[inline]
fn halts_at(self, status: u16) -> bool {
self.terminal || (self.halt_on == HaltOn::Failure && status >= 400)
}
}
#[derive(Clone, Copy)]
struct ElementCall<'a> {
workflow: &'a Workflow,
task: &'a Task,
for_each: &'a ForEach,
loop_counter: Option<i64>,
timed: bool,
}
struct ElementOutcome {
index: usize,
result: Result<TaskOutcome>,
changes: Vec<Change>,
new_errors: Vec<ErrorInfo>,
collected: OwnedDataValue,
timing: Option<StepTiming>,
}
impl ElementOutcome {
fn failed(&self) -> bool {
match &self.result {
Err(_) => true,
Ok(outcome) => outcome.audit_status().is_some_and(|s| s >= 400),
}
}
fn stops_fan_out(&self, continue_on_error: bool) -> bool {
match &self.result {
Err(_) => !continue_on_error,
Ok(TaskOutcome::Halt) => true,
Ok(outcome) => !continue_on_error && outcome.audit_status().is_some_and(|s| s >= 500),
}
}
}
fn fan_out_pass(task: &Task, errors_before: usize) -> TaskPass {
TaskPass {
continue_on_error: task.continue_on_error,
terminal: false,
halt_on: HaltOn::Never,
errors_before,
}
}
fn fan_out_flow(task: &Task, halt: bool) -> TaskControlFlow {
if halt || task.terminal {
TaskControlFlow::HaltWorkflow
} else {
TaskControlFlow::Continue
}
}
struct TaskSlice<'a, 'arena> {
tasks: &'arena [Task],
offset: usize,
gate: &'a mut GroupGate,
list: PassList<'arena>,
}
enum SliceOutcome {
Completed,
Halted,
JumpTo(usize),
}
#[derive(Default)]
struct GroupGate {
open: Vec<(usize, bool)>,
}
impl GroupGate {
fn close_through(&mut self, idx: usize) -> bool {
let mut terminal = false;
while let Some(&(end, is_terminal)) = self.open.last() {
if end > idx {
break;
}
self.open.pop();
terminal |= is_terminal;
}
terminal
}
fn enter<F>(&mut self, task: &Task, mut eval: F) -> Result<Option<usize>>
where
F: FnMut(Option<&Arc<Logic>>) -> Result<bool>,
{
for group in &task.group_starts {
if !eval(group.compiled_condition.as_ref())? {
return Ok(Some(group.end));
}
self.open.push((group.end, group.terminal));
}
Ok(None)
}
}
fn note_group_skip(
mut trace: Option<&mut ExecutionTrace>,
list: PassList<'_>,
from: usize,
to: usize,
loop_counter: Option<i64>,
) {
for task in &list.tasks[from..to.min(list.tasks.len())] {
note_task_skip(
trace.as_deref_mut(),
&list.workflow.id,
&task.id,
loop_counter,
);
}
}
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, message.routing_bucket()) {
(Some(r), Some(b)) => r.accepts(b),
_ => true,
}
}
fn joins_sync_run(workflow: &Workflow) -> bool {
workflow.fully_sync && workflow.loop_config.is_none()
}
fn resolve_parts(name: Option<&str>, precomputed: &Arc<[Arc<str>]>) -> Arc<[Arc<str>]> {
match name {
Some(name) if precomputed.is_empty() => compute_path_parts("temp_data", name),
_ => Arc::clone(precomputed),
}
}
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
&& 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
&& 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),
);
}
fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
OwnedDataValue::Object(vec![
("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
("task_id".to_string(), OwnedDataValue::from(task_id)),
("code".to_string(), OwnedDataValue::from(code)),
(
"status".to_string(),
OwnedDataValue::from(u64::from(status)),
),
])
}
fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
if !matches!(node, OwnedDataValue::Object(_)) {
*node = OwnedDataValue::Object(Vec::new());
}
match node {
OwnedDataValue::Object(fields) => fields,
_ => unreachable!("just normalised to an Object"),
}
}
fn append_error_records(
context: &mut OwnedDataValue,
cfg: &ErrorContextConfig,
workflow_id: &str,
task_id: &str,
status: u16,
new_errors: &[ErrorInfo],
) {
if new_errors.is_empty() {
return;
}
let Some((last, parents)) = cfg.path_parts.split_last() else {
return;
};
let mut node = context;
for part in parents {
let key = strip_hash_prefix(part);
if !matches!(node, OwnedDataValue::Object(_)) {
warn!(
"error context path `{}` runs through a non-object at `{}` — replacing it",
cfg.path, key
);
}
let fields = as_object_slot(node);
let idx = match fields.iter().position(|(k, _)| k == key) {
Some(i) => i,
None => {
fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
fields.len() - 1
}
};
node = &mut fields[idx].1;
}
let key = strip_hash_prefix(last);
let fields = as_object_slot(node);
let idx = match fields.iter().position(|(k, _)| k == key) {
Some(i) => i,
None => {
fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
fields.len() - 1
}
};
let slot = &mut fields[idx].1;
if !matches!(slot, OwnedDataValue::Array(_)) {
warn!(
"error context path `{}` held a non-array — replacing it",
cfg.path
);
*slot = OwnedDataValue::Array(Vec::new());
}
let OwnedDataValue::Array(items) = slot else {
unreachable!("just ensured an Array");
};
for error in new_errors {
items.push(new_error_record(workflow_id, task_id, &error.code, status));
}
if items.len() > cfg.limit {
items.drain(..items.len() - cfg.limit);
}
}
pub struct WorkflowExecutor {
task_executor: Arc<TaskExecutor>,
engine: Arc<Engine>,
observer: Option<Arc<dyn ExecutionObserver>>,
error_context: Option<Arc<ErrorContextConfig>>,
}
impl WorkflowExecutor {
pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
Self {
task_executor,
engine,
observer: None,
error_context: 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()
}
pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
self.error_context = Some(cfg);
self
}
pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
self.error_context.as_ref()
}
#[inline]
fn emit_task_event(
&self,
workflow: &Workflow,
task: &Task,
result: &Result<(TaskOutcome, Vec<Change>)>,
started_at: Option<DateTime<Utc>>,
) {
if self.observer.is_some() {
let duration = started_at
.map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
.unwrap_or_default();
self.emit_task_finished(workflow, task, result, duration);
}
}
fn emit_task_finished(
&self,
workflow: &Workflow,
task: &Task,
result: &Result<(TaskOutcome, Vec<Change>)>,
duration: Duration,
) {
if let Some(observer) = self.observer.as_ref() {
let status = match result {
Ok((outcome, _)) => outcome.audit_status(),
Err(_) => Some(500),
};
observer.task_finished(&TaskEvent {
workflow_id: &workflow.id,
task_id: &task.id,
function: task.function.function_name(),
status,
duration,
});
}
}
fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
span.sweeps += 1;
let Some(observer) = self.observer.as_ref() else {
return;
};
if span.started_at.is_some() {
return;
}
span.started_at = Some(Utc::now());
observer.workflow_started(&WorkflowStarted {
workflow_id: &workflow.id,
});
}
fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
let Some(observer) = self.observer.as_ref() else {
return;
};
let Some(started) = span.started_at else {
return;
};
observer.workflow_finished(&WorkflowFinished {
workflow_id: &workflow.id,
duration: Duration::from_micros(duration_us_between(started, Utc::now())),
sweeps: span.sweeps,
halted,
});
}
#[inline]
fn observer_clock(&self) -> Option<DateTime<Utc>> {
self.observer.as_ref().map(|_| Utc::now())
}
#[inline]
fn open_task_clocks(&self, tracing: bool, message: &Message) -> TaskClocks {
let trace_start = if tracing { Some(Utc::now()) } else { None };
TaskClocks {
trace_start,
obs_start: trace_start.or_else(|| self.observer_clock()),
errors_before: message.errors.len(),
}
}
pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
self.task_executor.task_functions()
}
pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
self.task_executor.registry()
}
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;
}
let mut span = WorkflowSpan::default();
let outcome = self
.execute_pass(
PassList::body(workflow),
message,
trace.as_deref_mut(),
PassCtx::once(now),
&mut span,
)
.await;
self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
match outcome {
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 counter_parts = resolve_parts(config.counter.as_deref(), &config.counter_parts);
let item_parts = resolve_parts(config.item.as_deref(), &config.item_parts);
let scratch_parts = resolve_parts(config.scratch.as_deref(), &config.scratch_parts);
let body = PassList::body(workflow);
let mut span = WorkflowSpan::default();
let mut halted = false;
let mut admitted = false;
let mut items: Option<Vec<OwnedDataValue>> = None;
if !config.setup.is_empty() || config.over.is_some() {
let setup = PassList {
workflow,
tasks: &config.setup,
};
match self
.execute_pass(
setup,
message,
trace.as_deref_mut(),
PassCtx::once(now),
&mut span,
)
.await
{
Ok(PassOutcome::ConditionFalse) => {
note_workflow_skip(trace, &workflow.id, "condition not met");
return Ok(false);
}
Ok(PassOutcome::Halted) => {
span.sweeps = 0;
self.end_workflow(&span, workflow, true);
info!(
"Successfully completed workflow: {} (halted in loop setup)",
workflow.id
);
return Ok(true);
}
Ok(PassOutcome::Completed) => {}
Err(e) => {
span.sweeps = 0;
let stop = self.record_workflow_error(workflow, message, &e);
self.end_workflow(&span, workflow, false);
return if stop { Err(e) } else { Ok(true) };
}
}
admitted = true;
span.sweeps = 0;
if config.over.is_some() {
match self.evaluate_over(workflow, config, message) {
Ok(list) => items = Some(list),
Err(e) => {
let stop = self.record_workflow_error(workflow, message, &e);
self.end_workflow(&span, workflow, false);
return if stop { Err(e) } else { Ok(true) };
}
}
}
}
let mut counter = config.init;
let mut sweeps_run: u32 = 0;
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
);
}
if let Some(items) = &items
&& let Ok(next) = usize::try_from(counter)
&& next < items.len()
{
warn!(
"Workflow {} stopped at its loop bound (max {}) with {} of {} \
element(s) of loop.over unvisited",
workflow.id,
config.max,
items.len() - next,
items.len()
);
}
break;
}
if let Some(items) = items.as_mut() {
let Some(slot) = usize::try_from(counter).ok().and_then(|i| items.get_mut(i))
else {
break;
};
let element = std::mem::take(slot);
set_nested_value_parts(&mut message.context, &item_parts, element);
}
set_nested_value_parts(
&mut message.context,
&scratch_parts,
OwnedDataValue::Object(Vec::new()),
);
let pass = PassCtx {
now,
loop_counter: Some(counter),
element_index: None,
};
match self
.execute_pass(body, message, trace.as_deref_mut(), pass, &mut span)
.await
{
Ok(PassOutcome::ConditionFalse) => {
if sweeps_run == 0 && !admitted {
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
);
halted = true;
break;
}
Ok(PassOutcome::Completed) => {
sweeps_run += 1;
}
Err(e) => {
sweeps_run += 1;
if self.record_workflow_error(workflow, message, &e) {
self.end_workflow(&span, workflow, halted);
return Err(e);
}
}
}
counter = counter.saturating_add(config.increment);
}
self.end_workflow(&span, workflow, halted);
if sweeps_run > 0 {
info!(
"Successfully completed workflow: {} ({} loop sweep(s))",
workflow.id, sweeps_run
);
}
Ok(sweeps_run > 0 || admitted)
}
async fn run_for_each(
&self,
workflow: &Workflow,
task: &Task,
for_each: &ForEach,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<TaskControlFlow> {
use futures_util::stream::{FuturesUnordered, StreamExt};
let tracing = trace.is_some();
let items = match self.evaluate_for_each_over(task, for_each, message) {
Ok(items) if !items.is_empty() => items,
other => {
let failed = other.is_err();
if other.is_ok() && !for_each.into_parts.is_empty() {
self.write_empty_results(for_each, message);
}
let clocks = self.open_task_clocks(tracing, message);
let result = other.map(|_| {
let changes = self.empty_results_change(for_each, message);
(TaskOutcome::Success, changes)
});
self.emit_task_event(workflow, task, &result, clocks.obs_start);
let flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
fan_out_pass(task, clocks.errors_before),
message,
pass,
)?;
pass.note_executed(trace, &workflow.id, &task.id, message, clocks, None);
let halt = matches!(flow, TaskControlFlow::HaltWorkflow)
|| (failed && task.halt_on == HaltOn::Failure);
return Ok(fan_out_flow(task, halt));
}
};
let n = items.len();
let call = ElementCall {
workflow,
task,
for_each,
loop_counter: pass.loop_counter,
timed: tracing || self.observer.is_some(),
};
let mut outcomes: Vec<Option<ElementOutcome>> = (0..n).map(|_| None).collect();
{
let base: &Message = message;
let mut pending = items.into_iter().enumerate();
let mut in_flight = FuturesUnordered::new();
let mut stop = false;
loop {
while !stop && in_flight.len() < for_each.max_concurrency {
let Some((index, item)) = pending.next() else {
break;
};
in_flight.push(self.run_element(call, base, index, item));
}
let Some(outcome) = in_flight.next().await else {
break;
};
stop |= outcome.stops_fan_out(task.continue_on_error);
let index = outcome.index;
outcomes[index] = Some(outcome);
}
}
if !for_each.into_parts.is_empty() {
set_nested_value_parts(
&mut message.context,
&for_each.into_parts,
OwnedDataValue::Array(vec![OwnedDataValue::Null; n]),
);
}
let mut halt = false;
let mut any_failed = false;
for outcome in outcomes.into_iter().flatten() {
let failed = outcome.failed();
let ElementOutcome {
index,
result,
changes,
new_errors,
collected,
timing,
} = outcome;
let errors_before = message.errors.len();
message
.errors
.extend(new_errors.into_iter().map(|mut error| {
error.element_index.get_or_insert(index);
error
}));
for change in &changes {
if change.removed {
remove_nested_value(&mut message.context, &change.path);
} else {
set_nested_value(&mut message.context, &change.path, change.new_value.clone());
}
}
let mut changes = if message.capture_changes {
changes
} else {
Vec::new()
};
if let Some(into) = for_each.into.as_deref() {
let value = if failed {
OwnedDataValue::Null
} else {
collected
};
if message.capture_changes {
changes.push(Change {
path: Arc::from(format!("{into}.{index}")),
old_value: OwnedDataValue::Null,
new_value: value.clone(),
removed: false,
});
}
let mut parts = for_each.into_parts.to_vec();
parts.push(Arc::from(index.to_string()));
set_nested_value_parts(&mut message.context, &parts, value);
}
any_failed |= failed;
let result = result.map(|outcome| (outcome, changes));
let element = PassCtx {
element_index: Some(index),
..pass
};
let duration = timing
.map(|t| Duration::from_micros(t.duration_us))
.unwrap_or_default();
self.emit_task_finished(workflow, task, &result, duration);
let flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
fan_out_pass(task, errors_before),
message,
element,
)?;
if let Some(timing) = timing {
element.note_executed_timed(
trace.as_deref_mut(),
&workflow.id,
&task.id,
message,
timing,
);
}
if matches!(flow, TaskControlFlow::HaltWorkflow) {
halt = true;
break;
}
}
if halt {
info!(
"Task {} halted workflow {} during its fan-out",
task.id, workflow.id
);
}
Ok(fan_out_flow(
task,
halt || (task.halt_on == HaltOn::Failure && any_failed),
))
}
async fn run_element(
&self,
call: ElementCall<'_>,
base: &Message,
index: usize,
item: OwnedDataValue,
) -> ElementOutcome {
let fe = call.for_each;
let mut copy = base.fork_for_element();
set_nested_value_parts(&mut copy.context, &fe.item_parts, item);
set_nested_value_parts(
&mut copy.context,
&fe.index_parts,
OwnedDataValue::from(index as u64),
);
let started_at = call.timed.then(Utc::now);
let result = self
.task_executor
.execute_in_workflow(
call.task,
&mut copy,
Some(TaskIdentity {
workflow_id: &call.workflow.id_arc,
task_id: &call.task.id_arc,
}),
CallStamp {
loop_counter: call.loop_counter,
element_index: Some(index),
},
)
.await;
let timing = started_at.map(|started_at| StepTiming {
started_at,
duration_us: duration_us_between(started_at, Utc::now()),
});
let (result, changes) = match result {
Ok((outcome, changes)) => (Ok(outcome), changes),
Err(e) => (Err(e), Vec::new()),
};
let collected = if fe.collect_parts.is_empty() {
OwnedDataValue::Null
} else {
get_nested_value_parts(©.context, &fe.collect_parts)
.cloned()
.unwrap_or(OwnedDataValue::Null)
};
ElementOutcome {
index,
result,
changes,
new_errors: copy.errors,
collected,
timing,
}
}
fn evaluate_for_each_over(
&self,
task: &Task,
for_each: &ForEach,
message: &Message,
) -> Result<Vec<OwnedDataValue>> {
let Some(compiled) = for_each.compiled_over.as_ref() else {
return Err(DataflowError::Task(format!(
"Task {}: for_each.over is not compiled — build the engine through \
Engine::builder or Engine::new rather than running the workflow directly",
task.id
)));
};
match eval_to_owned(&self.engine, compiled, &message.context) {
Ok(OwnedDataValue::Array(items)) => Ok(items),
Ok(other) => Err(DataflowError::Task(format!(
"Task {}: for_each.over must evaluate to an array, got {}",
task.id,
describe_kind(&other)
))),
Err(e) => match from_datalogic_eval(&e) {
budget @ DataflowError::BudgetExceeded(_) => Err(budget),
other => Err(DataflowError::Task(format!(
"Task {}: for_each.over failed to evaluate: {other}",
task.id
))),
},
}
}
fn write_empty_results(&self, for_each: &ForEach, message: &mut Message) {
set_nested_value_parts(
&mut message.context,
&for_each.into_parts,
OwnedDataValue::Array(Vec::new()),
);
}
fn empty_results_change(&self, for_each: &ForEach, message: &Message) -> Vec<Change> {
match for_each.into.as_deref() {
Some(into) if message.capture_changes => vec![Change {
path: Arc::from(into),
old_value: OwnedDataValue::Null,
new_value: OwnedDataValue::Array(Vec::new()),
removed: false,
}],
_ => Vec::new(),
}
}
fn evaluate_over(
&self,
workflow: &Workflow,
config: &LoopConfig,
message: &Message,
) -> Result<Vec<OwnedDataValue>> {
let Some(compiled) = config.compiled_over.as_ref() else {
return Err(DataflowError::Workflow(format!(
"Workflow {}: loop.over is not compiled — build the engine through \
Engine::builder or Engine::new rather than running the workflow directly",
workflow.id
)));
};
match eval_to_owned(&self.engine, compiled, &message.context) {
Ok(OwnedDataValue::Array(items)) => Ok(items),
Ok(other) => Err(DataflowError::Workflow(format!(
"Workflow {}: loop.over must evaluate to an array, got {}",
workflow.id,
describe_kind(&other)
))),
Err(e) => Err(DataflowError::Workflow(format!(
"Workflow {}: loop.over failed to evaluate: {}",
workflow.id,
from_datalogic_eval(&e)
))),
}
}
async fn execute_pass(
&self,
list: PassList<'_>,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
span: &mut WorkflowSpan,
) -> Result<PassOutcome> {
enum FirstStretch {
Skipped,
Halted,
Continue(usize),
}
let PassList { workflow, tasks } = list;
let first_boundary = next_async_boundary(tasks, 0);
let mut gate = GroupGate::default();
let first: Result<FirstStretch> =
if workflow.compiled_condition.is_none() && first_boundary == 0 {
self.begin_workflow(span, workflow);
Ok(FirstStretch::Continue(0))
} 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);
}
self.begin_workflow(span, workflow);
if first_boundary == 0 {
return Ok(FirstStretch::Continue(0));
}
let outcome = self.run_tasks_slice_in_arena(
TaskSlice {
tasks: &tasks[..first_boundary],
offset: 0,
gate: &mut gate,
list,
},
message,
&mut arena_ctx,
trace.as_deref_mut(),
pass,
)?;
Ok(match outcome {
SliceOutcome::Halted => FirstStretch::Halted,
SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
})
})
};
match first? {
FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
FirstStretch::Halted => Ok(PassOutcome::Halted),
FirstStretch::Continue(resume_at) => {
let halted = self
.execute_tasks(list, message, trace, pass, resume_at, &mut gate)
.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,
list: PassList<'_>,
message: &mut Message,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
start: usize,
gate: &mut GroupGate,
) -> Result<bool> {
let PassList { workflow, tasks } = list;
let mut idx = start;
while idx < tasks.len() {
let stretch_end = next_async_boundary(tasks, idx);
if stretch_end > idx {
match self.run_sync_stretch(
TaskSlice {
tasks: &tasks[idx..stretch_end],
offset: idx,
gate,
list,
},
message,
trace.as_deref_mut(),
pass,
)? {
SliceOutcome::Halted => return Ok(true),
SliceOutcome::JumpTo(target) => {
idx = target;
continue;
}
SliceOutcome::Completed => idx = stretch_end,
}
}
if idx < tasks.len() {
let task = &tasks[idx];
match admit_task(
list,
task,
idx,
gate,
trace.as_deref_mut(),
pass,
|compiled| evaluate_condition(&self.engine, compiled, &message.context),
)? {
Admission::Halt => return Ok(true),
Admission::Jump(target) => {
idx = target;
continue;
}
Admission::Skip => {
idx += 1;
continue;
}
Admission::Run => {}
}
if let Some(for_each) = task.for_each.as_ref() {
let flow = self
.run_for_each(
workflow,
task,
for_each,
message,
trace.as_deref_mut(),
pass,
)
.await?;
if matches!(flow, TaskControlFlow::HaltWorkflow) {
return Ok(true);
}
idx += 1;
continue;
}
let clocks = self.open_task_clocks(trace.is_some(), message);
let result = self
.task_executor
.execute_in_workflow(
task,
message,
Some(TaskIdentity {
workflow_id: &workflow.id_arc,
task_id: &task.id_arc,
}),
CallStamp {
loop_counter: pass.loop_counter,
element_index: None,
},
)
.await;
self.emit_task_event(workflow, task, &result, clocks.obs_start);
let control_flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
TaskPass {
continue_on_error: task.continue_on_error,
terminal: task.terminal,
halt_on: task.halt_on,
errors_before: clocks.errors_before,
},
message,
pass,
)?;
pass.note_executed(
trace.as_deref_mut(),
&workflow.id,
&task.id,
message,
clocks,
None,
);
if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
return Ok(true);
}
idx += 1;
}
}
Ok(gate.close_through(tasks.len()))
}
fn run_sync_stretch(
&self,
slice: TaskSlice<'_, '_>,
message: &mut Message,
trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<SliceOutcome> {
with_arena(|arena| -> Result<SliceOutcome> {
let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
self.run_tasks_slice_in_arena(slice, message, &mut arena_ctx, trace, pass)
})
}
fn run_tasks_slice_in_arena<'arena>(
&self,
slice: TaskSlice<'_, 'arena>,
message: &mut Message,
arena_ctx: &mut ArenaContext<'arena>,
mut trace: Option<&mut ExecutionTrace>,
pass: PassCtx,
) -> Result<SliceOutcome> {
let arena = arena_ctx.arena();
let TaskSlice {
tasks,
offset,
gate,
list,
} = slice;
let workflow = list.workflow;
let slice_end = offset + tasks.len();
let mut i = 0;
while i < tasks.len() {
let task = &tasks[i];
let abs = offset + i;
match admit_task(
list,
task,
abs,
gate,
trace.as_deref_mut(),
pass,
|compiled| {
evaluate_condition_in_arena(
&self.engine,
compiled,
arena_ctx.as_data_value(),
arena,
)
},
)? {
Admission::Halt => return Ok(SliceOutcome::Halted),
Admission::Jump(target) => {
if target >= slice_end {
return Ok(SliceOutcome::JumpTo(target));
}
i = target - offset;
continue;
}
Admission::Skip => {
i += 1;
continue;
}
Admission::Run => {}
}
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 clocks = self.open_task_clocks(trace.is_some(), message);
let result =
self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
self.emit_task_event(workflow, task, &result, clocks.obs_start);
let flow = self.handle_task_result(
result,
&workflow.id_arc,
&task.id_arc,
TaskPass {
continue_on_error: task.continue_on_error,
terminal: task.terminal,
halt_on: task.halt_on,
errors_before: clocks.errors_before,
},
message,
pass,
);
arena_ctx.refresh_for_path(&message.context, "metadata.progress");
if let Some(cfg) = self.error_context_refresh(message, clocks.errors_before) {
arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
}
let control_flow = flow?;
pass.note_executed(
trace.as_deref_mut(),
&workflow.id,
&task.id,
message,
clocks,
Some(mapping_snapshots).filter(|s| !s.is_empty()),
);
if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
return Ok(SliceOutcome::Halted);
}
i += 1;
}
Ok(SliceOutcome::Completed)
}
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,
trace: Option<&mut ExecutionTrace>,
now: DateTime<Utc>,
) -> Result<()> {
let started_at = self.observer_clock();
if let Some(observer) = self.observer.as_ref() {
observer.message_started(&MessageStarted {
message_id: message.id(),
workflows_considered: workflows.len(),
});
}
let outcome = self.run_all_inner(workflows, message, trace, now).await;
if let Some(observer) = self.observer.as_ref() {
observer.message_finished(&MessageFinished {
message_id: message.id(),
duration: started_at
.map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
.unwrap_or_default(),
errors: message.errors().len(),
stopped_early: outcome.is_err(),
});
}
outcome
}
async fn run_all_inner<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;
}
let mut span = WorkflowSpan::default();
self.begin_workflow(&mut span, workflow);
let mut gate = GroupGate::default();
match self.run_tasks_slice_in_arena(
TaskSlice {
tasks: &workflow.tasks,
offset: 0,
gate: &mut gate,
list: PassList::body(workflow),
},
message,
&mut arena_ctx,
trace.as_deref_mut(),
pass,
) {
Ok(outcome) => {
self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
info!("Successfully completed workflow: {}", workflow.id);
}
Err(e) => {
self.end_workflow(&span, workflow, false);
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()
))
})?
}
#[inline]
fn mirror_task_errors(
&self,
message: &mut Message,
workflow_id: &str,
task_id: &str,
status: u16,
task: TaskPass,
) {
let Some(cfg) = self.error_context.as_ref() else {
return;
};
let Message {
context, errors, ..
} = message;
let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
}
#[inline]
fn error_context_refresh<'a>(
&'a self,
message: &Message,
errors_before: usize,
) -> Option<&'a Arc<ErrorContextConfig>> {
let cfg = self.error_context.as_ref()?;
if message.errors.len() > errors_before {
Some(cfg)
} else {
None
}
}
fn handle_task_result(
&self,
result: Result<(TaskOutcome, Vec<Change>)>,
workflow_id_arc: &Arc<str>,
task_id_arc: &Arc<str>,
task: TaskPass,
message: &mut Message,
pass: PassCtx,
) -> Result<TaskControlFlow> {
let workflow_id: &str = workflow_id_arc;
let task_id: &str = task_id_arc;
let continue_on_error = task.continue_on_error;
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_requested = outcome.halts_workflow() || task.halts_at(status);
message.record_audit(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,
element_index: pass.element_index,
});
write_progress_metadata(&mut message.context, workflow_id, task_id, status);
let flow = if (400..500).contains(&status) {
warn!("Task {} returned client error status: {}", task_id, status);
Ok(TaskControlFlow::Continue)
} else if status >= 500 {
error!("Task {} returned server error status: {}", task_id, status);
let mut info = ErrorInfo::builder(
"TASK_STATUS_ERROR",
format!("Task {} returned status {}", task_id, status),
)
.workflow_id(workflow_id)
.task_id(task_id);
if let Some(index) = pass.element_index {
info = info.element_index(index);
}
message.errors.push(info.build());
if continue_on_error {
Ok(TaskControlFlow::Continue)
} else {
Err(DataflowError::Task(format!(
"Task {} failed with status {}",
task_id, status
)))
}
} else {
Ok(TaskControlFlow::Continue)
};
let flow = match flow {
Ok(TaskControlFlow::Continue) if halt_requested => {
info!("Task {} halted workflow {}", task_id, workflow_id);
Ok(TaskControlFlow::HaltWorkflow)
}
other => other,
};
self.mirror_task_errors(message, workflow_id, task_id, status, task);
flow
}
Err(e) => {
error!("Task {} failed: {:?}", task_id, e);
message.record_audit(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,
element_index: pass.element_index,
});
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);
}
if let Some(index) = pass.element_index {
info = info.element_index(index);
}
message.errors.push(info.build());
self.mirror_task_errors(message, workflow_id, task_id, 500, task);
if !continue_on_error {
Err(e)
} else if task.halts_at(500) {
info!(
"Task {} halted workflow {} after failing",
task_id, workflow_id
);
Ok(TaskControlFlow::HaltWorkflow)
} 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 executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
.expect("metadata.errors is a valid path");
executor(engine).with_error_context(Arc::new(cfg))
}
#[tokio::test]
async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
let (workflow, engine) = compiled(
r#"{ "id": "w", "name": "w", "tasks": [
{ "id": "check", "name": "check", "continue_on_error": true,
"function": {"name": "validation", "input": {"rules": [
{"logic": false, "message": "nope"}]}}},
{ "id": "react", "name": "react",
"function": {"name": "map", "input": {"mappings": [
{"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
]}"#,
);
let mut message = Message::from_value(&json!({}));
executor_with_error_context(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("continue_on_error keeps the workflow running");
assert_eq!(
message.context["data"].get("seen"),
Some(&dv(json!("VALIDATION_ERROR"))),
"the map must read the record the validation appended in the same stretch"
);
}
#[tokio::test]
async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
executor_with_error_context(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect("workflow should complete");
assert_eq!(
message.context["metadata"].get("errors"),
None,
"a clean run leaves the key absent, not an empty array"
);
}
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());
}
const OVER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
"input": {"mappings": [
{"path": "data.last", "logic": {"var": "temp_data.item.id"}},
{"path": "data.last_i", "logic": {"var": "temp_data.i"}}]}}}"#;
fn items_message() -> Message {
Message::builder()
.data(dv(
json!({"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}),
))
.build()
}
#[tokio::test]
async fn an_over_loop_visits_each_element_with_its_index() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
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)],
"one sweep per element, not one per max"
);
assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
assert_eq!(message.context["data"].get("last_i"), Some(&dv(json!(2))));
assert_eq!(
message.context["temp_data"].get("item"),
Some(&dv(json!({"id": "c"}))),
"the element slot is left holding the last element, like the counter"
);
assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
}
#[tokio::test]
async fn max_still_bounds_an_over_loop() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 2, "over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert_eq!(counters(&message), vec![Some(0), Some(1)]);
}
#[tokio::test]
async fn init_offsets_and_increment_strides_the_element_index() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "init": 1, "increment": 2, "max": 100,
"over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = Message::builder()
.data(dv(json!({
"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}, {"id": "d"}, {"id": "e"}, {"id": "f"}]
})))
.build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert_eq!(counters(&message), vec![Some(1), Some(3), Some(5)]);
assert_eq!(message.context["data"].get("last"), Some(&dv(json!("f"))));
}
#[tokio::test]
async fn an_empty_over_runs_no_iteration_but_the_workflow_still_executed() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 100, "over": [], "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = Message::from_value(&json!({}));
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(
executed,
"admitted by its condition, zero elements to visit"
);
assert!(message.audit_trail.is_empty());
assert_eq!(
message.context["temp_data"].get("item"),
None,
"never written"
);
}
#[tokio::test]
async fn a_non_array_over_is_a_workflow_error() {
let make = |continue_on_error: bool| {
compiled(&format!(
r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
"loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
))
};
for (items, kind) in [
(json!("nope"), "a string"),
(json!(null), "null"),
(json!({"a": 1}), "an object"),
] {
let (workflow, engine) = make(false);
let mut message = Message::builder().data(dv(json!({"items": items}))).build();
let err = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect_err("not an array");
assert!(
err.to_string().contains("loop.over"),
"names the loop: {err}"
);
assert!(err.to_string().contains(kind), "says what it got: {err}");
assert_eq!(message.errors.len(), 1);
assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
assert!(message.audit_trail.is_empty(), "no sweep ran");
}
let (workflow, engine) = make(true);
let mut message = Message::builder().data(dv(json!({"items": 5}))).build();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
assert!(message.audit_trail.is_empty());
}
const SETUP: &str = r#"[{"id": "read", "name": "read", "function": {"name": "map",
"input": {"mappings": [
{"path": "temp_data.batch", "logic": {"var": "data.items"}},
{"path": "data.setup_ran", "logic": true}]}}}]"#;
#[tokio::test]
async fn setup_runs_once_before_the_first_iteration_without_a_loop_counter() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 100, "setup": {SETUP},
"over": {{"var": "temp_data.batch"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert_eq!(
counters(&message),
vec![None, Some(0), Some(1), Some(2)],
"setup is stamped like a non-looping task, then one entry per element"
);
assert_eq!(message.audit_trail[0].task_id.as_ref(), "read");
assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
}
#[tokio::test]
async fn a_false_condition_skips_setup_and_the_loop_alike() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w", "condition": false,
"loop": {{"counter": "i", "max": 100, "setup": {SETUP},
"over": {{"var": "temp_data.batch"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(!executed);
assert!(message.audit_trail.is_empty(), "setup did not run");
assert_eq!(message.context["data"].get("setup_ran"), None);
}
#[tokio::test]
async fn the_condition_is_re_evaluated_per_iteration_after_setup() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"condition": {{"!=": [{{"var": "temp_data.i"}}, 2]}},
"loop": {{"counter": "i", "max": 100, "setup": {SETUP},
"over": {{"var": "temp_data.batch"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
assert_eq!(counters(&message), vec![None, Some(0), Some(1)]);
}
#[tokio::test]
async fn a_condition_false_on_the_first_iteration_after_setup_still_reports_executed() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"condition": {{"!": [{{"var": "temp_data.batch"}}]}},
"loop": {{"counter": "i", "max": 100, "setup": {SETUP},
"over": {{"var": "temp_data.batch"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
assert_eq!(counters(&message), vec![None]);
}
#[tokio::test]
async fn a_terminal_setup_step_ends_the_workflow_before_any_iteration() {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 100,
"setup": [{{"id": "stop", "name": "stop", "terminal": true,
"function": {{"name": "map", "input": {{"mappings": []}}}}}}],
"over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
));
let mut message = items_message();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
assert_eq!(counters(&message), vec![None], "only the setup step ran");
assert!(message.errors.is_empty());
}
#[tokio::test]
async fn a_setup_error_stops_the_message_unless_the_workflow_continues_on_error() {
let make = |continue_on_error: bool| {
compiled(&format!(
r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
"loop": {{"counter": "i", "max": 100,
"setup": [{{"id": "boom", "name": "boom",
"function": {{"name": "map", "input": {{"mappings": [
{{"path": {{"var": "data.root"}}, "unset": true}}]}}}}}}],
"over": {{"var": "data.items"}}, "as": "item"}},
"tasks": [{OVER_BODY}] }}"#
))
};
let data = || dv(json!({"items": [1], "root": "temp_data"}));
let (workflow, engine) = make(false);
let mut message = Message::builder().data(data()).build();
assert!(
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.is_err()
);
assert_eq!(
counters(&message),
vec![None],
"setup recorded, no iteration"
);
let (workflow, engine) = make(true);
let mut message = Message::builder().data(data()).build();
let executed = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
assert!(executed);
assert_eq!(
counters(&message),
vec![None],
"recorded and ended — no first iteration without setup"
);
assert!(message.errors.iter().any(|e| e.code == "WORKFLOW_ERROR"));
}
const SCRATCH_BODY: &str = r#"
{"id": "flag", "name": "flag", "condition": {"==": [{"var": "temp_data.i"}, 0]},
"function": {"name": "map", "input": {"mappings": [
{"path": "temp_data.it.flag", "logic": true}]}}},
{"id": "read", "name": "read",
"function": {"name": "map", "input": {"mappings": [
{"path": {"cat": ["data.f", {"var": "temp_data.i"}]},
"logic": {"!!": [{"var": "temp_data.it.flag"}]}}]}}}"#;
async fn run_scratch_body(scratch: &str) -> Message {
let (workflow, engine) = compiled(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 3{scratch}}},
"tasks": [{SCRATCH_BODY}] }}"#
));
let mut message = Message::builder()
.temp_data(dv(json!({"keep": "me"})))
.build();
executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.unwrap();
message
}
#[tokio::test]
async fn scratch_is_reset_at_the_start_of_every_iteration() {
let leaky = run_scratch_body("").await;
assert_eq!(
leaky.context["data"].get("f1"),
Some(&dv(json!(true))),
"without scratch the slot leaks into the next iteration"
);
let clean = run_scratch_body(r#", "scratch": "it""#).await;
assert_eq!(clean.context["data"].get("f0"), Some(&dv(json!(true))));
assert_eq!(clean.context["data"].get("f1"), Some(&dv(json!(false))));
assert_eq!(clean.context["data"].get("f2"), Some(&dv(json!(false))));
assert_eq!(
clean.context["temp_data"].get("keep"),
Some(&dv(json!("me"))),
"the reset is narrow"
);
assert_eq!(
clean.context["temp_data"].get("it"),
Some(&dv(json!({}))),
"left holding the last iteration's (empty) scratch"
);
}
#[tokio::test]
async fn an_uncompiled_over_is_an_error_not_a_panic() {
let workflow = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"counter": "i", "max": 3, "over": {{"var": "data.items"}}}},
"tasks": [{COUNTER_BODY}] }}"#
))
.unwrap();
let engine = LogicCompiler::new().into_engine();
let mut message = items_message();
let err = executor(engine)
.execute(&workflow, &mut message, Utc::now())
.await
.expect_err("not compiled");
assert!(err.to_string().contains("loop.over"), "got: {err}");
}
#[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);
}
}