dataflow_rs/engine/workflow_executor.rs
1//! # Workflow Execution Module
2//!
3//! This module handles the execution of workflows and their associated tasks.
4//! It provides a clean separation between workflow orchestration and task execution.
5
6use crate::engine::error::{
7 DataflowError, ErrorContextConfig, ErrorInfo, Result, service_error_code,
8};
9use crate::engine::executor::{
10 ArenaContext, evaluate_condition, evaluate_condition_in_arena, with_arena,
11};
12use crate::engine::functions::BoxedFunctionHandler;
13use crate::engine::message::{AuditTrail, Change, Message};
14use crate::engine::observer::{
15 ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
16 WorkflowStarted,
17};
18use crate::engine::task::Task;
19use crate::engine::task_context::TaskIdentity;
20use crate::engine::task_executor::TaskExecutor;
21use crate::engine::task_outcome::TaskOutcome;
22use crate::engine::trace::{ExecutionStep, ExecutionTrace, StepTiming, duration_us_between};
23use crate::engine::utils::{
24 compute_path_parts, set_nested_value, set_nested_value_parts, strip_hash_prefix,
25};
26use crate::engine::workflow::{LoopConfig, Workflow};
27use chrono::{DateTime, Utc};
28use core::time::Duration;
29use datalogic_rs::{Engine, Logic};
30use datavalue::OwnedDataValue;
31use log::{debug, error, info, warn};
32use serde_json::Value;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36/// Result of handling a task, including possible control flow signals
37enum TaskControlFlow {
38 /// Continue executing the next task
39 Continue,
40 /// Stop executing further tasks in this workflow (filter halt)
41 HaltWorkflow,
42}
43
44/// Constants shared by every task in one pass over a workflow's task list.
45///
46/// Tracks one workflow's observer span across however many sweeps it runs.
47///
48/// `started_at` doubles as "has `workflow_started` been emitted": a looping
49/// workflow opens the span on its first admitted sweep and closes it once, so
50/// the observer sees one pair for the whole loop rather than one per sweep.
51#[derive(Default)]
52struct WorkflowSpan {
53 started_at: Option<DateTime<Utc>>,
54 sweeps: u32,
55}
56
57/// Bundles the per-message timestamp with the loop counter so that threading
58/// the counter through the task loop did not push `run_tasks_slice_in_arena`
59/// and `handle_task_result` past clippy's argument-count threshold.
60#[derive(Clone, Copy)]
61struct PassCtx {
62 /// The single `Utc::now()` read for this `process_message` call, shared by
63 /// every `AuditTrail` it produces.
64 now: DateTime<Utc>,
65 /// Loop counter of the sweep this pass is, or `None` for a workflow
66 /// without a `loop`.
67 loop_counter: Option<i64>,
68}
69
70impl PassCtx {
71 /// The single pass of a workflow without a `loop`.
72 #[inline]
73 fn once(now: DateTime<Utc>) -> Self {
74 Self {
75 now,
76 loop_counter: None,
77 }
78 }
79}
80
81/// The two per-*task* values `handle_task_result` needs beyond the shared
82/// [`PassCtx`].
83///
84/// Bundled rather than passed separately because `handle_task_result` already
85/// sits at clippy's `too_many_arguments` threshold, and `PassCtx` cannot carry
86/// them — it is per-pass and shared by every task in a sweep.
87#[derive(Clone, Copy)]
88struct TaskPass {
89 /// The task-level `continue_on_error` flag.
90 continue_on_error: bool,
91 /// The task-level `terminal` flag — halt the workflow once this task has
92 /// run, whatever it returned.
93 terminal: bool,
94 /// `message.errors.len()` immediately before this task ran, so the errors it
95 /// contributed can be identified as the tail beyond this index.
96 errors_before: usize,
97}
98
99/// One slice of a workflow's task list, plus the group state that spans slices.
100///
101/// Bundled into a single parameter because `run_tasks_slice_in_arena` already
102/// sits at clippy's `too_many_arguments` threshold, and because the three
103/// travel together: an absolute task index is `offset + i`, and `gate` is the
104/// only thing that has to survive from one slice to the next.
105struct TaskSlice<'a, 'arena> {
106 /// The tasks to run — a sub-slice of `workflow.tasks`.
107 tasks: &'arena [Task],
108 /// Index of `tasks[0]` within `workflow.tasks`.
109 offset: usize,
110 /// Group state for the whole pass, shared across every slice in it.
111 gate: &'a mut GroupGate,
112}
113
114/// Result of running one slice of a workflow's task list.
115enum SliceOutcome {
116 /// The slice ran to its end.
117 Completed,
118 /// A task halted the workflow — `TaskOutcome::Halt`, `Task::terminal`, or
119 /// the end of a terminal group.
120 Halted,
121 /// A group condition was false and its span ends beyond this slice, so the
122 /// caller must resume at this absolute task index.
123 JumpTo(usize),
124}
125
126/// Tracks which task groups are currently open during one pass over a
127/// workflow's task list.
128///
129/// Group spans are recorded at parse time on the task that opens them
130/// (`Task::group_starts`), so the executor keeps walking a flat `&[Task]`.
131/// This gate turns those spans back into control flow: evaluate a group's
132/// condition **once** on entry, jump past the span when it is false, and halt
133/// when a terminal group closes.
134///
135/// A workflow using no groups never pushes, so the gate costs one
136/// `Vec::is_empty` check per task and never allocates.
137#[derive(Default)]
138struct GroupGate {
139 /// `(end, terminal)` for each open group, outermost first.
140 open: Vec<(usize, bool)>,
141}
142
143impl GroupGate {
144 /// Close every open group whose span ends at or before `idx`, returning
145 /// `true` if any of them was `terminal`.
146 ///
147 /// Driven by `end` rather than by a per-task close count because a jump can
148 /// skip straight past the task that would have carried the count: with
149 /// `group A { group B { t1 } }` and `B`'s condition false, nothing in `A`
150 /// ever executes, yet `A` was entered and — if terminal — must still halt.
151 fn close_through(&mut self, idx: usize) -> bool {
152 let mut terminal = false;
153 while let Some(&(end, is_terminal)) = self.open.last() {
154 if end > idx {
155 break;
156 }
157 self.open.pop();
158 terminal |= is_terminal;
159 }
160 terminal
161 }
162
163 /// Evaluate the groups opening at `task`, outermost first. Returns
164 /// `Some(end)` when one's condition is false and the cursor must jump past
165 /// its span; the groups outside it stay open.
166 fn enter<F>(&mut self, task: &Task, mut eval: F) -> Result<Option<usize>>
167 where
168 F: FnMut(&Arc<Logic>) -> Result<bool>,
169 {
170 for group in &task.group_starts {
171 let entered = match group.compiled_condition.as_ref() {
172 None => true,
173 Some(compiled) => eval(compiled)?,
174 };
175 if !entered {
176 return Ok(Some(group.end));
177 }
178 self.open.push((group.end, group.terminal));
179 }
180 Ok(None)
181 }
182}
183
184/// Record the skip of every task in `workflow.tasks[from..to]` — the span of a
185/// group whose condition was false.
186///
187/// The trace stays task-granular rather than growing a group-level step, so
188/// `StepResult` and the npm wire type it mirrors are unchanged.
189fn note_group_skip(
190 mut trace: Option<&mut ExecutionTrace>,
191 workflow: &Workflow,
192 from: usize,
193 to: usize,
194 loop_counter: Option<i64>,
195) {
196 for task in &workflow.tasks[from..to.min(workflow.tasks.len())] {
197 note_task_skip(trace.as_deref_mut(), &workflow.id, &task.id, loop_counter);
198 }
199}
200
201/// Result of one pass over a workflow's task list.
202enum PassOutcome {
203 /// The workflow condition evaluated false — no task ran.
204 ConditionFalse,
205 /// Every task ran (or was individually skipped) to the end of the list.
206 Completed,
207 /// A task returned [`TaskOutcome::Halt`].
208 Halted,
209}
210
211/// Return the index of the first task at or after `start` that is *not* a
212/// synchronous built-in. Used to chunk `workflow.tasks` into sync-only
213/// stretches that can share a single `ArenaContext`.
214fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
215 let mut i = start;
216 while i < tasks.len() && tasks[i].function.is_sync_builtin() {
217 i += 1;
218 }
219 i
220}
221
222/// Log and (if tracing) record a whole-workflow skip. `reason` is only for the
223/// debug log — `ExecutionStep::workflow_skipped` doesn't carry one, so a
224/// rollout-bucket exclusion and a false condition are indistinguishable in the
225/// trace, same as before this was factored out of its four call sites.
226fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
227 debug!("Skipping workflow {} - {}", workflow_id, reason);
228 if let Some(t) = trace {
229 t.add_step(ExecutionStep::workflow_skipped(workflow_id));
230 }
231}
232
233/// Log and (if tracing) record a single task's condition skip.
234///
235/// The async task loop and the shared-arena one both reach this point with the
236/// same state, and previously spelled the block out twice — every field added
237/// to the skipped step had to be added in both places, with nothing to catch a
238/// one-sided edit. Companion to [`note_workflow_skip`] above.
239fn note_task_skip(
240 trace: Option<&mut ExecutionTrace>,
241 workflow_id: &str,
242 task_id: &str,
243 loop_counter: Option<i64>,
244) {
245 debug!("Skipping task {} - condition not met", task_id);
246 if let Some(t) = trace {
247 t.add_step(
248 ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
249 );
250 }
251}
252
253/// Whether `workflow` serves this message's routing bucket.
254///
255/// A workflow with no `rollout`, or a message with no bucket, is admitted. The
256/// missing-bucket case admits deliberately: every message any existing caller
257/// builds has no bucket, and the wasm entry points have no way to set one, so
258/// rejecting would silently stop those workflows running.
259///
260/// Nested `match` rather than a let-chain: MSRV is 1.85. See
261/// `write_progress_metadata` below for the same reason.
262fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
263 match workflow.rollout {
264 None => true,
265 Some(r) => match message.routing_bucket() {
266 None => true,
267 Some(b) => r.accepts(b),
268 },
269 }
270}
271
272/// Whether `workflow` may join a shared-arena run of consecutive fully-sync
273/// workflows.
274///
275/// A looping workflow is excluded even when every task is a sync built-in: its
276/// sweeps run through `execute_inner`, which opens a fresh arena scope per
277/// sweep. Bump arenas never free mid-scope, so sweeping inside one shared
278/// scope would grow memory with the iteration count.
279fn joins_sync_run(workflow: &Workflow) -> bool {
280 workflow.fully_sync && workflow.loop_config.is_none()
281}
282
283/// Resolve the counter's pre-split write path, once per looping workflow.
284///
285/// `LogicCompiler` pre-splits `temp_data.{counter}` at build time. A workflow
286/// constructed directly rather than through `Engine::builder` never got that
287/// pass, so the parts are computed here instead — once, ahead of the sweep
288/// loop, rather than re-formatted and re-split on every sweep.
289///
290/// An unnamed counter resolves to an empty slice, which `set_nested_value_parts`
291/// treats as a no-op: the loop is still bounded, the value simply is not
292/// exposed to JSONLogic (the audit trail carries it either way).
293fn resolve_counter_parts(config: &LoopConfig) -> Arc<[Arc<str>]> {
294 match &config.counter {
295 Some(counter) if config.counter_parts.is_empty() => {
296 compute_path_parts("temp_data", counter)
297 }
298 _ => Arc::clone(&config.counter_parts),
299 }
300}
301
302/// Build a fresh `metadata.progress` object value.
303fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
304 OwnedDataValue::Object(vec![
305 (
306 "workflow_id".to_string(),
307 OwnedDataValue::String(workflow_id.to_string()),
308 ),
309 (
310 "task_id".to_string(),
311 OwnedDataValue::String(task_id.to_string()),
312 ),
313 (
314 "status_code".to_string(),
315 OwnedDataValue::from(u64::from(status)),
316 ),
317 ])
318}
319
320/// Overwrite a string slot by reusing its existing buffer where possible.
321///
322/// The ids written per task are drawn from a small, repeating set — in a loop
323/// they are outright constant across every sweep — so the common case is
324/// writing the value that is already there. Comparing first turns that case
325/// into a no-op, and the mismatch case still reuses the allocation.
326fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
327 match slot {
328 OwnedDataValue::String(existing) => {
329 if existing != value {
330 existing.clear();
331 existing.push_str(value);
332 }
333 }
334 _ => *slot = OwnedDataValue::String(value.to_string()),
335 }
336}
337
338/// Overwrite the three fields of an existing 3-key `progress` object without
339/// reallocating it. Returns `false` when the object's shape diverges from
340/// `{workflow_id, task_id, status_code}`, in which case the caller replaces
341/// the slot wholesale (partial overwrites here are harmless — the whole slot
342/// gets replaced).
343fn overwrite_progress_in_place(
344 fields: &mut [(String, OwnedDataValue)],
345 workflow_id: &str,
346 task_id: &str,
347 status: u16,
348) -> bool {
349 if fields.len() != 3 {
350 return false;
351 }
352 let mut matched = 0;
353 for (k, v) in fields.iter_mut() {
354 match k.as_str() {
355 "workflow_id" => {
356 overwrite_str_in_place(v, workflow_id);
357 matched += 1;
358 }
359 "task_id" => {
360 overwrite_str_in_place(v, task_id);
361 matched += 1;
362 }
363 "status_code" => {
364 *v = OwnedDataValue::from(u64::from(status));
365 matched += 1;
366 }
367 _ => {}
368 }
369 }
370 matched == 3
371}
372
373/// Write `metadata.progress = {workflow_id, task_id, status_code}` with a
374/// single tree walk. From the second task of a message onward the slot
375/// already holds the expected 3-key object, so the three values are
376/// overwritten in place, reusing the id `String` buffers — no allocation at
377/// all once the shape settles. First write (or any shape divergence)
378/// replaces the slot wholesale; a context whose `metadata` is missing or
379/// non-Object falls back to the generic `set_nested_value` writer, which
380/// creates intermediate containers as needed.
381fn write_progress_metadata(
382 context: &mut OwnedDataValue,
383 workflow_id: &str,
384 task_id: &str,
385 status: u16,
386) {
387 // Nested `if let` rather than a let-chain: let-chains are stable only from
388 // Rust 1.88 and this crate's MSRV is 1.85. Keep it that way.
389 if let OwnedDataValue::Object(top) = context {
390 if let Some((_, OwnedDataValue::Object(meta))) =
391 top.iter_mut().find(|(k, _)| k == "metadata")
392 {
393 match meta.iter_mut().find(|(k, _)| k == "progress") {
394 Some((_, slot)) => {
395 if let OwnedDataValue::Object(fields) = slot {
396 if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
397 return;
398 }
399 }
400 *slot = new_progress_object(workflow_id, task_id, status);
401 }
402 None => {
403 meta.push((
404 "progress".to_string(),
405 new_progress_object(workflow_id, task_id, status),
406 ));
407 }
408 }
409 return;
410 }
411 }
412 set_nested_value(
413 context,
414 "metadata.progress",
415 new_progress_object(workflow_id, task_id, status),
416 );
417}
418
419/// Build one context record for a failed task.
420///
421/// `workflow_id`, `task_id` and `status` come from the executor rather than from
422/// the `ErrorInfo`: `validation` builds its entries with `ErrorInfo::simple_ref`,
423/// which leaves both ids `None`, and `ErrorInfo` carries no status at all.
424///
425/// The error `message` and the operator-only `detail` are deliberately omitted —
426/// this value lands in `Message.context`, which is serialized back to callers.
427fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
428 OwnedDataValue::Object(vec![
429 ("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
430 ("task_id".to_string(), OwnedDataValue::from(task_id)),
431 ("code".to_string(), OwnedDataValue::from(code)),
432 (
433 "status".to_string(),
434 OwnedDataValue::from(u64::from(status)),
435 ),
436 ])
437}
438
439/// Take `node` as an `Object`, replacing whatever non-`Object` sat there.
440///
441/// Normalise first, then destructure — the inverse order (match, then assign in
442/// the fallback arm and re-match the same binding) is NLL problem case #3 and
443/// does not compile.
444fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
445 if !matches!(node, OwnedDataValue::Object(_)) {
446 *node = OwnedDataValue::Object(Vec::new());
447 }
448 match node {
449 OwnedDataValue::Object(fields) => fields,
450 _ => unreachable!("just normalised to an Object"),
451 }
452}
453
454/// Append one record per entry in `new_errors` to the configured context path,
455/// keeping at most `cfg.limit` of them.
456///
457/// Hand-walks to the slot the way [`write_progress_metadata`] does. The generic
458/// [`set_nested_value`] cannot express an append — it indexes arrays by numeric
459/// segment and `Null`-pads the gap — and silently no-ops when a non-numeric
460/// segment meets an `Array`. A slot holding something other than an `Array` is
461/// replaced wholesale rather than skipped, so the shape a workflow author reads
462/// is predictable even if a `map` task wrote over the path first.
463///
464/// The array is created lazily, only when there is something to push, so a
465/// message whose tasks all succeed keeps the exact wire shape it had before the
466/// option existed — the key is absent, not `[]`.
467fn append_error_records(
468 context: &mut OwnedDataValue,
469 cfg: &ErrorContextConfig,
470 workflow_id: &str,
471 task_id: &str,
472 status: u16,
473 new_errors: &[ErrorInfo],
474) {
475 if new_errors.is_empty() {
476 return;
477 }
478 // Walk to the parent of the final segment, creating containers as needed,
479 // then take the slot itself.
480 let Some((last, parents)) = cfg.path_parts.split_last() else {
481 return;
482 };
483
484 let mut node = context;
485 for part in parents {
486 let key = strip_hash_prefix(part);
487 if !matches!(node, OwnedDataValue::Object(_)) {
488 // A scalar or array on the way down cannot hold a named child. The
489 // host declared the engine owns this path, so resolve the conflict
490 // in favour of the records rather than dropping them — but say so:
491 // whatever was written here is being discarded.
492 warn!(
493 "error context path `{}` runs through a non-object at `{}` — replacing it",
494 cfg.path, key
495 );
496 }
497 let fields = as_object_slot(node);
498 let idx = match fields.iter().position(|(k, _)| k == key) {
499 Some(i) => i,
500 None => {
501 fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
502 fields.len() - 1
503 }
504 };
505 node = &mut fields[idx].1;
506 }
507
508 let key = strip_hash_prefix(last);
509 let fields = as_object_slot(node);
510 let idx = match fields.iter().position(|(k, _)| k == key) {
511 Some(i) => i,
512 None => {
513 fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
514 fields.len() - 1
515 }
516 };
517 let slot = &mut fields[idx].1;
518 if !matches!(slot, OwnedDataValue::Array(_)) {
519 warn!(
520 "error context path `{}` held a non-array — replacing it",
521 cfg.path
522 );
523 *slot = OwnedDataValue::Array(Vec::new());
524 }
525 let OwnedDataValue::Array(items) = slot else {
526 unreachable!("just ensured an Array");
527 };
528
529 for error in new_errors {
530 items.push(new_error_record(workflow_id, task_id, &error.code, status));
531 }
532 // Keep-newest: a looping workflow with a failing body would otherwise grow
533 // this list once per sweep, and `Message.context` is deep-cloned into every
534 // trace snapshot.
535 if items.len() > cfg.limit {
536 items.drain(..items.len() - cfg.limit);
537 }
538}
539
540/// Handles the execution of workflows and their tasks
541///
542/// The `WorkflowExecutor` is responsible for:
543/// - Evaluating workflow conditions
544/// - Orchestrating task execution within workflows
545/// - Managing workflow-level error handling
546/// - Recording audit trails
547pub struct WorkflowExecutor {
548 /// Task executor for executing individual tasks
549 task_executor: Arc<TaskExecutor>,
550 /// Shared datalogic engine for condition evaluation
551 engine: Arc<Engine>,
552 /// Optional per-task observer. `None` keeps the instrumentation — and its
553 /// clock reads — entirely out of the dispatch path.
554 observer: Option<Arc<dyn ExecutionObserver>>,
555 /// Optional context path where per-task failure codes are mirrored. `None`
556 /// keeps the whole mechanism out of the dispatch path.
557 error_context: Option<Arc<ErrorContextConfig>>,
558}
559
560impl WorkflowExecutor {
561 /// Create a new WorkflowExecutor
562 pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
563 Self {
564 task_executor,
565 engine,
566 observer: None,
567 error_context: None,
568 }
569 }
570
571 /// Attach an observer to an existing executor. Replaces any previous one.
572 pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
573 self.observer = Some(observer);
574 self
575 }
576
577 /// The registered observer, if any.
578 ///
579 /// Used by `Engine::with_new_workflows` to carry the observer across a hot
580 /// reload — without it, metrics would stop silently at the first reload.
581 pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
582 self.observer.as_ref()
583 }
584
585 /// Attach an error-context path to an existing executor. Replaces any
586 /// previous one.
587 pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
588 self.error_context = Some(cfg);
589 self
590 }
591
592 /// The configured error-context path, if any.
593 ///
594 /// Used by `Engine`'s executor rebuilds to carry the setting across a hot
595 /// reload or a `with_observer` call — without it, failure codes would stop
596 /// being recorded silently.
597 pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
598 self.error_context.as_ref()
599 }
600
601 /// Emit a task event, deriving the status from the dispatch result.
602 ///
603 /// Called before `handle_task_result`, which takes `result` by value and
604 /// whose `?` propagates on a hard failure — emitting afterwards would
605 /// silently drop exactly the tasks a host most wants timed.
606 #[inline]
607 fn emit_task_event(
608 &self,
609 workflow: &Workflow,
610 task: &Task,
611 result: &Result<(TaskOutcome, Vec<Change>)>,
612 started_at: Option<DateTime<Utc>>,
613 ) {
614 if let Some(observer) = self.observer.as_ref() {
615 let status = match result {
616 Ok((outcome, _)) => outcome.audit_status(),
617 Err(_) => Some(500),
618 };
619 let duration = started_at
620 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
621 .unwrap_or_default();
622 observer.task_finished(&TaskEvent {
623 workflow_id: &workflow.id,
624 task_id: &task.id,
625 function: task.function.function_name(),
626 status,
627 duration,
628 });
629 }
630 }
631
632 /// Emit `workflow_started` the first time a workflow is admitted, and
633 /// remember when — so a looping workflow reports one pair for the whole
634 /// loop rather than one per sweep.
635 fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
636 span.sweeps += 1;
637 // Unobserved: no clock read, and `started_at` stays `None` so
638 // `end_workflow` is a no-op too. The crate's documented
639 // one-`Utc::now()`-per-message invariant holds unchanged.
640 let Some(observer) = self.observer.as_ref() else {
641 return;
642 };
643 if span.started_at.is_some() {
644 return;
645 }
646 span.started_at = Some(Utc::now());
647 observer.workflow_started(&WorkflowStarted {
648 workflow_id: &workflow.id,
649 });
650 }
651
652 /// Close a span opened by [`Self::begin_workflow`]. A no-op when the
653 /// workflow was never admitted, so a skipped workflow emits nothing.
654 fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
655 let Some(observer) = self.observer.as_ref() else {
656 return;
657 };
658 let Some(started) = span.started_at else {
659 return;
660 };
661 observer.workflow_finished(&WorkflowFinished {
662 workflow_id: &workflow.id,
663 duration: Duration::from_micros(duration_us_between(started, Utc::now())),
664 sweeps: span.sweeps,
665 halted,
666 });
667 }
668
669 /// Clock read for the observer, only when one is attached.
670 ///
671 /// Gated so that `process_message`'s documented "one `Utc::now()` per
672 /// message" holds for every caller that has not opted in.
673 #[inline]
674 fn observer_clock(&self) -> Option<DateTime<Utc>> {
675 self.observer.as_ref().map(|_| Utc::now())
676 }
677
678 /// Get a clone of the task_functions Arc for reuse in new engines
679 pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
680 self.task_executor.task_functions()
681 }
682
683 /// Borrow the handler registry, for enumeration keyed to this executor's
684 /// lifetime. See `TaskExecutor::registry`.
685 pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
686 self.task_executor.registry()
687 }
688
689 /// Execute a workflow if its condition is met
690 ///
691 /// This method:
692 /// 1. Evaluates the workflow condition
693 /// 2. Executes tasks sequentially if condition is met
694 /// 3. Handles error recovery based on workflow configuration
695 /// 4. Updates message metadata and audit trail
696 ///
697 /// # Arguments
698 /// * `workflow` - The workflow to execute
699 /// * `message` - The message being processed
700 ///
701 /// # Returns
702 /// * `Result<bool>` - Ok(true) if workflow was executed, Ok(false) if skipped, Err on failure
703 pub async fn execute(
704 &self,
705 workflow: &Workflow,
706 message: &mut Message,
707 now: DateTime<Utc>,
708 ) -> Result<bool> {
709 self.execute_inner(workflow, message, None, now).await
710 }
711
712 /// Execute a workflow with step-by-step tracing
713 ///
714 /// Similar to `execute` but records execution steps for debugging.
715 pub async fn execute_with_trace(
716 &self,
717 workflow: &Workflow,
718 message: &mut Message,
719 trace: &mut ExecutionTrace,
720 now: DateTime<Utc>,
721 ) -> Result<bool> {
722 self.execute_inner(workflow, message, Some(trace), now)
723 .await
724 }
725
726 /// Run `workflow` against `message`: the rollout gate, then either a single
727 /// pass over the task list or — for a workflow carrying a `loop` — a
728 /// bounded sweep loop.
729 ///
730 /// `trace` is `None` for the production path and `Some(&mut trace)` for the
731 /// debug path; stepping is the only behavioural difference between them.
732 async fn execute_inner(
733 &self,
734 workflow: &Workflow,
735 message: &mut Message,
736 mut trace: Option<&mut ExecutionTrace>,
737 now: DateTime<Utc>,
738 ) -> Result<bool> {
739 // Traffic-split gate, ahead of any arena work so an excluded workflow
740 // costs no `ArenaContext::from_owned` walk. Reuses the existing skipped
741 // path verbatim, so an excluded workflow is indistinguishable from a
742 // false condition.
743 if !rollout_admits(workflow, message) {
744 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
745 return Ok(false);
746 }
747
748 if let Some(loop_config) = workflow.loop_config.as_ref() {
749 return self
750 .execute_loop(workflow, loop_config, message, trace, now)
751 .await;
752 }
753
754 // Opened inside `execute_pass` the moment the condition admits, so a
755 // skipped workflow leaves it closed and emits nothing.
756 let mut span = WorkflowSpan::default();
757 let outcome = self
758 .execute_pass(
759 workflow,
760 message,
761 trace.as_deref_mut(),
762 PassCtx::once(now),
763 &mut span,
764 )
765 .await;
766 self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
767
768 match outcome {
769 Ok(PassOutcome::ConditionFalse) => {
770 // Last use of `trace` on this path — no reborrow needed.
771 note_workflow_skip(trace, &workflow.id, "condition not met");
772 Ok(false)
773 }
774 Ok(_) => {
775 info!("Successfully completed workflow: {}", workflow.id);
776 Ok(true)
777 }
778 Err(e) => {
779 // Single-channel contract: every error appears in
780 // `message.errors`. The `Result::Err` return only signals to
781 // the caller that we stopped before processing further
782 // workflows. The workflow-level wrapper records workflow
783 // context that the underlying task error doesn't carry.
784 if self.record_workflow_error(workflow, message, &e) {
785 Err(e)
786 } else {
787 Ok(true)
788 }
789 }
790 }
791 }
792
793 /// Drive a looping workflow: repeat [`Self::execute_pass`] while the
794 /// counter is below `max` and the workflow condition holds.
795 ///
796 /// Per-sweep order — write counter, check bound, check condition, run
797 /// tasks, advance counter — is the documented contract. The counter is in
798 /// `temp_data` before the first condition evaluation, so a condition that
799 /// indexes by it works on sweep 0.
800 ///
801 /// Returns `Ok(false)` only when no sweep ever ran, which is what a
802 /// condition-skipped workflow reports.
803 async fn execute_loop(
804 &self,
805 workflow: &Workflow,
806 config: &LoopConfig,
807 message: &mut Message,
808 mut trace: Option<&mut ExecutionTrace>,
809 now: DateTime<Utc>,
810 ) -> Result<bool> {
811 let mut counter = config.init;
812 let mut sweeps_run: u32 = 0;
813 let counter_parts = resolve_counter_parts(config);
814 // One span for the whole loop: per-sweep events would explode
815 // cardinality, so the sweep count goes on the single finished event.
816 let mut span = WorkflowSpan::default();
817 let mut halted = false;
818
819 loop {
820 // Written before the bound and condition checks so a condition
821 // indexing by the counter — the per-item pattern — resolves on the
822 // very first sweep. No arena refresh is needed: `execute_pass`
823 // builds its `ArenaContext` from `message.context` after this write.
824 set_nested_value_parts(
825 &mut message.context,
826 &counter_parts,
827 OwnedDataValue::from_i64(counter),
828 );
829
830 // `>=`, not `>`, and that is load-bearing for termination rather
831 // than a style choice. `increment >= 1` is validated at build time
832 // and the advance below saturates, so the counter strictly
833 // increases until it pins at `i64::MAX` — which satisfies
834 // `>= config.max` for every representable `max`. With `>` a loop
835 // whose counter saturates would spin forever.
836 if counter >= config.max {
837 // Normal completion: `max` is always author-supplied, so
838 // reaching it is the stated bound rather than a runaway. A
839 // condition that was still true wanted to keep going, which is
840 // worth a log line but not an error.
841 if workflow.compiled_condition.is_some() {
842 warn!(
843 "Workflow {} stopped at its loop bound (max {}) with the condition \
844 still true after {} sweep(s)",
845 workflow.id, config.max, sweeps_run
846 );
847 }
848 break;
849 }
850
851 let pass = PassCtx {
852 now,
853 loop_counter: Some(counter),
854 };
855
856 match self
857 .execute_pass(workflow, message, trace.as_deref_mut(), pass, &mut span)
858 .await
859 {
860 Ok(PassOutcome::ConditionFalse) => {
861 if sweeps_run == 0 {
862 // Never entered: indistinguishable from a plain
863 // condition-skipped workflow, and reported as one.
864 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
865 } else {
866 debug!(
867 "Workflow {} loop exited at counter {} - condition no longer met",
868 workflow.id, counter
869 );
870 }
871 break;
872 }
873 Ok(PassOutcome::Halted) => {
874 sweeps_run += 1;
875 debug!(
876 "Workflow {} loop halted at counter {}",
877 workflow.id, counter
878 );
879 halted = true;
880 break;
881 }
882 Ok(PassOutcome::Completed) => {
883 sweeps_run += 1;
884 }
885 Err(e) => {
886 sweeps_run += 1;
887 // Same single-channel contract as the non-looping path. On
888 // `continue_on_error` the loop advances past the failing
889 // sweep rather than abandoning the rest — the per-item case
890 // wants item 8 processed after item 7 failed.
891 if self.record_workflow_error(workflow, message, &e) {
892 // Closed on the error path too: an observer measuring
893 // engine overhead must not lose the span of the
894 // workflow that actually failed.
895 self.end_workflow(&span, workflow, halted);
896 return Err(e);
897 }
898 }
899 }
900
901 counter = counter.saturating_add(config.increment);
902 }
903
904 self.end_workflow(&span, workflow, halted);
905
906 if sweeps_run > 0 {
907 info!(
908 "Successfully completed workflow: {} ({} loop sweep(s))",
909 workflow.id, sweeps_run
910 );
911 }
912 Ok(sweeps_run > 0)
913 }
914
915 /// One pass over `workflow.tasks`: evaluate the workflow condition, then
916 /// run the task list once. This is the whole of a non-looping workflow, and
917 /// one sweep of a looping one.
918 ///
919 /// The workflow condition is folded into the *first* sync stretch's arena
920 /// scope: one `ArenaContext::from_owned` walk serves both the condition
921 /// eval and the leading run of sync built-in tasks. The owned path
922 /// (`eval_to_owned`) deep-borrowed the entire context — including the
923 /// heavy `data.input` payload — for the condition, and `execute_tasks`
924 /// then walked the same context again to build the first stretch's arena
925 /// form. Mixed sync+async workflows now pay one walk where they paid two.
926 /// No `.await` occurs inside the scope, preserving the `!Send` arena
927 /// invariant.
928 async fn execute_pass(
929 &self,
930 workflow: &Workflow,
931 message: &mut Message,
932 mut trace: Option<&mut ExecutionTrace>,
933 pass: PassCtx,
934 span: &mut WorkflowSpan,
935 ) -> Result<PassOutcome> {
936 /// Outcome of the folded condition-plus-first-stretch arena scope.
937 enum FirstStretch {
938 /// Workflow condition evaluated false — skip the workflow.
939 Skipped,
940 /// A filter task halted the workflow inside the first stretch.
941 Halted,
942 /// Continue with the remaining tasks, resuming at this index —
943 /// the first async boundary, or further on when a skipped group's
944 /// span reached past it.
945 Continue(usize),
946 }
947
948 let tasks = &workflow.tasks;
949 let first_boundary = next_async_boundary(tasks, 0);
950 // One gate for the whole pass: a group can open in the folded first
951 // stretch and close somewhere in the async tail.
952 let mut gate = GroupGate::default();
953
954 let first: Result<FirstStretch> =
955 if workflow.compiled_condition.is_none() && first_boundary == 0 {
956 // No condition and the workflow leads with an async task —
957 // nothing to fold; don't build an arena context for nothing.
958 // Unconditional, so the workflow runs and the span opens here.
959 self.begin_workflow(span, workflow);
960 Ok(FirstStretch::Continue(0))
961 } else {
962 with_arena(|arena| -> Result<FirstStretch> {
963 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
964
965 let should_execute = match workflow.compiled_condition.as_ref() {
966 None => true,
967 Some(compiled) => evaluate_condition_in_arena(
968 &self.engine,
969 Some(compiled),
970 arena_ctx.as_data_value(),
971 arena,
972 )?,
973 };
974 if !should_execute {
975 return Ok(FirstStretch::Skipped);
976 }
977 // Admitted: this is the earliest point the workflow is
978 // known to run, so it is where the span opens. Emitting any
979 // earlier would report a workflow its condition rejected.
980 self.begin_workflow(span, workflow);
981 if first_boundary == 0 {
982 return Ok(FirstStretch::Continue(0));
983 }
984 let outcome = self.run_tasks_slice_in_arena(
985 TaskSlice {
986 tasks: &tasks[..first_boundary],
987 offset: 0,
988 gate: &mut gate,
989 },
990 workflow,
991 message,
992 &mut arena_ctx,
993 trace.as_deref_mut(),
994 pass,
995 )?;
996 Ok(match outcome {
997 SliceOutcome::Halted => FirstStretch::Halted,
998 SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
999 SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
1000 })
1001 })
1002 };
1003
1004 // Drive the remaining (async-containing) tail. The workflow-level error
1005 // contract lives in the caller, which is the one place that knows
1006 // whether this pass was a whole workflow or one sweep of a loop.
1007 match first? {
1008 FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
1009 FirstStretch::Halted => Ok(PassOutcome::Halted),
1010 FirstStretch::Continue(resume_at) => {
1011 let halted = self
1012 .execute_tasks(workflow, message, trace, pass, resume_at, &mut gate)
1013 .await?;
1014 Ok(if halted {
1015 PassOutcome::Halted
1016 } else {
1017 PassOutcome::Completed
1018 })
1019 }
1020 }
1021 }
1022
1023 /// Record a `WORKFLOW_ERROR` to `message.errors` and log at the level
1024 /// `continue_on_error` implies. Returns `true` when the caller should stop
1025 /// processing further workflows (i.e. `continue_on_error` is `false`).
1026 ///
1027 /// Shared by `execute_inner` (returns from its own `Result<bool>`) and
1028 /// `execute_sync_workflow_run` (returns from its `with_arena` closure or
1029 /// continues the loop) — the recording and log-level decision are
1030 /// identical; only what happens next differs by call site.
1031 fn record_workflow_error(
1032 &self,
1033 workflow: &Workflow,
1034 message: &mut Message,
1035 e: &DataflowError,
1036 ) -> bool {
1037 message.errors.push(
1038 ErrorInfo::builder(
1039 "WORKFLOW_ERROR",
1040 format!("Workflow {} error: {}", workflow.id, e),
1041 )
1042 .workflow_id(&workflow.id)
1043 .build(),
1044 );
1045
1046 if workflow.continue_on_error {
1047 warn!(
1048 "Workflow {} encountered error but continuing: {:?}",
1049 workflow.id, e
1050 );
1051 false
1052 } else {
1053 error!("Workflow {} failed: {:?}", workflow.id, e);
1054 true
1055 }
1056 }
1057
1058 /// Execute the tasks of a workflow from index `start` onward.
1059 ///
1060 /// Groups consecutive synchronous built-in tasks into a single
1061 /// `with_arena` scope so the arena form of `message.context` is built
1062 /// once at the start of the stretch and reused across `parse_json`,
1063 /// `map`, `validation`, `log`, and `filter`. Async tasks (HTTP, Kafka,
1064 /// custom handlers) break the stretch — the arena flushes any pending
1065 /// state back to `OwnedDataValue` automatically (since each sync task
1066 /// already mutates `message.context` in place) and the next stretch
1067 /// rebuilds the arena form.
1068 ///
1069 /// `start` is non-zero when `execute_inner` already ran the leading sync
1070 /// stretch inside the folded condition scope.
1071 ///
1072 /// When `trace` is `Some`, the loop also records `ExecutionStep` entries
1073 /// after each task (skipped/executed) including per-mapping snapshots
1074 /// for `Map` tasks.
1075 ///
1076 /// Returns `Ok(true)` when a task halted the workflow.
1077 async fn execute_tasks(
1078 &self,
1079 workflow: &Workflow,
1080 message: &mut Message,
1081 mut trace: Option<&mut ExecutionTrace>,
1082 pass: PassCtx,
1083 start: usize,
1084 gate: &mut GroupGate,
1085 ) -> Result<bool> {
1086 let tasks = &workflow.tasks;
1087 let mut idx = start;
1088 while idx < tasks.len() {
1089 let stretch_end = next_async_boundary(tasks, idx);
1090
1091 if stretch_end > idx {
1092 // Run [idx, stretch_end) as a sync stretch inside one arena.
1093 match self.run_sync_stretch(
1094 TaskSlice {
1095 tasks: &tasks[idx..stretch_end],
1096 offset: idx,
1097 gate,
1098 },
1099 workflow,
1100 message,
1101 trace.as_deref_mut(),
1102 pass,
1103 )? {
1104 SliceOutcome::Halted => return Ok(true),
1105 // A group opening inside the stretch was skipped and its
1106 // span reaches past the stretch — resume where it ends.
1107 SliceOutcome::JumpTo(target) => {
1108 idx = target;
1109 continue;
1110 }
1111 SliceOutcome::Completed => idx = stretch_end,
1112 }
1113 }
1114
1115 if idx < tasks.len() {
1116 // Single async task (or non-sync-builtin) at `idx`.
1117 let task = &tasks[idx];
1118
1119 if gate.close_through(idx) {
1120 return Ok(true);
1121 }
1122 let jump = gate.enter(task, |compiled| {
1123 evaluate_condition(&self.engine, Some(compiled), &message.context)
1124 })?;
1125 if let Some(target) = jump {
1126 note_group_skip(
1127 trace.as_deref_mut(),
1128 workflow,
1129 idx,
1130 target,
1131 pass.loop_counter,
1132 );
1133 idx = target;
1134 continue;
1135 }
1136
1137 let should_execute = evaluate_condition(
1138 &self.engine,
1139 task.compiled_condition.as_ref(),
1140 &message.context,
1141 )?;
1142
1143 if !should_execute {
1144 note_task_skip(
1145 trace.as_deref_mut(),
1146 &workflow.id,
1147 &task.id,
1148 pass.loop_counter,
1149 );
1150 idx += 1;
1151 continue;
1152 }
1153
1154 // Clock reads only when a trace is live or an observer is
1155 // attached, so the plain path keeps its documented
1156 // one-`Utc::now()`-per-message invariant.
1157 let trace_start = if trace.is_some() {
1158 Some(Utc::now())
1159 } else {
1160 None
1161 };
1162 let obs_start = trace_start.or_else(|| self.observer_clock());
1163
1164 // Sampled before the body runs — see the sync-stretch site.
1165 let errors_before = message.errors.len();
1166
1167 let result = self
1168 .task_executor
1169 .execute_in_workflow(
1170 task,
1171 message,
1172 Some(TaskIdentity {
1173 workflow_id: &workflow.id_arc,
1174 task_id: &task.id_arc,
1175 }),
1176 pass.loop_counter,
1177 )
1178 .await;
1179
1180 // Before `handle_task_result`, whose `?` would drop failed tasks.
1181 self.emit_task_event(workflow, task, &result, obs_start);
1182
1183 // No arena refresh here: no `ArenaContext` is live on this path,
1184 // and `run_sync_stretch` rebuilds one from `message.context` at
1185 // the start of the next stretch.
1186 let control_flow = self.handle_task_result(
1187 result,
1188 &workflow.id_arc,
1189 &task.id_arc,
1190 TaskPass {
1191 continue_on_error: task.continue_on_error,
1192 terminal: task.terminal,
1193 errors_before,
1194 },
1195 message,
1196 pass,
1197 )?;
1198
1199 // Async tasks at the boundary have no per-mapping snapshots —
1200 // they're either HTTP/Kafka/Enrich or a custom handler.
1201 if let Some(t) = trace.as_deref_mut() {
1202 let started_at = trace_start.unwrap_or(pass.now);
1203 t.add_executed_step(
1204 &workflow.id,
1205 &task.id,
1206 message,
1207 StepTiming {
1208 started_at,
1209 duration_us: duration_us_between(started_at, Utc::now()),
1210 },
1211 None,
1212 pass.loop_counter,
1213 );
1214 }
1215
1216 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1217 return Ok(true);
1218 }
1219 idx += 1;
1220 }
1221 }
1222
1223 // A terminal group closing on the last task still has to halt: for a
1224 // workflow carrying a `loop`, halting breaks the loop where completing
1225 // would start another sweep.
1226 Ok(gate.close_through(tasks.len()))
1227 }
1228
1229 /// Execute a contiguous run of sync-builtin tasks inside one
1230 /// `with_arena` scope. The arena context is built once at the start and
1231 /// refreshed in place after each mutating task. Returns `Ok(true)` if a
1232 /// filter task halted the workflow.
1233 ///
1234 /// This is the single-workflow entry; the cross-workflow path
1235 /// (`execute_sync_workflow_run`) shares the same task loop via
1236 /// `run_tasks_slice_in_arena` but carries one `ArenaContext` across several
1237 /// workflows.
1238 fn run_sync_stretch(
1239 &self,
1240 slice: TaskSlice<'_, '_>,
1241 workflow: &Workflow,
1242 message: &mut Message,
1243 trace: Option<&mut ExecutionTrace>,
1244 pass: PassCtx,
1245 ) -> Result<SliceOutcome> {
1246 with_arena(|arena| -> Result<SliceOutcome> {
1247 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1248 self.run_tasks_slice_in_arena(slice, workflow, message, &mut arena_ctx, trace, pass)
1249 })
1250 }
1251
1252 /// Run `tasks` against an already-built `ArenaContext`, evaluating each
1253 /// task's condition in-arena and refreshing the cache after each mutating
1254 /// task. Returns `Ok(true)` if a filter task halted the workflow.
1255 ///
1256 /// Factored out of `run_sync_stretch` so both the single-workflow stretch
1257 /// and the cross-workflow shared-arena run (`execute_sync_workflow_run`)
1258 /// share one implementation. The caller owns the `ArenaContext` lifetime,
1259 /// so the cross-workflow path can reuse the same arena form of
1260 /// `message.context` across consecutive workflows instead of rebuilding it.
1261 fn run_tasks_slice_in_arena<'arena>(
1262 &self,
1263 slice: TaskSlice<'_, 'arena>,
1264 workflow: &Workflow,
1265 message: &mut Message,
1266 arena_ctx: &mut ArenaContext<'arena>,
1267 mut trace: Option<&mut ExecutionTrace>,
1268 pass: PassCtx,
1269 ) -> Result<SliceOutcome> {
1270 let arena = arena_ctx.arena();
1271 let TaskSlice {
1272 tasks,
1273 offset,
1274 gate,
1275 } = slice;
1276 let slice_end = offset + tasks.len();
1277
1278 let mut i = 0;
1279 while i < tasks.len() {
1280 let task = &tasks[i];
1281 let abs = offset + i;
1282
1283 // Close any group whose span ended before this task. A terminal one
1284 // ends the workflow here, before the next task runs.
1285 if gate.close_through(abs) {
1286 return Ok(SliceOutcome::Halted);
1287 }
1288
1289 // Open the groups that start here, evaluating each condition once.
1290 // A false one skips the whole span without touching the member
1291 // tasks' own conditions.
1292 let jump = gate.enter(task, |compiled| {
1293 evaluate_condition_in_arena(
1294 &self.engine,
1295 Some(compiled),
1296 arena_ctx.as_data_value(),
1297 arena,
1298 )
1299 })?;
1300 if let Some(target) = jump {
1301 note_group_skip(
1302 trace.as_deref_mut(),
1303 workflow,
1304 abs,
1305 target,
1306 pass.loop_counter,
1307 );
1308 if target >= slice_end {
1309 return Ok(SliceOutcome::JumpTo(target));
1310 }
1311 i = target - offset;
1312 continue;
1313 }
1314
1315 // Task condition — evaluate against the arena form so we don't
1316 // re-borrow the thread-local `RefCell`. A `None` compiled
1317 // condition (compiler folds the default literal `true` to
1318 // `None`) skips both the eval and the per-task arena context
1319 // slice build.
1320 let should_execute = match task.compiled_condition.as_ref() {
1321 None => true,
1322 Some(compiled) => evaluate_condition_in_arena(
1323 &self.engine,
1324 Some(compiled),
1325 arena_ctx.as_data_value(),
1326 arena,
1327 )?,
1328 };
1329
1330 if !should_execute {
1331 note_task_skip(
1332 trace.as_deref_mut(),
1333 &workflow.id,
1334 &task.id,
1335 pass.loop_counter,
1336 );
1337 i += 1;
1338 continue;
1339 }
1340
1341 // Per-task snapshot buffer — only used for Map tasks in trace
1342 // mode, and only when the trace's policy wants them. Allocating an
1343 // empty Vec is cheap and the buffer stays empty for non-Map tasks.
1344 let mut mapping_snapshots: Vec<Value> = Vec::new();
1345 let want_mapping_contexts = trace
1346 .as_deref()
1347 .is_some_and(|t| t.options().mapping_contexts);
1348 let mapping_snapshots_buf = if want_mapping_contexts {
1349 Some(&mut mapping_snapshots)
1350 } else {
1351 None
1352 };
1353
1354 // Clock reads only when a trace is live or an observer is attached,
1355 // so the plain path keeps its documented
1356 // one-`Utc::now()`-per-message invariant.
1357 let trace_start = if trace.is_some() {
1358 Some(Utc::now())
1359 } else {
1360 None
1361 };
1362 let obs_start = trace_start.or_else(|| self.observer_clock());
1363
1364 // Sampled before the body runs: `validation` and
1365 // `TaskContext::add_error` both push during it, so the tail beyond
1366 // this index is exactly what this task contributed.
1367 let errors_before = message.errors.len();
1368
1369 let result =
1370 self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
1371
1372 // Before `handle_task_result`, whose `?` would drop failed tasks.
1373 self.emit_task_event(workflow, task, &result, obs_start);
1374
1375 let flow = self.handle_task_result(
1376 result,
1377 &workflow.id_arc,
1378 &task.id_arc,
1379 TaskPass {
1380 continue_on_error: task.continue_on_error,
1381 terminal: task.terminal,
1382 errors_before,
1383 },
1384 message,
1385 pass,
1386 );
1387
1388 // Refresh the slots `handle_task_result` wrote so the next task —
1389 // and, in the cross-workflow path, the next workflow's condition —
1390 // sees them, without re-arenaing unrelated metadata children
1391 // (mapped `metadata.routing.*`, chained workflow state, …) after
1392 // every task.
1393 //
1394 // Deliberately *before* the `?`. An `Err` here does not necessarily
1395 // end this arena scope: `execute_sync_workflow_run` continues into
1396 // the next workflow carrying this same `ArenaContext` whenever the
1397 // failing task had `continue_on_error: false` but its workflow had
1398 // `continue_on_error: true`, and that workflow's condition would
1399 // otherwise be evaluated against a stale `metadata.progress`.
1400 arena_ctx.refresh_for_path(&message.context, "metadata.progress");
1401 // Gated on a failure actually being recorded: this walk deep-copies
1402 // the target subtree into the arena, so running it after every
1403 // successful task would be a permanent cost on the hot path.
1404 if let Some(cfg) = self.error_context_refresh(message, errors_before) {
1405 arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
1406 }
1407
1408 let control_flow = flow?;
1409
1410 if let Some(t) = trace.as_deref_mut() {
1411 let started_at = trace_start.unwrap_or(pass.now);
1412 let mapping_contexts = if mapping_snapshots.is_empty() {
1413 None
1414 } else {
1415 Some(mapping_snapshots)
1416 };
1417 t.add_executed_step(
1418 &workflow.id,
1419 &task.id,
1420 message,
1421 StepTiming {
1422 started_at,
1423 duration_us: duration_us_between(started_at, Utc::now()),
1424 },
1425 mapping_contexts,
1426 pass.loop_counter,
1427 );
1428 }
1429
1430 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1431 return Ok(SliceOutcome::Halted);
1432 }
1433 i += 1;
1434 }
1435 Ok(SliceOutcome::Completed)
1436 }
1437
1438 /// Drive a message through `workflows` in order, grouping maximal runs of
1439 /// consecutive `fully_sync` workflows into a single shared-arena scope
1440 /// (`execute_sync_workflow_run`) and falling back to the per-workflow
1441 /// `.await` path (`execute_inner`) for any workflow containing an async
1442 /// task.
1443 ///
1444 /// A thin `&[&Workflow]` wrapper over [`Self::run_all_borrowed`], which is
1445 /// the actual shared entry all four `Engine::process_message*` variants
1446 /// call directly (against `&[Workflow]` from the engine's own registry,
1447 /// with no per-message `Vec<&Workflow>` collect). This method exists for
1448 /// a caller that already holds borrowed references.
1449 pub async fn run_all(
1450 &self,
1451 workflows: &[&Workflow],
1452 message: &mut Message,
1453 trace: Option<&mut ExecutionTrace>,
1454 now: DateTime<Utc>,
1455 ) -> Result<()> {
1456 self.run_all_borrowed(workflows, message, trace, now).await
1457 }
1458
1459 /// Generic driver behind [`Self::run_all`]: accepts any slice whose
1460 /// elements borrow as `Workflow` — `&[Workflow]` directly from the
1461 /// engine's registry (no per-message `Vec<&Workflow>` collect) or the
1462 /// `&[&Workflow]` shape the public entry keeps for compatibility.
1463 pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
1464 &self,
1465 workflows: &[W],
1466 message: &mut Message,
1467 trace: Option<&mut ExecutionTrace>,
1468 now: DateTime<Utc>,
1469 ) -> Result<()> {
1470 let started_at = self.observer_clock();
1471 if let Some(observer) = self.observer.as_ref() {
1472 observer.message_started(&MessageStarted {
1473 message_id: message.id(),
1474 workflows_considered: workflows.len(),
1475 });
1476 }
1477 let outcome = self.run_all_inner(workflows, message, trace, now).await;
1478 if let Some(observer) = self.observer.as_ref() {
1479 observer.message_finished(&MessageFinished {
1480 message_id: message.id(),
1481 duration: started_at
1482 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
1483 .unwrap_or_default(),
1484 errors: message.errors().len(),
1485 stopped_early: outcome.is_err(),
1486 });
1487 }
1488 outcome
1489 }
1490
1491 /// The driver proper. Split out so `message_finished` fires on the early
1492 /// `Err` path too — an observer measuring a run must see the runs that
1493 /// stopped, which are the interesting ones.
1494 async fn run_all_inner<W: std::borrow::Borrow<Workflow>>(
1495 &self,
1496 workflows: &[W],
1497 message: &mut Message,
1498 mut trace: Option<&mut ExecutionTrace>,
1499 now: DateTime<Utc>,
1500 ) -> Result<()> {
1501 let mut i = 0;
1502 while i < workflows.len() {
1503 if joins_sync_run(workflows[i].borrow()) {
1504 // Extend over the maximal run of consecutive fully-sync
1505 // workflows and execute them in one shared arena scope.
1506 let mut j = i + 1;
1507 while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
1508 j += 1;
1509 }
1510 self.execute_sync_workflow_run(
1511 &workflows[i..j],
1512 message,
1513 trace.as_deref_mut(),
1514 now,
1515 )?;
1516 i = j;
1517 } else {
1518 // Mixed sync+async (or fully-async) workflow: the existing
1519 // driver interleaves per-stretch arenas with `.await`.
1520 self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
1521 .await?;
1522 i += 1;
1523 }
1524 }
1525 Ok(())
1526 }
1527
1528 /// Execute a maximal run of consecutive fully-sync workflows inside ONE
1529 /// shared `with_arena` scope. The message context is deep-walked into the
1530 /// arena once for the whole run, then carried — with the existing
1531 /// incremental `refresh_for_path` after each mutating task — across
1532 /// workflow boundaries, instead of being rebuilt per workflow.
1533 ///
1534 /// Per-workflow semantics are preserved exactly: each workflow's condition
1535 /// is evaluated (in-arena), a false condition skips only that workflow, a
1536 /// filter-halt stops only that workflow, and task errors are wrapped with
1537 /// the workflow id and honor `continue_on_error` (continue, or propagate
1538 /// `Err` out of the run to stop the whole message) — mirroring
1539 /// `execute_inner`.
1540 ///
1541 /// **Tokio safety:** this method is synchronous and the `fully_sync`
1542 /// precondition guarantees every task is a sync built-in, so no `.await`
1543 /// occurs while the `!Send` arena borrow is live. The borrow checker
1544 /// enforces this — the shared `ArenaContext` cannot escape the closure.
1545 fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
1546 &self,
1547 workflows: &[W],
1548 message: &mut Message,
1549 mut trace: Option<&mut ExecutionTrace>,
1550 now: DateTime<Utc>,
1551 ) -> Result<()> {
1552 // `joins_sync_run` keeps looping workflows out of this path, so every
1553 // workflow here runs exactly one pass and carries no loop counter.
1554 debug_assert!(
1555 workflows.iter().all(|w| joins_sync_run(w.borrow())),
1556 "only non-looping fully-sync workflows may join a shared-arena run"
1557 );
1558 let pass = PassCtx::once(now);
1559
1560 with_arena(|arena| -> Result<()> {
1561 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1562
1563 for workflow in workflows {
1564 let workflow: &Workflow = workflow.borrow();
1565
1566 // Same gate as `execute_inner`. This is the site a fully-sync
1567 // workflow actually reaches: `fully_sync` routes every
1568 // map/log/validation/filter-only workflow here and never through
1569 // `execute_inner`, so gating only there would silently not apply
1570 // to most workflows.
1571 if !rollout_admits(workflow, message) {
1572 note_workflow_skip(
1573 trace.as_deref_mut(),
1574 &workflow.id,
1575 "outside rollout bucket",
1576 );
1577 continue;
1578 }
1579
1580 // Workflow condition in-arena: a folded `None` skips the eval;
1581 // a real condition reuses the carried context instead of the
1582 // owned-path `eval_to_owned` deep-walk.
1583 let should_execute = match workflow.compiled_condition.as_ref() {
1584 None => true,
1585 Some(compiled) => evaluate_condition_in_arena(
1586 &self.engine,
1587 Some(compiled),
1588 arena_ctx.as_data_value(),
1589 arena,
1590 )?,
1591 };
1592
1593 if !should_execute {
1594 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1595 continue;
1596 }
1597
1598 // Admitted, so the span opens here — the sync run's own gate
1599 // site, which `execute_inner` never sees for these workflows.
1600 let mut span = WorkflowSpan::default();
1601 self.begin_workflow(&mut span, workflow);
1602
1603 // Group state is per-workflow: this run carries one arena
1604 // across several workflows, but never a group.
1605 let mut gate = GroupGate::default();
1606 match self.run_tasks_slice_in_arena(
1607 TaskSlice {
1608 tasks: &workflow.tasks,
1609 offset: 0,
1610 gate: &mut gate,
1611 },
1612 workflow,
1613 message,
1614 &mut arena_ctx,
1615 trace.as_deref_mut(),
1616 pass,
1617 ) {
1618 // A halt stops only this workflow; carry on with the next
1619 // one (and keep the shared arena context). The slice spans
1620 // the whole task list, so a jump can only land at its end.
1621 Ok(outcome) => {
1622 self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
1623 info!("Successfully completed workflow: {}", workflow.id);
1624 }
1625 Err(e) => {
1626 // Closed before the early return, for the same reason
1627 // as the loop path: the failing workflow's span is the
1628 // one an observer most wants.
1629 self.end_workflow(&span, workflow, false);
1630 // Single-channel contract — mirror `execute_inner`.
1631 if self.record_workflow_error(workflow, message, &e) {
1632 return Err(e);
1633 }
1634 }
1635 }
1636 }
1637 Ok(())
1638 })
1639 }
1640
1641 /// Dispatch a single sync-builtin task via the consolidated
1642 /// `FunctionConfig::try_execute_in_arena`. `next_async_boundary` guarantees
1643 /// the stretch contents are sync built-ins, so the `None` arm is
1644 /// unreachable in practice.
1645 ///
1646 /// `mapping_snapshots` is only consulted by the `Map` variant; non-Map
1647 /// sync builtins ignore it. Pass `None` from the production path.
1648 fn execute_sync_task_in_arena<'arena>(
1649 &self,
1650 task: &'arena Task,
1651 message: &mut Message,
1652 arena_ctx: &mut ArenaContext<'arena>,
1653 mapping_snapshots: Option<&mut Vec<Value>>,
1654 ) -> Result<(TaskOutcome, Vec<Change>)> {
1655 debug!(
1656 "Executing sync task in arena: {} ({})",
1657 task.id,
1658 task.function.function_name()
1659 );
1660 debug_assert!(
1661 task.function.is_sync_builtin(),
1662 "execute_sync_task_in_arena called with non-sync-builtin task: {}",
1663 task.function.function_name()
1664 );
1665 // In debug builds the assert above catches mis-dispatch; in release
1666 // we still surface the invariant violation as a recoverable engine
1667 // error rather than panicking via `unreachable!`.
1668 task.function
1669 .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
1670 .ok_or_else(|| {
1671 DataflowError::Task(format!(
1672 "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
1673 (engine bug — sync-stretch should only contain sync-builtin tasks)",
1674 task.function.function_name()
1675 ))
1676 })?
1677 }
1678
1679 /// Mirror every error this task contributed to `message.errors` into the
1680 /// configured context path.
1681 ///
1682 /// Taking the delta beyond `task.errors_before` rather than recording at each
1683 /// push site is what makes coverage match `errors()` exactly. Two of the four
1684 /// per-task producers never reach a failure arm at all: the `validation`
1685 /// built-in appends its per-rule failures and then returns `Status(400)`,
1686 /// which lands in the *success* arm, and `TaskContext::add_error` can fire on
1687 /// a task that succeeds outright. Neither is visible to a host that wraps
1688 /// handlers either, since the sync built-ins never reach the registry.
1689 ///
1690 /// Must run *after* the two pushes `handle_task_result` performs itself
1691 /// (`TASK_STATUS_ERROR` and the task error), or they fall outside the delta —
1692 /// which would silently drop every `map` failure, since `map` returns
1693 /// `Status(500)` without touching `errors` itself.
1694 ///
1695 /// Returns nothing — the sync stretch reads [`Self::error_context_refresh`]
1696 /// instead, so the arena refresh stays off the no-failure path.
1697 #[inline]
1698 fn mirror_task_errors(
1699 &self,
1700 message: &mut Message,
1701 workflow_id: &str,
1702 task_id: &str,
1703 status: u16,
1704 task: TaskPass,
1705 ) {
1706 let Some(cfg) = self.error_context.as_ref() else {
1707 return;
1708 };
1709 // Disjoint borrows of two fields of `Message`, so the split is needed to
1710 // read the error tail while mutating the context.
1711 let Message {
1712 context, errors, ..
1713 } = message;
1714 let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
1715 append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
1716 }
1717
1718 /// Whether the sync stretch must refresh the arena for the error-context
1719 /// path after this task — i.e. the option is on and the task contributed at
1720 /// least one error.
1721 #[inline]
1722 fn error_context_refresh<'a>(
1723 &'a self,
1724 message: &Message,
1725 errors_before: usize,
1726 ) -> Option<&'a Arc<ErrorContextConfig>> {
1727 let cfg = self.error_context.as_ref()?;
1728 if message.errors.len() > errors_before {
1729 Some(cfg)
1730 } else {
1731 None
1732 }
1733 }
1734
1735 /// Handle the result of a task execution.
1736 ///
1737 /// `workflow_id_arc` and `task_id_arc` are the compile-time cached
1738 /// `Arc<str>` mirrors of `workflow.id` / `task.id`; we Arc-clone them into
1739 /// each `AuditTrail` rather than reallocating from the `&str` form.
1740 fn handle_task_result(
1741 &self,
1742 result: Result<(TaskOutcome, Vec<Change>)>,
1743 workflow_id_arc: &Arc<str>,
1744 task_id_arc: &Arc<str>,
1745 task: TaskPass,
1746 message: &mut Message,
1747 pass: PassCtx,
1748 ) -> Result<TaskControlFlow> {
1749 let workflow_id: &str = workflow_id_arc;
1750 let task_id: &str = task_id_arc;
1751 let continue_on_error = task.continue_on_error;
1752 match result {
1753 Ok((TaskOutcome::Skip, _)) => {
1754 // No audit trail, no progress write, and no error-context record
1755 // — the task has explicitly opted out of the per-task record
1756 // (filter gate set to `Skip`). `audit_status()` is `None` here,
1757 // so a record would need a fabricated `status`, breaking the
1758 // fixed four-key shape that makes the path predictable to branch
1759 // on. Reaching this with errors recorded takes a handler that
1760 // calls `add_error` and *then* skips; the entry is still on
1761 // `message.errors()`.
1762 debug!("Task {} signaled skip", task_id);
1763 Ok(TaskControlFlow::Continue)
1764 }
1765 Ok((outcome, changes)) => {
1766 // `Skip` already returned above; the remaining variants all
1767 // record an audit entry. `audit_status()` is `Some` for
1768 // Success/Status/Halt — expect is for documentation only.
1769 let status = outcome
1770 .audit_status()
1771 .expect("Skip handled above; remaining variants emit audit status");
1772 // `Task::terminal` reaches the same halt as `TaskOutcome::Halt`,
1773 // but it is applied *after* the status classification below —
1774 // see the `flow` fold. Deciding here would make halting the
1775 // first branch of the chain, so a terminal task returning 500
1776 // would stop without recording `TASK_STATUS_ERROR` and without
1777 // propagating when `continue_on_error` is false.
1778 let halt_requested = outcome.halts_workflow() || task.terminal;
1779
1780 // Record audit trail. workflow_id_arc/task_id_arc are populated
1781 // by LogicCompiler at engine construction; cloning them is a
1782 // refcount bump, not a string copy. `now` is shared with all
1783 // other AuditTrails in this process_message call.
1784 message.audit_trail.push(AuditTrail {
1785 timestamp: pass.now,
1786 workflow_id: Arc::clone(workflow_id_arc),
1787 task_id: Arc::clone(task_id_arc),
1788 status: status as usize,
1789 changes,
1790 loop_counter: pass.loop_counter,
1791 });
1792
1793 // Update progress metadata for workflow chaining. Always
1794 // emitted: when multiple workflows are registered in the same
1795 // engine, downstream workflows route on
1796 // `metadata.progress.{workflow_id,task_id,status_code}` to
1797 // advance through linear sequences. After the first task the
1798 // slot already holds the expected 3-key object, so the write
1799 // overwrites the three values in place — only the two id
1800 // `String` allocs remain. (This beat both three separate
1801 // `set_nested_value` calls and the batched slot replace on
1802 // the realistic workload.)
1803 write_progress_metadata(&mut message.context, workflow_id, task_id, status);
1804
1805 // Decide the control flow first rather than returning from
1806 // inside each branch, so the error-context mirror below runs on
1807 // exactly one path. The halt and `!continue_on_error` exits would
1808 // otherwise each need their own call, and a future exit added
1809 // without one would silently stop recording.
1810 let flow = if (400..500).contains(&status) {
1811 warn!("Task {} returned client error status: {}", task_id, status);
1812 Ok(TaskControlFlow::Continue)
1813 } else if status >= 500 {
1814 error!("Task {} returned server error status: {}", task_id, status);
1815 // Single-channel contract: surface 5xx outcomes through
1816 // `message.errors` as well as the audit trail, so callers
1817 // that scan `errors()` see a 5xx-status task even when
1818 // the workflow continues past it.
1819 message.errors.push(
1820 ErrorInfo::builder(
1821 "TASK_STATUS_ERROR",
1822 format!("Task {} returned status {}", task_id, status),
1823 )
1824 .workflow_id(workflow_id)
1825 .task_id(task_id)
1826 .build(),
1827 );
1828 if continue_on_error {
1829 Ok(TaskControlFlow::Continue)
1830 } else {
1831 Err(DataflowError::Task(format!(
1832 "Task {} failed with status {}",
1833 task_id, status
1834 )))
1835 }
1836 } else {
1837 Ok(TaskControlFlow::Continue)
1838 };
1839
1840 // Upgrade a `Continue` to a halt, leaving the 5xx `Err` and the
1841 // recording above untouched. `TaskOutcome::Halt`'s own status is
1842 // 299 — neither 4xx nor 5xx — so its behaviour is unchanged.
1843 let flow = match flow {
1844 Ok(TaskControlFlow::Continue) if halt_requested => {
1845 info!("Task {} halted workflow {}", task_id, workflow_id);
1846 Ok(TaskControlFlow::HaltWorkflow)
1847 }
1848 other => other,
1849 };
1850
1851 // After the `TASK_STATUS_ERROR` push above, so it lands inside
1852 // this task's delta.
1853 self.mirror_task_errors(message, workflow_id, task_id, status, task);
1854 flow
1855 }
1856 Err(e) => {
1857 error!("Task {} failed: {:?}", task_id, e);
1858
1859 // Record error in audit trail (Arc clones are refcount bumps).
1860 message.audit_trail.push(AuditTrail {
1861 timestamp: pass.now,
1862 workflow_id: Arc::clone(workflow_id_arc),
1863 task_id: Arc::clone(task_id_arc),
1864 status: 500,
1865 changes: vec![],
1866 loop_counter: pass.loop_counter,
1867 });
1868
1869 // Same invariant as the Ok arm: `metadata.progress` is written
1870 // after every task, unconditionally, so a downstream workflow
1871 // gating on it still sees this task ran even though it errored.
1872 write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
1873
1874 // Add error to message. A service-classified error contributes
1875 // its own `kind` as the code and carries its operator-only
1876 // `detail`; everything else takes its variant's code.
1877 // Deliberately lifted at the task site only: the two
1878 // `WORKFLOW_ERROR` wrappers wrap the same propagated error, so
1879 // lifting there too would put two entries with the same
1880 // `code` on the message — making "count errors by code"
1881 // double-count — and would stop `WORKFLOW_ERROR` reliably
1882 // meaning "a workflow stopped".
1883 //
1884 // `format!("{}", e)` stays caller-safe because `Service`'s
1885 // `Display` is `{message}` — the detail is never interpolated.
1886 let mut info = ErrorInfo::builder(
1887 service_error_code(&e),
1888 format!("Task {} error: {}", task_id, e),
1889 )
1890 .workflow_id(workflow_id)
1891 .task_id(task_id);
1892 // Nested `if let`, not a let-chain: MSRV is 1.85.
1893 if let Some(detail) = e.detail() {
1894 info = info.detail(detail);
1895 }
1896 message.errors.push(info.build());
1897
1898 // `500` matches the audit entry and the progress write above: a
1899 // handler `Err` has no status of its own.
1900 self.mirror_task_errors(message, workflow_id, task_id, 500, task);
1901
1902 if !continue_on_error {
1903 Err(e)
1904 } else if task.terminal {
1905 // `terminal` is about position, not outcome: the author said
1906 // "nothing after this runs". The error stays on
1907 // `message.errors()` either way.
1908 info!(
1909 "Terminal task {} halted workflow {} after failing",
1910 task_id, workflow_id
1911 );
1912 Ok(TaskControlFlow::HaltWorkflow)
1913 } else {
1914 Ok(TaskControlFlow::Continue)
1915 }
1916 }
1917 }
1918 }
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923 use super::*;
1924 use crate::engine::compiler::LogicCompiler;
1925 use serde_json::json;
1926 use std::collections::HashMap;
1927
1928 /// Test-only helper: build an `OwnedDataValue` from a `json!` literal.
1929 fn dv(v: serde_json::Value) -> OwnedDataValue {
1930 OwnedDataValue::from(&v)
1931 }
1932
1933 /// Compile `json` into a single runnable workflow plus its engine.
1934 fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
1935 let compiler = LogicCompiler::new();
1936 let workflow = Workflow::from_json(json).expect("workflow should parse");
1937 let compiled = compiler
1938 .compile_workflows(vec![workflow])
1939 .expect("workflow should compile");
1940 (
1941 compiled.into_iter().next().expect("one workflow"),
1942 compiler.into_engine(),
1943 )
1944 }
1945
1946 /// A `WorkflowExecutor` over an empty handler registry.
1947 fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
1948 let task_executor = Arc::new(TaskExecutor::new(
1949 Arc::new(HashMap::new()),
1950 Arc::clone(&engine),
1951 ));
1952 WorkflowExecutor::new(task_executor, engine)
1953 }
1954
1955 /// A `WorkflowExecutor` that mirrors failure codes to `metadata.errors`.
1956 fn executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
1957 let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
1958 .expect("metadata.errors is a valid path");
1959 executor(engine).with_error_context(Arc::new(cfg))
1960 }
1961
1962 #[tokio::test]
1963 async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
1964 // A failing `validation` followed by a `map`, both sync built-ins, so
1965 // they share one `ArenaContext`. Two things are under test:
1966 //
1967 // 1. the `map` reads the record appended by the `validation`, which only
1968 // works if the append refreshed the arena; and
1969 // 2. `apply_mutation_parts_write_through`'s `#[cfg(test)]`
1970 // `assert_matches_owned` runs on the `map`'s write, giving free
1971 // differential verification that the refresh left the arena cache
1972 // identical to a from-scratch rebuild of the owned context. That
1973 // assertion is compiled out for the `tests/` binaries, so it can only
1974 // be exercised from here.
1975 let (workflow, engine) = compiled(
1976 r#"{ "id": "w", "name": "w", "tasks": [
1977 { "id": "check", "name": "check", "continue_on_error": true,
1978 "function": {"name": "validation", "input": {"rules": [
1979 {"logic": false, "message": "nope"}]}}},
1980 { "id": "react", "name": "react",
1981 "function": {"name": "map", "input": {"mappings": [
1982 {"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
1983 ]}"#,
1984 );
1985 let mut message = Message::from_value(&json!({}));
1986
1987 executor_with_error_context(engine)
1988 .execute(&workflow, &mut message, Utc::now())
1989 .await
1990 .expect("continue_on_error keeps the workflow running");
1991
1992 assert_eq!(
1993 message.context["data"].get("seen"),
1994 Some(&dv(json!("VALIDATION_ERROR"))),
1995 "the map must read the record the validation appended in the same stretch"
1996 );
1997 }
1998
1999 #[tokio::test]
2000 async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
2001 let (workflow, engine) = compiled(&format!(
2002 r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
2003 ));
2004 let mut message = Message::from_value(&json!({}));
2005
2006 executor_with_error_context(engine)
2007 .execute(&workflow, &mut message, Utc::now())
2008 .await
2009 .expect("workflow should complete");
2010
2011 assert_eq!(
2012 message.context["metadata"].get("errors"),
2013 None,
2014 "a clean run leaves the key absent, not an empty array"
2015 );
2016 }
2017
2018 /// Every `loop_counter` recorded on the audit trail, in order.
2019 fn counters(message: &Message) -> Vec<Option<i64>> {
2020 message
2021 .audit_trail
2022 .iter()
2023 .map(|entry| entry.loop_counter)
2024 .collect()
2025 }
2026
2027 /// A one-task `map` workflow body writing `data.n` from the counter.
2028 const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2029 "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
2030
2031 #[tokio::test]
2032 async fn loop_without_a_condition_runs_exactly_max_sweeps() {
2033 let (workflow, engine) = compiled(&format!(
2034 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2035 "tasks": [{COUNTER_BODY}] }}"#
2036 ));
2037 let mut message = Message::from_value(&json!({}));
2038
2039 let executed = executor(engine)
2040 .execute(&workflow, &mut message, Utc::now())
2041 .await
2042 .expect("loop should complete");
2043
2044 assert!(executed);
2045 // One audit entry per sweep, each stamped with its counter.
2046 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2047 // The counter is left at the bound the loop stopped on.
2048 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2049 // The body observed each value; the last one survives.
2050 assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
2051 }
2052
2053 #[tokio::test]
2054 async fn loop_exits_early_when_the_condition_goes_false() {
2055 // Bounded at 10 but the condition stops it at 4.
2056 let (workflow, engine) = compiled(&format!(
2057 r#"{{ "id": "w", "name": "w",
2058 "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
2059 "loop": {{"counter": "i", "max": 10}},
2060 "tasks": [{COUNTER_BODY}] }}"#
2061 ));
2062 let mut message = Message::from_value(&json!({}));
2063
2064 executor(engine)
2065 .execute(&workflow, &mut message, Utc::now())
2066 .await
2067 .expect("loop should complete");
2068
2069 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
2070 }
2071
2072 #[tokio::test]
2073 async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
2074 let (workflow, engine) = compiled(&format!(
2075 r#"{{ "id": "w", "name": "w", "condition": false,
2076 "loop": {{"counter": "i", "max": 5}},
2077 "tasks": [{COUNTER_BODY}] }}"#
2078 ));
2079 let mut message = Message::from_value(&json!({}));
2080
2081 let executed = executor(engine)
2082 .execute(&workflow, &mut message, Utc::now())
2083 .await
2084 .expect("a skip is not an error");
2085
2086 assert!(!executed, "a never-entered loop reports as skipped");
2087 assert!(message.audit_trail.is_empty());
2088 }
2089
2090 #[tokio::test]
2091 async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
2092 let (workflow, engine) = compiled(
2093 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
2094 "tasks": [
2095 {"id": "gate", "name": "gate", "function": {"name": "filter",
2096 "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
2097 "on_reject": "halt"}}},
2098 {"id": "body", "name": "body", "function": {"name": "map",
2099 "input": {"mappings": [
2100 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
2101 );
2102 let mut message = Message::from_value(&json!({}));
2103
2104 executor(engine)
2105 .execute(&workflow, &mut message, Utc::now())
2106 .await
2107 .expect("a halt is not an error");
2108
2109 // Sweeps 0 and 1 run both tasks; sweep 2's gate halts and ends the
2110 // loop rather than moving on to sweep 3.
2111 let ids: Vec<&str> = message
2112 .audit_trail
2113 .iter()
2114 .map(|entry| entry.task_id.as_ref())
2115 .collect();
2116 assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
2117 assert_eq!(
2118 counters(&message),
2119 vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
2120 );
2121 }
2122
2123 #[tokio::test]
2124 async fn init_and_increment_drive_the_counter() {
2125 let (workflow, engine) = compiled(&format!(
2126 r#"{{ "id": "w", "name": "w",
2127 "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
2128 "tasks": [{COUNTER_BODY}] }}"#
2129 ));
2130 let mut message = Message::from_value(&json!({}));
2131
2132 executor(engine)
2133 .execute(&workflow, &mut message, Utc::now())
2134 .await
2135 .expect("loop should complete");
2136
2137 assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
2138 }
2139
2140 #[tokio::test]
2141 async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
2142 let (workflow, engine) = compiled(
2143 r#"{ "id": "w", "name": "w", "loop": {"max": 2},
2144 "tasks": [{"id": "t", "name": "t",
2145 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2146 );
2147 let mut message = Message::from_value(&json!({}));
2148
2149 executor(engine)
2150 .execute(&workflow, &mut message, Utc::now())
2151 .await
2152 .expect("loop should complete");
2153
2154 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2155 // Nothing was written to temp_data — the counter was never named.
2156 assert_eq!(message.context["temp_data"], dv(json!({})));
2157 }
2158
2159 #[tokio::test]
2160 async fn a_non_looping_workflow_records_no_loop_counter() {
2161 let (workflow, engine) = compiled(
2162 r#"{ "id": "w", "name": "w",
2163 "tasks": [{"id": "t", "name": "t",
2164 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2165 );
2166 let mut message = Message::from_value(&json!({}));
2167
2168 executor(engine)
2169 .execute(&workflow, &mut message, Utc::now())
2170 .await
2171 .expect("should complete");
2172
2173 assert_eq!(counters(&message), vec![None]);
2174 }
2175
2176 #[tokio::test]
2177 async fn progress_metadata_is_written_on_every_sweep() {
2178 // `metadata.progress` is load-bearing for cross-workflow chaining; a
2179 // loop must not gate it.
2180 let (workflow, engine) = compiled(&format!(
2181 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2182 "tasks": [{COUNTER_BODY}] }}"#
2183 ));
2184 let mut message = Message::from_value(&json!({}));
2185
2186 executor(engine)
2187 .execute(&workflow, &mut message, Utc::now())
2188 .await
2189 .expect("loop should complete");
2190
2191 let progress = message.context["metadata"]
2192 .get("progress")
2193 .expect("progress must be written");
2194 assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
2195 assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
2196 assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
2197 }
2198
2199 #[tokio::test]
2200 async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
2201 // A body task writing the counter path is overwritten at the next
2202 // increment, so termination reasoning stays local to LoopConfig.
2203 let (workflow, engine) = compiled(
2204 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2205 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2206 "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
2207 );
2208 let mut message = Message::from_value(&json!({}));
2209
2210 executor(engine)
2211 .execute(&workflow, &mut message, Utc::now())
2212 .await
2213 .expect("loop should complete");
2214
2215 assert_eq!(
2216 counters(&message),
2217 vec![Some(0), Some(1), Some(2)],
2218 "the body's write must not stall or skew the loop"
2219 );
2220 }
2221
2222 /// Run a bare counting loop with the given bounds and return the counter
2223 /// values the sweeps actually recorded.
2224 async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
2225 let (workflow, engine) = compiled(&format!(
2226 r#"{{ "id": "w", "name": "w",
2227 "loop": {{"counter": "i", "init": {init},
2228 "increment": {increment}, "max": {max}}},
2229 "tasks": [{{"id": "t", "name": "t",
2230 "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
2231 ));
2232 let mut message = Message::from_value(&json!({}));
2233 executor(engine)
2234 .execute(&workflow, &mut message, Utc::now())
2235 .await
2236 .expect("loop should complete");
2237 counters(&message)
2238 }
2239
2240 #[tokio::test]
2241 async fn counter_sequence_matrix_over_init_increment_and_max() {
2242 // The half-open `counter < max` bound, swept across signs and step
2243 // sizes. Each expected list is the exact sequence of sweeps.
2244 let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
2245 // Defaults: 0-based, step 1 — the array-index case.
2246 (0, 1, 1, vec![0]),
2247 (0, 1, 2, vec![0, 1]),
2248 (0, 1, 5, vec![0, 1, 2, 3, 4]),
2249 // Non-unit steps, including a range the step does not divide.
2250 (0, 2, 6, vec![0, 2, 4]),
2251 (0, 3, 10, vec![0, 3, 6, 9]),
2252 (0, 5, 3, vec![0]),
2253 (0, 100, 1, vec![0]),
2254 // Non-zero starts.
2255 (10, 5, 25, vec![10, 15, 20]),
2256 (3, 1, 6, vec![3, 4, 5]),
2257 // Negative and mixed-sign ranges.
2258 (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
2259 (-4, 2, 1, vec![-4, -2, 0]),
2260 (-10, 5, -5, vec![-10]),
2261 ];
2262
2263 for (init, increment, max, expected) in cases {
2264 let got = counter_sequence(init, increment, max).await;
2265 let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
2266 assert_eq!(got, expected, "init={init} increment={increment} max={max}");
2267 }
2268 }
2269
2270 #[tokio::test]
2271 async fn the_counter_advance_saturates_instead_of_overflowing() {
2272 // A huge increment must end the loop, not wrap into a negative counter
2273 // and spin. Both the giant-step and the near-i64::MAX start are
2274 // exercised, since either could overflow a plain `+`.
2275 assert_eq!(
2276 counter_sequence(0, i64::MAX, 5).await,
2277 vec![Some(0)],
2278 "one sweep, then the advance saturates past max"
2279 );
2280 assert_eq!(
2281 counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
2282 vec![Some(i64::MAX - 1)],
2283 "the last representable sweep still terminates"
2284 );
2285 assert_eq!(
2286 counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
2287 vec![Some(i64::MAX - 2)]
2288 );
2289 }
2290
2291 #[tokio::test]
2292 async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
2293 // Per-sweep task conditions are the mechanism for "do this only on
2294 // some iterations"; a stale condition cache would break it.
2295 let (workflow, engine) = compiled(
2296 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
2297 "tasks": [
2298 {"id": "evens", "name": "evens",
2299 "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
2300 "function": {"name": "map", "input": {"mappings": []}}},
2301 {"id": "always", "name": "always",
2302 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2303 );
2304 let mut message = Message::from_value(&json!({}));
2305
2306 executor(engine)
2307 .execute(&workflow, &mut message, Utc::now())
2308 .await
2309 .expect("loop should complete");
2310
2311 let entries: Vec<(&str, Option<i64>)> = message
2312 .audit_trail
2313 .iter()
2314 .map(|e| (e.task_id.as_ref(), e.loop_counter))
2315 .collect();
2316 assert_eq!(
2317 entries,
2318 [
2319 ("evens", Some(0)),
2320 ("always", Some(0)),
2321 ("always", Some(1)),
2322 ("evens", Some(2)),
2323 ("always", Some(2)),
2324 ("always", Some(3)),
2325 ],
2326 "the gated task runs only on even counters"
2327 );
2328 }
2329
2330 #[tokio::test]
2331 async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
2332 // `TaskOutcome::Skip` records no audit entry and no progress write.
2333 // The loop is driven by its bound, not by whether tasks recorded
2334 // anything, so it still runs exactly `max` sweeps.
2335 let (workflow, engine) = compiled(
2336 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2337 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
2338 "input": {"condition": false, "on_reject": "skip"}}}] }"#,
2339 );
2340 let mut message = Message::from_value(&json!({}));
2341
2342 let executed = executor(engine)
2343 .execute(&workflow, &mut message, Utc::now())
2344 .await
2345 .expect("skip is not an error");
2346
2347 assert!(executed, "sweeps ran even though every task skipped");
2348 assert!(message.audit_trail.is_empty(), "Skip records no entry");
2349 assert_eq!(
2350 message.context["temp_data"].get("i"),
2351 Some(&dv(json!(3))),
2352 "the loop still ran to its bound"
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
2358 // A failing `validation` yields 400: warned, recorded, loop continues.
2359 let (workflow, engine) = compiled(
2360 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2361 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
2362 "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
2363 );
2364 let mut message = Message::from_value(&json!({}));
2365
2366 executor(engine)
2367 .execute(&workflow, &mut message, Utc::now())
2368 .await
2369 .expect("a 4xx does not stop the workflow");
2370
2371 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2372 assert!(
2373 message.audit_trail.iter().all(|e| e.status == 400),
2374 "every sweep recorded the 4xx"
2375 );
2376 }
2377
2378 #[tokio::test]
2379 async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
2380 // The gate runs ahead of the loop, so an excluded workflow writes no
2381 // counter at all — it must be indistinguishable from a plain skip.
2382 let (workflow, engine) = compiled(
2383 r#"{ "id": "w", "name": "w",
2384 "rollout": {"bucket_start": 0, "bucket_end": 50},
2385 "loop": {"counter": "i", "max": 5},
2386 "tasks": [{"id": "t", "name": "t",
2387 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2388 );
2389 let mut message = Message::builder().routing_bucket(75).build();
2390
2391 let executed = executor(engine)
2392 .execute(&workflow, &mut message, Utc::now())
2393 .await
2394 .expect("an excluded workflow is not an error");
2395
2396 assert!(!executed);
2397 assert!(message.audit_trail.is_empty());
2398 assert_eq!(
2399 message.context["temp_data"].get("i"),
2400 None,
2401 "no counter is written for an excluded workflow"
2402 );
2403 }
2404
2405 #[tokio::test]
2406 async fn a_nested_counter_path_is_created_and_advanced() {
2407 let (workflow, engine) = compiled(
2408 r#"{ "id": "w", "name": "w",
2409 "loop": {"counter": "cursor.index", "max": 3},
2410 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2411 "input": {"mappings": [
2412 {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
2413 );
2414 let mut message = Message::from_value(&json!({}));
2415
2416 executor(engine)
2417 .execute(&workflow, &mut message, Utc::now())
2418 .await
2419 .expect("loop should complete");
2420
2421 assert_eq!(
2422 message.context["temp_data"]["cursor"].get("index"),
2423 Some(&dv(json!(3)))
2424 );
2425 assert_eq!(
2426 message.context["data"].get("seen"),
2427 Some(&dv(json!(2))),
2428 "the body read the nested counter"
2429 );
2430 }
2431
2432 #[tokio::test]
2433 async fn writing_the_counter_preserves_unrelated_temp_data() {
2434 let (workflow, engine) = compiled(
2435 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
2436 "tasks": [{"id": "t", "name": "t",
2437 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2438 );
2439 let mut message = Message::builder()
2440 .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
2441 .build();
2442
2443 executor(engine)
2444 .execute(&workflow, &mut message, Utc::now())
2445 .await
2446 .expect("loop should complete");
2447
2448 assert_eq!(
2449 message.context["temp_data"].get("keep"),
2450 Some(&dv(json!("me")))
2451 );
2452 assert_eq!(
2453 message.context["temp_data"]["nested"].get("a"),
2454 Some(&dv(json!(1)))
2455 );
2456 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
2457 }
2458
2459 #[tokio::test]
2460 async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
2461 // The engine owns the path: whatever was there before the loop is
2462 // replaced by `init` on the first sweep.
2463 let (workflow, engine) = compiled(
2464 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
2465 "tasks": [{"id": "t", "name": "t",
2466 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2467 );
2468 let mut message = Message::builder()
2469 .temp_data(dv(json!({"i": "not a number"})))
2470 .build();
2471
2472 executor(engine)
2473 .execute(&workflow, &mut message, Utc::now())
2474 .await
2475 .expect("loop should complete");
2476
2477 assert_eq!(counters(&message), vec![Some(5), Some(6)]);
2478 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
2479 }
2480
2481 #[tokio::test]
2482 async fn a_loop_records_audit_entries_with_capture_changes_off() {
2483 // `capture_changes(false)` suppresses the per-change diff, not the
2484 // audit entries themselves — so the loop counter is still recorded.
2485 let (workflow, engine) = compiled(
2486 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
2487 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2488 "input": {"mappings": [
2489 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
2490 );
2491 let mut message = Message::builder().capture_changes(false).build();
2492
2493 executor(engine)
2494 .execute(&workflow, &mut message, Utc::now())
2495 .await
2496 .expect("loop should complete");
2497
2498 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2499 assert!(
2500 message.audit_trail.iter().all(|e| e.changes.is_empty()),
2501 "no diffs captured, but the entries are still there"
2502 );
2503 }
2504
2505 #[tokio::test]
2506 async fn two_loops_sharing_a_counter_name_do_not_interfere() {
2507 // Each loop re-initialises the path it owns, so the second starts from
2508 // its own `init` rather than inheriting where the first stopped.
2509 let first = r#"{ "id": "a", "name": "a", "priority": 0,
2510 "loop": {"counter": "i", "max": 2},
2511 "tasks": [{"id": "t", "name": "t",
2512 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
2513 let second = r#"{ "id": "b", "name": "b", "priority": 1,
2514 "loop": {"counter": "i", "init": 10, "max": 12},
2515 "tasks": [{"id": "t", "name": "t",
2516 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
2517
2518 let compiler = LogicCompiler::new();
2519 let workflows = compiler
2520 .compile_workflows(vec![
2521 Workflow::from_json(first).unwrap(),
2522 Workflow::from_json(second).unwrap(),
2523 ])
2524 .expect("should compile");
2525 let exec = executor(compiler.into_engine());
2526 let mut message = Message::from_value(&json!({}));
2527
2528 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2529 .await
2530 .expect("both loops should complete");
2531
2532 let per_workflow: Vec<(&str, Option<i64>)> = message
2533 .audit_trail
2534 .iter()
2535 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
2536 .collect();
2537 assert_eq!(
2538 per_workflow,
2539 [
2540 ("a", Some(0)),
2541 ("a", Some(1)),
2542 ("b", Some(10)),
2543 ("b", Some(11)),
2544 ]
2545 );
2546 }
2547
2548 #[tokio::test]
2549 async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
2550 // Regression guard for the `joins_sync_run` change: a loop workflow is
2551 // excluded from the shared-arena run, which must split the run around
2552 // it rather than dropping its neighbours.
2553 let sync_wf = |id: &str, priority: u32| {
2554 format!(
2555 r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
2556 "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
2557 "input": {{"mappings": [
2558 {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
2559 )
2560 };
2561 let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
2562 "loop": {"counter": "i", "max": 2},
2563 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2564 "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
2565
2566 let compiler = LogicCompiler::new();
2567 let workflows = compiler
2568 .compile_workflows(vec![
2569 Workflow::from_json(&sync_wf("before", 0)).unwrap(),
2570 Workflow::from_json(loop_wf).unwrap(),
2571 Workflow::from_json(&sync_wf("after", 2)).unwrap(),
2572 ])
2573 .expect("should compile");
2574 // All three are sync-only, but the loop must not join a shared run.
2575 assert!(workflows.iter().all(|w| w.fully_sync));
2576 assert!(!joins_sync_run(&workflows[1]));
2577
2578 let exec = executor(compiler.into_engine());
2579 let mut message = Message::from_value(&json!({}));
2580
2581 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2582 .await
2583 .expect("all three should run");
2584
2585 for id in ["before", "mid", "after"] {
2586 assert_eq!(
2587 message.context["data"].get(id),
2588 Some(&dv(json!(true))),
2589 "workflow {id} must have run"
2590 );
2591 }
2592 let order: Vec<(&str, Option<i64>)> = message
2593 .audit_trail
2594 .iter()
2595 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
2596 .collect();
2597 assert_eq!(
2598 order,
2599 [
2600 ("before", None),
2601 ("mid", Some(0)),
2602 ("mid", Some(1)),
2603 ("after", None),
2604 ],
2605 "priority order is preserved across the split"
2606 );
2607 }
2608
2609 #[tokio::test]
2610 async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
2611 // The other half of the same regression: without a loop in the way,
2612 // every fully-sync workflow still groups as it always did.
2613 let compiler = LogicCompiler::new();
2614 let workflows = compiler
2615 .compile_workflows(vec![
2616 Workflow::from_json(
2617 r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
2618 "function": {"name": "map", "input": {"mappings": [
2619 {"path": "data.a", "logic": 1}]}}}] }"#,
2620 )
2621 .unwrap(),
2622 Workflow::from_json(
2623 r#"{ "id": "b", "name": "b", "priority": 1,
2624 "condition": {"==": [{"var": "data.a"}, 1]},
2625 "tasks": [{"id": "t", "name": "t",
2626 "function": {"name": "map", "input": {"mappings": [
2627 {"path": "data.b", "logic": 2}]}}}] }"#,
2628 )
2629 .unwrap(),
2630 ])
2631 .expect("should compile");
2632 assert!(workflows.iter().all(joins_sync_run));
2633
2634 let exec = executor(compiler.into_engine());
2635 let mut message = Message::from_value(&json!({}));
2636 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2637 .await
2638 .expect("both should run");
2639
2640 // `b`'s condition reads what `a` wrote, which only works if the shared
2641 // arena context was refreshed across the workflow boundary.
2642 assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
2643 assert_eq!(counters(&message), vec![None, None]);
2644 }
2645
2646 #[tokio::test]
2647 async fn a_loop_body_can_index_an_array_by_its_counter() {
2648 // The per-item pattern, using only core operators.
2649 let (workflow, engine) = compiled(
2650 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2651 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
2652 "input": {"mappings": [
2653 {"path": "data.picked",
2654 "logic": {"merge": [{"var": "data.picked"},
2655 [{"val": [["data", "items",
2656 {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
2657 );
2658 let mut message = Message::builder()
2659 .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
2660 .build();
2661
2662 executor(engine)
2663 .execute(&workflow, &mut message, Utc::now())
2664 .await
2665 .expect("loop should complete");
2666
2667 assert_eq!(
2668 serde_json::Value::from(&message.context["data"]["picked"]),
2669 json!(["a", "b", "c"]),
2670 "each sweep appended the item at its own index"
2671 );
2672 }
2673
2674 #[tokio::test]
2675 async fn test_workflow_executor_skip_condition() {
2676 // Create a workflow with a false condition
2677 let workflow_json = r#"{
2678 "id": "test_workflow",
2679 "name": "Test Workflow",
2680 "condition": false,
2681 "tasks": [{
2682 "id": "dummy_task",
2683 "name": "Dummy Task",
2684 "function": {
2685 "name": "map",
2686 "input": {"mappings": []}
2687 }
2688 }]
2689 }"#;
2690
2691 let compiler = LogicCompiler::new();
2692 let mut workflow = Workflow::from_json(workflow_json).unwrap();
2693
2694 // Compile the workflow condition
2695 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2696 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2697 workflow = compiled_workflow.clone();
2698 }
2699
2700 let engine = compiler.into_engine();
2701 let task_executor = Arc::new(TaskExecutor::new(
2702 Arc::new(HashMap::new()),
2703 Arc::clone(&engine),
2704 ));
2705 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2706
2707 let mut message = Message::from_value(&json!({}));
2708
2709 // Execute workflow - should be skipped due to false condition
2710 let executed = workflow_executor
2711 .execute(&workflow, &mut message, Utc::now())
2712 .await
2713 .unwrap();
2714 assert!(!executed);
2715 assert_eq!(message.audit_trail.len(), 0);
2716 }
2717
2718 #[tokio::test]
2719 async fn test_workflow_executor_execute_success() {
2720 // Create a workflow with a true condition
2721 let workflow_json = r#"{
2722 "id": "test_workflow",
2723 "name": "Test Workflow",
2724 "condition": true,
2725 "tasks": [{
2726 "id": "dummy_task",
2727 "name": "Dummy Task",
2728 "function": {
2729 "name": "map",
2730 "input": {"mappings": []}
2731 }
2732 }]
2733 }"#;
2734
2735 let compiler = LogicCompiler::new();
2736 let mut workflow = Workflow::from_json(workflow_json).unwrap();
2737
2738 // Compile the workflow
2739 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2740 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2741 workflow = compiled_workflow.clone();
2742 }
2743
2744 let engine = compiler.into_engine();
2745 let task_executor = Arc::new(TaskExecutor::new(
2746 Arc::new(HashMap::new()),
2747 Arc::clone(&engine),
2748 ));
2749 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2750
2751 let mut message = Message::from_value(&json!({}));
2752
2753 // Execute workflow - should succeed with empty task list
2754 let executed = workflow_executor
2755 .execute(&workflow, &mut message, Utc::now())
2756 .await
2757 .unwrap();
2758 assert!(executed);
2759 }
2760}