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