1use crate::engine::error::{
7 DataflowError, ErrorContextConfig, ErrorInfo, Result, from_datalogic_eval, service_error_code,
8};
9use crate::engine::executor::{
10 ArenaContext, eval_to_owned, evaluate_condition, evaluate_condition_in_arena, with_arena,
11};
12use crate::engine::for_each::ForEach;
13use crate::engine::functions::BoxedFunctionHandler;
14use crate::engine::message::{AuditTrail, Change, Message};
15use crate::engine::observer::{
16 ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
17 WorkflowStarted,
18};
19use crate::engine::task::{HaltOn, Task};
20use crate::engine::task_context::{CallStamp, TaskIdentity};
21use crate::engine::task_executor::TaskExecutor;
22use crate::engine::task_outcome::TaskOutcome;
23use crate::engine::trace::{
24 ExecutionStep, ExecutionTrace, StepStamp, StepTiming, duration_us_between,
25};
26use crate::engine::utils::{
27 compute_path_parts, describe_kind, get_nested_value_parts, remove_nested_value,
28 set_nested_value, set_nested_value_parts, strip_hash_prefix,
29};
30use crate::engine::workflow::{LoopConfig, Workflow};
31use chrono::{DateTime, Utc};
32use core::time::Duration;
33use datalogic_rs::{Engine, Logic};
34use datavalue::OwnedDataValue;
35use log::{debug, error, info, warn};
36use serde_json::Value;
37use std::collections::HashMap;
38use std::sync::Arc;
39
40enum TaskControlFlow {
42 Continue,
44 HaltWorkflow,
46}
47
48#[derive(Default)]
56struct WorkflowSpan {
57 started_at: Option<DateTime<Utc>>,
58 sweeps: u32,
59}
60
61#[derive(Clone, Copy)]
65struct PassCtx {
66 now: DateTime<Utc>,
69 loop_counter: Option<i64>,
72 element_index: Option<usize>,
75}
76
77impl PassCtx {
78 #[inline]
80 fn once(now: DateTime<Utc>) -> Self {
81 Self {
82 now,
83 loop_counter: None,
84 element_index: None,
85 }
86 }
87
88 fn note_executed(
95 self,
96 trace: Option<&mut ExecutionTrace>,
97 workflow_id: &str,
98 task_id: &str,
99 message: &Message,
100 clocks: TaskClocks,
101 mapping_contexts: Option<Vec<Value>>,
102 ) {
103 let Some(t) = trace else {
104 return;
105 };
106 let started_at = clocks.trace_start.unwrap_or(self.now);
107 t.add_executed_step(
108 workflow_id,
109 task_id,
110 message,
111 StepTiming {
112 started_at,
113 duration_us: duration_us_between(started_at, Utc::now()),
114 },
115 mapping_contexts,
116 StepStamp {
117 loop_counter: self.loop_counter,
118 element_index: self.element_index,
119 },
120 );
121 }
122}
123
124impl PassCtx {
125 fn note_executed_timed(
133 self,
134 trace: Option<&mut ExecutionTrace>,
135 workflow_id: &str,
136 task_id: &str,
137 message: &Message,
138 timing: StepTiming,
139 ) {
140 if let Some(t) = trace {
141 t.add_executed_step(
142 workflow_id,
143 task_id,
144 message,
145 timing,
146 None,
147 StepStamp {
148 loop_counter: self.loop_counter,
149 element_index: self.element_index,
150 },
151 );
152 }
153 }
154}
155
156#[derive(Clone, Copy)]
165struct TaskClocks {
166 trace_start: Option<DateTime<Utc>>,
168 obs_start: Option<DateTime<Utc>>,
172 errors_before: usize,
175}
176
177#[derive(Clone, Copy)]
183struct PassList<'a> {
184 workflow: &'a Workflow,
185 tasks: &'a [Task],
186}
187
188impl<'a> PassList<'a> {
189 #[inline]
192 fn body(workflow: &'a Workflow) -> Self {
193 Self {
194 workflow,
195 tasks: &workflow.tasks,
196 }
197 }
198}
199
200enum Admission {
202 Halt,
204 Jump(usize),
206 Skip,
208 Run,
210}
211
212fn admit_task(
223 list: PassList<'_>,
224 task: &Task,
225 abs: usize,
226 gate: &mut GroupGate,
227 mut trace: Option<&mut ExecutionTrace>,
228 pass: PassCtx,
229 mut eval: impl FnMut(Option<&Arc<Logic>>) -> Result<bool>,
230) -> Result<Admission> {
231 if gate.close_through(abs) {
232 return Ok(Admission::Halt);
233 }
234
235 if let Some(target) = gate.enter(task, &mut eval)? {
236 note_group_skip(trace.as_deref_mut(), list, abs, target, pass.loop_counter);
237 return Ok(Admission::Jump(target));
238 }
239
240 if !eval(task.compiled_condition.as_ref())? {
241 note_task_skip(trace, &list.workflow.id, &task.id, pass.loop_counter);
242 return Ok(Admission::Skip);
243 }
244
245 Ok(Admission::Run)
246}
247
248#[derive(Clone, Copy)]
255struct TaskPass {
256 continue_on_error: bool,
258 terminal: bool,
261 halt_on: HaltOn,
264 errors_before: usize,
267}
268
269impl TaskPass {
270 #[inline]
278 fn halts_at(self, status: u16) -> bool {
279 self.terminal || (self.halt_on == HaltOn::Failure && status >= 400)
280 }
281}
282
283#[derive(Clone, Copy)]
288struct ElementCall<'a> {
289 workflow: &'a Workflow,
290 task: &'a Task,
291 for_each: &'a ForEach,
292 loop_counter: Option<i64>,
294 timed: bool,
298}
299
300struct ElementOutcome {
305 index: usize,
306 result: Result<TaskOutcome>,
309 changes: Vec<Change>,
311 new_errors: Vec<ErrorInfo>,
313 collected: OwnedDataValue,
315 timing: Option<StepTiming>,
317}
318
319impl ElementOutcome {
320 fn failed(&self) -> bool {
323 match &self.result {
324 Err(_) => true,
325 Ok(outcome) => outcome.audit_status().is_some_and(|s| s >= 400),
326 }
327 }
328
329 fn stops_fan_out(&self, continue_on_error: bool) -> bool {
335 match &self.result {
336 Err(_) => !continue_on_error,
337 Ok(TaskOutcome::Halt) => true,
338 Ok(outcome) => !continue_on_error && outcome.audit_status().is_some_and(|s| s >= 500),
339 }
340 }
341}
342
343fn fan_out_pass(task: &Task, errors_before: usize) -> TaskPass {
347 TaskPass {
348 continue_on_error: task.continue_on_error,
349 terminal: false,
350 halt_on: HaltOn::Never,
351 errors_before,
352 }
353}
354
355fn fan_out_flow(task: &Task, halt: bool) -> TaskControlFlow {
358 if halt || task.terminal {
359 TaskControlFlow::HaltWorkflow
360 } else {
361 TaskControlFlow::Continue
362 }
363}
364
365struct TaskSlice<'a, 'arena> {
372 tasks: &'arena [Task],
374 offset: usize,
376 gate: &'a mut GroupGate,
378 list: PassList<'arena>,
380}
381
382enum SliceOutcome {
384 Completed,
386 Halted,
389 JumpTo(usize),
392}
393
394#[derive(Default)]
406struct GroupGate {
407 open: Vec<(usize, bool)>,
409}
410
411impl GroupGate {
412 fn close_through(&mut self, idx: usize) -> bool {
420 let mut terminal = false;
421 while let Some(&(end, is_terminal)) = self.open.last() {
422 if end > idx {
423 break;
424 }
425 self.open.pop();
426 terminal |= is_terminal;
427 }
428 terminal
429 }
430
431 fn enter<F>(&mut self, task: &Task, mut eval: F) -> Result<Option<usize>>
435 where
436 F: FnMut(Option<&Arc<Logic>>) -> Result<bool>,
437 {
438 for group in &task.group_starts {
439 if !eval(group.compiled_condition.as_ref())? {
440 return Ok(Some(group.end));
441 }
442 self.open.push((group.end, group.terminal));
443 }
444 Ok(None)
445 }
446}
447
448fn note_group_skip(
454 mut trace: Option<&mut ExecutionTrace>,
455 list: PassList<'_>,
456 from: usize,
457 to: usize,
458 loop_counter: Option<i64>,
459) {
460 for task in &list.tasks[from..to.min(list.tasks.len())] {
461 note_task_skip(
462 trace.as_deref_mut(),
463 &list.workflow.id,
464 &task.id,
465 loop_counter,
466 );
467 }
468}
469
470enum PassOutcome {
472 ConditionFalse,
474 Completed,
476 Halted,
478}
479
480fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
484 let mut i = start;
485 while i < tasks.len() && tasks[i].function.is_sync_builtin() {
486 i += 1;
487 }
488 i
489}
490
491fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
496 debug!("Skipping workflow {} - {}", workflow_id, reason);
497 if let Some(t) = trace {
498 t.add_step(ExecutionStep::workflow_skipped(workflow_id));
499 }
500}
501
502fn note_task_skip(
509 trace: Option<&mut ExecutionTrace>,
510 workflow_id: &str,
511 task_id: &str,
512 loop_counter: Option<i64>,
513) {
514 debug!("Skipping task {} - condition not met", task_id);
515 if let Some(t) = trace {
516 t.add_step(
517 ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
518 );
519 }
520}
521
522fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
529 match (workflow.rollout, message.routing_bucket()) {
530 (Some(r), Some(b)) => r.accepts(b),
531 _ => true,
532 }
533}
534
535fn joins_sync_run(workflow: &Workflow) -> bool {
543 workflow.fully_sync && workflow.loop_config.is_none()
544}
545
546fn resolve_parts(name: Option<&str>, precomputed: &Arc<[Arc<str>]>) -> Arc<[Arc<str>]> {
558 match name {
559 Some(name) if precomputed.is_empty() => compute_path_parts("temp_data", name),
560 _ => Arc::clone(precomputed),
561 }
562}
563
564fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
566 OwnedDataValue::Object(vec![
567 (
568 "workflow_id".to_string(),
569 OwnedDataValue::String(workflow_id.to_string()),
570 ),
571 (
572 "task_id".to_string(),
573 OwnedDataValue::String(task_id.to_string()),
574 ),
575 (
576 "status_code".to_string(),
577 OwnedDataValue::from(u64::from(status)),
578 ),
579 ])
580}
581
582fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
589 match slot {
590 OwnedDataValue::String(existing) => {
591 if existing != value {
592 existing.clear();
593 existing.push_str(value);
594 }
595 }
596 _ => *slot = OwnedDataValue::String(value.to_string()),
597 }
598}
599
600fn overwrite_progress_in_place(
606 fields: &mut [(String, OwnedDataValue)],
607 workflow_id: &str,
608 task_id: &str,
609 status: u16,
610) -> bool {
611 if fields.len() != 3 {
612 return false;
613 }
614 let mut matched = 0;
615 for (k, v) in fields.iter_mut() {
616 match k.as_str() {
617 "workflow_id" => {
618 overwrite_str_in_place(v, workflow_id);
619 matched += 1;
620 }
621 "task_id" => {
622 overwrite_str_in_place(v, task_id);
623 matched += 1;
624 }
625 "status_code" => {
626 *v = OwnedDataValue::from(u64::from(status));
627 matched += 1;
628 }
629 _ => {}
630 }
631 }
632 matched == 3
633}
634
635fn write_progress_metadata(
644 context: &mut OwnedDataValue,
645 workflow_id: &str,
646 task_id: &str,
647 status: u16,
648) {
649 if let OwnedDataValue::Object(top) = context
650 && let Some((_, OwnedDataValue::Object(meta))) =
651 top.iter_mut().find(|(k, _)| k == "metadata")
652 {
653 match meta.iter_mut().find(|(k, _)| k == "progress") {
654 Some((_, slot)) => {
655 if let OwnedDataValue::Object(fields) = slot
656 && overwrite_progress_in_place(fields, workflow_id, task_id, status)
657 {
658 return;
659 }
660 *slot = new_progress_object(workflow_id, task_id, status);
661 }
662 None => {
663 meta.push((
664 "progress".to_string(),
665 new_progress_object(workflow_id, task_id, status),
666 ));
667 }
668 }
669 return;
670 }
671 set_nested_value(
672 context,
673 "metadata.progress",
674 new_progress_object(workflow_id, task_id, status),
675 );
676}
677
678fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
687 OwnedDataValue::Object(vec![
688 ("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
689 ("task_id".to_string(), OwnedDataValue::from(task_id)),
690 ("code".to_string(), OwnedDataValue::from(code)),
691 (
692 "status".to_string(),
693 OwnedDataValue::from(u64::from(status)),
694 ),
695 ])
696}
697
698fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
704 if !matches!(node, OwnedDataValue::Object(_)) {
705 *node = OwnedDataValue::Object(Vec::new());
706 }
707 match node {
708 OwnedDataValue::Object(fields) => fields,
709 _ => unreachable!("just normalised to an Object"),
710 }
711}
712
713fn append_error_records(
727 context: &mut OwnedDataValue,
728 cfg: &ErrorContextConfig,
729 workflow_id: &str,
730 task_id: &str,
731 status: u16,
732 new_errors: &[ErrorInfo],
733) {
734 if new_errors.is_empty() {
735 return;
736 }
737 let Some((last, parents)) = cfg.path_parts.split_last() else {
740 return;
741 };
742
743 let mut node = context;
744 for part in parents {
745 let key = strip_hash_prefix(part);
746 if !matches!(node, OwnedDataValue::Object(_)) {
747 warn!(
752 "error context path `{}` runs through a non-object at `{}` — replacing it",
753 cfg.path, key
754 );
755 }
756 let fields = as_object_slot(node);
757 let idx = match fields.iter().position(|(k, _)| k == key) {
758 Some(i) => i,
759 None => {
760 fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
761 fields.len() - 1
762 }
763 };
764 node = &mut fields[idx].1;
765 }
766
767 let key = strip_hash_prefix(last);
768 let fields = as_object_slot(node);
769 let idx = match fields.iter().position(|(k, _)| k == key) {
770 Some(i) => i,
771 None => {
772 fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
773 fields.len() - 1
774 }
775 };
776 let slot = &mut fields[idx].1;
777 if !matches!(slot, OwnedDataValue::Array(_)) {
778 warn!(
779 "error context path `{}` held a non-array — replacing it",
780 cfg.path
781 );
782 *slot = OwnedDataValue::Array(Vec::new());
783 }
784 let OwnedDataValue::Array(items) = slot else {
785 unreachable!("just ensured an Array");
786 };
787
788 for error in new_errors {
789 items.push(new_error_record(workflow_id, task_id, &error.code, status));
790 }
791 if items.len() > cfg.limit {
795 items.drain(..items.len() - cfg.limit);
796 }
797}
798
799pub struct WorkflowExecutor {
807 task_executor: Arc<TaskExecutor>,
809 engine: Arc<Engine>,
811 observer: Option<Arc<dyn ExecutionObserver>>,
814 error_context: Option<Arc<ErrorContextConfig>>,
817}
818
819impl WorkflowExecutor {
820 pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
822 Self {
823 task_executor,
824 engine,
825 observer: None,
826 error_context: None,
827 }
828 }
829
830 pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
832 self.observer = Some(observer);
833 self
834 }
835
836 pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
841 self.observer.as_ref()
842 }
843
844 pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
847 self.error_context = Some(cfg);
848 self
849 }
850
851 pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
857 self.error_context.as_ref()
858 }
859
860 #[inline]
866 fn emit_task_event(
867 &self,
868 workflow: &Workflow,
869 task: &Task,
870 result: &Result<(TaskOutcome, Vec<Change>)>,
871 started_at: Option<DateTime<Utc>>,
872 ) {
873 if self.observer.is_some() {
874 let duration = started_at
875 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
876 .unwrap_or_default();
877 self.emit_task_finished(workflow, task, result, duration);
878 }
879 }
880
881 fn emit_task_finished(
885 &self,
886 workflow: &Workflow,
887 task: &Task,
888 result: &Result<(TaskOutcome, Vec<Change>)>,
889 duration: Duration,
890 ) {
891 if let Some(observer) = self.observer.as_ref() {
892 let status = match result {
893 Ok((outcome, _)) => outcome.audit_status(),
894 Err(_) => Some(500),
895 };
896 observer.task_finished(&TaskEvent {
897 workflow_id: &workflow.id,
898 task_id: &task.id,
899 function: task.function.function_name(),
900 status,
901 duration,
902 });
903 }
904 }
905
906 fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
910 span.sweeps += 1;
911 let Some(observer) = self.observer.as_ref() else {
915 return;
916 };
917 if span.started_at.is_some() {
918 return;
919 }
920 span.started_at = Some(Utc::now());
921 observer.workflow_started(&WorkflowStarted {
922 workflow_id: &workflow.id,
923 });
924 }
925
926 fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
929 let Some(observer) = self.observer.as_ref() else {
930 return;
931 };
932 let Some(started) = span.started_at else {
933 return;
934 };
935 observer.workflow_finished(&WorkflowFinished {
936 workflow_id: &workflow.id,
937 duration: Duration::from_micros(duration_us_between(started, Utc::now())),
938 sweeps: span.sweeps,
939 halted,
940 });
941 }
942
943 #[inline]
948 fn observer_clock(&self) -> Option<DateTime<Utc>> {
949 self.observer.as_ref().map(|_| Utc::now())
950 }
951
952 #[inline]
955 fn open_task_clocks(&self, tracing: bool, message: &Message) -> TaskClocks {
956 let trace_start = if tracing { Some(Utc::now()) } else { None };
960 TaskClocks {
961 trace_start,
962 obs_start: trace_start.or_else(|| self.observer_clock()),
963 errors_before: message.errors.len(),
967 }
968 }
969
970 pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
972 self.task_executor.task_functions()
973 }
974
975 pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
978 self.task_executor.registry()
979 }
980
981 pub async fn execute(
996 &self,
997 workflow: &Workflow,
998 message: &mut Message,
999 now: DateTime<Utc>,
1000 ) -> Result<bool> {
1001 self.execute_inner(workflow, message, None, now).await
1002 }
1003
1004 pub async fn execute_with_trace(
1008 &self,
1009 workflow: &Workflow,
1010 message: &mut Message,
1011 trace: &mut ExecutionTrace,
1012 now: DateTime<Utc>,
1013 ) -> Result<bool> {
1014 self.execute_inner(workflow, message, Some(trace), now)
1015 .await
1016 }
1017
1018 async fn execute_inner(
1025 &self,
1026 workflow: &Workflow,
1027 message: &mut Message,
1028 mut trace: Option<&mut ExecutionTrace>,
1029 now: DateTime<Utc>,
1030 ) -> Result<bool> {
1031 if !rollout_admits(workflow, message) {
1036 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
1037 return Ok(false);
1038 }
1039
1040 if let Some(loop_config) = workflow.loop_config.as_ref() {
1041 return self
1042 .execute_loop(workflow, loop_config, message, trace, now)
1043 .await;
1044 }
1045
1046 let mut span = WorkflowSpan::default();
1049 let outcome = self
1050 .execute_pass(
1051 PassList::body(workflow),
1052 message,
1053 trace.as_deref_mut(),
1054 PassCtx::once(now),
1055 &mut span,
1056 )
1057 .await;
1058 self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
1059
1060 match outcome {
1061 Ok(PassOutcome::ConditionFalse) => {
1062 note_workflow_skip(trace, &workflow.id, "condition not met");
1064 Ok(false)
1065 }
1066 Ok(_) => {
1067 info!("Successfully completed workflow: {}", workflow.id);
1068 Ok(true)
1069 }
1070 Err(e) => {
1071 if self.record_workflow_error(workflow, message, &e) {
1077 Err(e)
1078 } else {
1079 Ok(true)
1080 }
1081 }
1082 }
1083 }
1084
1085 async fn execute_loop(
1102 &self,
1103 workflow: &Workflow,
1104 config: &LoopConfig,
1105 message: &mut Message,
1106 mut trace: Option<&mut ExecutionTrace>,
1107 now: DateTime<Utc>,
1108 ) -> Result<bool> {
1109 let counter_parts = resolve_parts(config.counter.as_deref(), &config.counter_parts);
1110 let item_parts = resolve_parts(config.item.as_deref(), &config.item_parts);
1111 let scratch_parts = resolve_parts(config.scratch.as_deref(), &config.scratch_parts);
1112 let body = PassList::body(workflow);
1113 let mut span = WorkflowSpan::default();
1116 let mut halted = false;
1117
1118 let mut admitted = false;
1123 let mut items: Option<Vec<OwnedDataValue>> = None;
1124 if !config.setup.is_empty() || config.over.is_some() {
1125 let setup = PassList {
1126 workflow,
1127 tasks: &config.setup,
1128 };
1129 match self
1130 .execute_pass(
1131 setup,
1132 message,
1133 trace.as_deref_mut(),
1134 PassCtx::once(now),
1135 &mut span,
1136 )
1137 .await
1138 {
1139 Ok(PassOutcome::ConditionFalse) => {
1140 note_workflow_skip(trace, &workflow.id, "condition not met");
1141 return Ok(false);
1142 }
1143 Ok(PassOutcome::Halted) => {
1144 span.sweeps = 0;
1147 self.end_workflow(&span, workflow, true);
1148 info!(
1149 "Successfully completed workflow: {} (halted in loop setup)",
1150 workflow.id
1151 );
1152 return Ok(true);
1153 }
1154 Ok(PassOutcome::Completed) => {}
1155 Err(e) => {
1156 span.sweeps = 0;
1160 let stop = self.record_workflow_error(workflow, message, &e);
1161 self.end_workflow(&span, workflow, false);
1162 return if stop { Err(e) } else { Ok(true) };
1163 }
1164 }
1165 admitted = true;
1166 span.sweeps = 0;
1169
1170 if config.over.is_some() {
1171 match self.evaluate_over(workflow, config, message) {
1172 Ok(list) => items = Some(list),
1173 Err(e) => {
1174 let stop = self.record_workflow_error(workflow, message, &e);
1175 self.end_workflow(&span, workflow, false);
1176 return if stop { Err(e) } else { Ok(true) };
1177 }
1178 }
1179 }
1180 }
1181
1182 let mut counter = config.init;
1183 let mut sweeps_run: u32 = 0;
1184
1185 loop {
1186 set_nested_value_parts(
1190 &mut message.context,
1191 &counter_parts,
1192 OwnedDataValue::from_i64(counter),
1193 );
1194
1195 if counter >= config.max {
1202 if workflow.compiled_condition.is_some() {
1207 warn!(
1208 "Workflow {} stopped at its loop bound (max {}) with the condition \
1209 still true after {} sweep(s)",
1210 workflow.id, config.max, sweeps_run
1211 );
1212 }
1213 if let Some(items) = &items
1214 && let Ok(next) = usize::try_from(counter)
1215 && next < items.len()
1216 {
1217 warn!(
1218 "Workflow {} stopped at its loop bound (max {}) with {} of {} \
1219 element(s) of loop.over unvisited",
1220 workflow.id,
1221 config.max,
1222 items.len() - next,
1223 items.len()
1224 );
1225 }
1226 break;
1227 }
1228
1229 if let Some(items) = items.as_mut() {
1232 let Some(slot) = usize::try_from(counter).ok().and_then(|i| items.get_mut(i))
1233 else {
1234 break;
1235 };
1236 let element = std::mem::take(slot);
1239 set_nested_value_parts(&mut message.context, &item_parts, element);
1240 }
1241
1242 set_nested_value_parts(
1247 &mut message.context,
1248 &scratch_parts,
1249 OwnedDataValue::Object(Vec::new()),
1250 );
1251
1252 let pass = PassCtx {
1253 now,
1254 loop_counter: Some(counter),
1255 element_index: None,
1256 };
1257
1258 match self
1259 .execute_pass(body, message, trace.as_deref_mut(), pass, &mut span)
1260 .await
1261 {
1262 Ok(PassOutcome::ConditionFalse) => {
1263 if sweeps_run == 0 && !admitted {
1264 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1267 } else {
1268 debug!(
1269 "Workflow {} loop exited at counter {} - condition no longer met",
1270 workflow.id, counter
1271 );
1272 }
1273 break;
1274 }
1275 Ok(PassOutcome::Halted) => {
1276 sweeps_run += 1;
1277 debug!(
1278 "Workflow {} loop halted at counter {}",
1279 workflow.id, counter
1280 );
1281 halted = true;
1282 break;
1283 }
1284 Ok(PassOutcome::Completed) => {
1285 sweeps_run += 1;
1286 }
1287 Err(e) => {
1288 sweeps_run += 1;
1289 if self.record_workflow_error(workflow, message, &e) {
1294 self.end_workflow(&span, workflow, halted);
1298 return Err(e);
1299 }
1300 }
1301 }
1302
1303 counter = counter.saturating_add(config.increment);
1304 }
1305
1306 self.end_workflow(&span, workflow, halted);
1307
1308 if sweeps_run > 0 {
1309 info!(
1310 "Successfully completed workflow: {} ({} loop sweep(s))",
1311 workflow.id, sweeps_run
1312 );
1313 }
1314 Ok(sweeps_run > 0 || admitted)
1315 }
1316
1317 async fn run_for_each(
1326 &self,
1327 workflow: &Workflow,
1328 task: &Task,
1329 for_each: &ForEach,
1330 message: &mut Message,
1331 mut trace: Option<&mut ExecutionTrace>,
1332 pass: PassCtx,
1333 ) -> Result<TaskControlFlow> {
1334 use futures_util::stream::{FuturesUnordered, StreamExt};
1335
1336 let tracing = trace.is_some();
1337
1338 let items = match self.evaluate_for_each_over(task, for_each, message) {
1343 Ok(items) if !items.is_empty() => items,
1344 other => {
1345 let failed = other.is_err();
1346 if other.is_ok() && !for_each.into_parts.is_empty() {
1347 self.write_empty_results(for_each, message);
1348 }
1349 let clocks = self.open_task_clocks(tracing, message);
1350 let result = other.map(|_| {
1351 let changes = self.empty_results_change(for_each, message);
1352 (TaskOutcome::Success, changes)
1353 });
1354 self.emit_task_event(workflow, task, &result, clocks.obs_start);
1355 let flow = self.handle_task_result(
1356 result,
1357 &workflow.id_arc,
1358 &task.id_arc,
1359 fan_out_pass(task, clocks.errors_before),
1360 message,
1361 pass,
1362 )?;
1363 pass.note_executed(trace, &workflow.id, &task.id, message, clocks, None);
1364 let halt = matches!(flow, TaskControlFlow::HaltWorkflow)
1365 || (failed && task.halt_on == HaltOn::Failure);
1366 return Ok(fan_out_flow(task, halt));
1367 }
1368 };
1369 let n = items.len();
1370
1371 let call = ElementCall {
1373 workflow,
1374 task,
1375 for_each,
1376 loop_counter: pass.loop_counter,
1377 timed: tracing || self.observer.is_some(),
1378 };
1379 let mut outcomes: Vec<Option<ElementOutcome>> = (0..n).map(|_| None).collect();
1380 {
1381 let base: &Message = message;
1384 let mut pending = items.into_iter().enumerate();
1385 let mut in_flight = FuturesUnordered::new();
1386 let mut stop = false;
1387 loop {
1388 while !stop && in_flight.len() < for_each.max_concurrency {
1389 let Some((index, item)) = pending.next() else {
1390 break;
1391 };
1392 in_flight.push(self.run_element(call, base, index, item));
1393 }
1394 let Some(outcome) = in_flight.next().await else {
1395 break;
1396 };
1397 stop |= outcome.stops_fan_out(task.continue_on_error);
1398 let index = outcome.index;
1399 outcomes[index] = Some(outcome);
1400 }
1401 }
1402
1403 if !for_each.into_parts.is_empty() {
1407 set_nested_value_parts(
1410 &mut message.context,
1411 &for_each.into_parts,
1412 OwnedDataValue::Array(vec![OwnedDataValue::Null; n]),
1413 );
1414 }
1415 let mut halt = false;
1416 let mut any_failed = false;
1417 for outcome in outcomes.into_iter().flatten() {
1418 let failed = outcome.failed();
1419 let ElementOutcome {
1420 index,
1421 result,
1422 changes,
1423 new_errors,
1424 collected,
1425 timing,
1426 } = outcome;
1427
1428 let errors_before = message.errors.len();
1432 message
1433 .errors
1434 .extend(new_errors.into_iter().map(|mut error| {
1435 error.element_index.get_or_insert(index);
1436 error
1437 }));
1438
1439 for change in &changes {
1440 if change.removed {
1441 remove_nested_value(&mut message.context, &change.path);
1442 } else {
1443 set_nested_value(&mut message.context, &change.path, change.new_value.clone());
1444 }
1445 }
1446
1447 let mut changes = if message.capture_changes {
1448 changes
1449 } else {
1450 Vec::new()
1451 };
1452 if let Some(into) = for_each.into.as_deref() {
1453 let value = if failed {
1454 OwnedDataValue::Null
1455 } else {
1456 collected
1457 };
1458 if message.capture_changes {
1459 changes.push(Change {
1460 path: Arc::from(format!("{into}.{index}")),
1461 old_value: OwnedDataValue::Null,
1462 new_value: value.clone(),
1463 removed: false,
1464 });
1465 }
1466 let mut parts = for_each.into_parts.to_vec();
1467 parts.push(Arc::from(index.to_string()));
1468 set_nested_value_parts(&mut message.context, &parts, value);
1469 }
1470
1471 any_failed |= failed;
1472 let result = result.map(|outcome| (outcome, changes));
1473 let element = PassCtx {
1474 element_index: Some(index),
1475 ..pass
1476 };
1477 let duration = timing
1478 .map(|t| Duration::from_micros(t.duration_us))
1479 .unwrap_or_default();
1480 self.emit_task_finished(workflow, task, &result, duration);
1481 let flow = self.handle_task_result(
1482 result,
1483 &workflow.id_arc,
1484 &task.id_arc,
1485 fan_out_pass(task, errors_before),
1486 message,
1487 element,
1488 )?;
1489 if let Some(timing) = timing {
1490 element.note_executed_timed(
1491 trace.as_deref_mut(),
1492 &workflow.id,
1493 &task.id,
1494 message,
1495 timing,
1496 );
1497 }
1498 if matches!(flow, TaskControlFlow::HaltWorkflow) {
1503 halt = true;
1504 break;
1505 }
1506 }
1507
1508 if halt {
1509 info!(
1510 "Task {} halted workflow {} during its fan-out",
1511 task.id, workflow.id
1512 );
1513 }
1514 Ok(fan_out_flow(
1515 task,
1516 halt || (task.halt_on == HaltOn::Failure && any_failed),
1517 ))
1518 }
1519
1520 async fn run_element(
1530 &self,
1531 call: ElementCall<'_>,
1532 base: &Message,
1533 index: usize,
1534 item: OwnedDataValue,
1535 ) -> ElementOutcome {
1536 let fe = call.for_each;
1537 let mut copy = base.fork_for_element();
1538 set_nested_value_parts(&mut copy.context, &fe.item_parts, item);
1539 set_nested_value_parts(
1540 &mut copy.context,
1541 &fe.index_parts,
1542 OwnedDataValue::from(index as u64),
1543 );
1544
1545 let started_at = call.timed.then(Utc::now);
1546 let result = self
1547 .task_executor
1548 .execute_in_workflow(
1549 call.task,
1550 &mut copy,
1551 Some(TaskIdentity {
1552 workflow_id: &call.workflow.id_arc,
1553 task_id: &call.task.id_arc,
1554 }),
1555 CallStamp {
1556 loop_counter: call.loop_counter,
1557 element_index: Some(index),
1558 },
1559 )
1560 .await;
1561 let timing = started_at.map(|started_at| StepTiming {
1562 started_at,
1563 duration_us: duration_us_between(started_at, Utc::now()),
1564 });
1565
1566 let (result, changes) = match result {
1567 Ok((outcome, changes)) => (Ok(outcome), changes),
1568 Err(e) => (Err(e), Vec::new()),
1569 };
1570 let collected = if fe.collect_parts.is_empty() {
1571 OwnedDataValue::Null
1572 } else {
1573 get_nested_value_parts(©.context, &fe.collect_parts)
1574 .cloned()
1575 .unwrap_or(OwnedDataValue::Null)
1576 };
1577 ElementOutcome {
1578 index,
1579 result,
1580 changes,
1581 new_errors: copy.errors,
1582 collected,
1583 timing,
1584 }
1585 }
1586
1587 fn evaluate_for_each_over(
1593 &self,
1594 task: &Task,
1595 for_each: &ForEach,
1596 message: &Message,
1597 ) -> Result<Vec<OwnedDataValue>> {
1598 let Some(compiled) = for_each.compiled_over.as_ref() else {
1599 return Err(DataflowError::Task(format!(
1600 "Task {}: for_each.over is not compiled — build the engine through \
1601 Engine::builder or Engine::new rather than running the workflow directly",
1602 task.id
1603 )));
1604 };
1605 match eval_to_owned(&self.engine, compiled, &message.context) {
1606 Ok(OwnedDataValue::Array(items)) => Ok(items),
1607 Ok(other) => Err(DataflowError::Task(format!(
1608 "Task {}: for_each.over must evaluate to an array, got {}",
1609 task.id,
1610 describe_kind(&other)
1611 ))),
1612 Err(e) => match from_datalogic_eval(&e) {
1613 budget @ DataflowError::BudgetExceeded(_) => Err(budget),
1614 other => Err(DataflowError::Task(format!(
1615 "Task {}: for_each.over failed to evaluate: {other}",
1616 task.id
1617 ))),
1618 },
1619 }
1620 }
1621
1622 fn write_empty_results(&self, for_each: &ForEach, message: &mut Message) {
1624 set_nested_value_parts(
1625 &mut message.context,
1626 &for_each.into_parts,
1627 OwnedDataValue::Array(Vec::new()),
1628 );
1629 }
1630
1631 fn empty_results_change(&self, for_each: &ForEach, message: &Message) -> Vec<Change> {
1635 match for_each.into.as_deref() {
1636 Some(into) if message.capture_changes => vec![Change {
1637 path: Arc::from(into),
1638 old_value: OwnedDataValue::Null,
1639 new_value: OwnedDataValue::Array(Vec::new()),
1640 removed: false,
1641 }],
1642 _ => Vec::new(),
1643 }
1644 }
1645
1646 fn evaluate_over(
1652 &self,
1653 workflow: &Workflow,
1654 config: &LoopConfig,
1655 message: &Message,
1656 ) -> Result<Vec<OwnedDataValue>> {
1657 let Some(compiled) = config.compiled_over.as_ref() else {
1658 return Err(DataflowError::Workflow(format!(
1659 "Workflow {}: loop.over is not compiled — build the engine through \
1660 Engine::builder or Engine::new rather than running the workflow directly",
1661 workflow.id
1662 )));
1663 };
1664 match eval_to_owned(&self.engine, compiled, &message.context) {
1665 Ok(OwnedDataValue::Array(items)) => Ok(items),
1666 Ok(other) => Err(DataflowError::Workflow(format!(
1667 "Workflow {}: loop.over must evaluate to an array, got {}",
1668 workflow.id,
1669 describe_kind(&other)
1670 ))),
1671 Err(e) => Err(DataflowError::Workflow(format!(
1672 "Workflow {}: loop.over failed to evaluate: {}",
1673 workflow.id,
1674 from_datalogic_eval(&e)
1675 ))),
1676 }
1677 }
1678
1679 async fn execute_pass(
1695 &self,
1696 list: PassList<'_>,
1697 message: &mut Message,
1698 mut trace: Option<&mut ExecutionTrace>,
1699 pass: PassCtx,
1700 span: &mut WorkflowSpan,
1701 ) -> Result<PassOutcome> {
1702 enum FirstStretch {
1704 Skipped,
1706 Halted,
1708 Continue(usize),
1712 }
1713
1714 let PassList { workflow, tasks } = list;
1715 let first_boundary = next_async_boundary(tasks, 0);
1716 let mut gate = GroupGate::default();
1719
1720 let first: Result<FirstStretch> =
1721 if workflow.compiled_condition.is_none() && first_boundary == 0 {
1722 self.begin_workflow(span, workflow);
1726 Ok(FirstStretch::Continue(0))
1727 } else {
1728 with_arena(|arena| -> Result<FirstStretch> {
1729 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1730
1731 let should_execute = match workflow.compiled_condition.as_ref() {
1732 None => true,
1733 Some(compiled) => evaluate_condition_in_arena(
1734 &self.engine,
1735 Some(compiled),
1736 arena_ctx.as_data_value(),
1737 arena,
1738 )?,
1739 };
1740 if !should_execute {
1741 return Ok(FirstStretch::Skipped);
1742 }
1743 self.begin_workflow(span, workflow);
1747 if first_boundary == 0 {
1748 return Ok(FirstStretch::Continue(0));
1749 }
1750 let outcome = self.run_tasks_slice_in_arena(
1751 TaskSlice {
1752 tasks: &tasks[..first_boundary],
1753 offset: 0,
1754 gate: &mut gate,
1755 list,
1756 },
1757 message,
1758 &mut arena_ctx,
1759 trace.as_deref_mut(),
1760 pass,
1761 )?;
1762 Ok(match outcome {
1763 SliceOutcome::Halted => FirstStretch::Halted,
1764 SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
1765 SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
1766 })
1767 })
1768 };
1769
1770 match first? {
1774 FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
1775 FirstStretch::Halted => Ok(PassOutcome::Halted),
1776 FirstStretch::Continue(resume_at) => {
1777 let halted = self
1778 .execute_tasks(list, message, trace, pass, resume_at, &mut gate)
1779 .await?;
1780 Ok(if halted {
1781 PassOutcome::Halted
1782 } else {
1783 PassOutcome::Completed
1784 })
1785 }
1786 }
1787 }
1788
1789 fn record_workflow_error(
1798 &self,
1799 workflow: &Workflow,
1800 message: &mut Message,
1801 e: &DataflowError,
1802 ) -> bool {
1803 message.errors.push(
1804 ErrorInfo::builder(
1805 "WORKFLOW_ERROR",
1806 format!("Workflow {} error: {}", workflow.id, e),
1807 )
1808 .workflow_id(&workflow.id)
1809 .build(),
1810 );
1811
1812 if workflow.continue_on_error {
1813 warn!(
1814 "Workflow {} encountered error but continuing: {:?}",
1815 workflow.id, e
1816 );
1817 false
1818 } else {
1819 error!("Workflow {} failed: {:?}", workflow.id, e);
1820 true
1821 }
1822 }
1823
1824 async fn execute_tasks(
1844 &self,
1845 list: PassList<'_>,
1846 message: &mut Message,
1847 mut trace: Option<&mut ExecutionTrace>,
1848 pass: PassCtx,
1849 start: usize,
1850 gate: &mut GroupGate,
1851 ) -> Result<bool> {
1852 let PassList { workflow, tasks } = list;
1853 let mut idx = start;
1854 while idx < tasks.len() {
1855 let stretch_end = next_async_boundary(tasks, idx);
1856
1857 if stretch_end > idx {
1858 match self.run_sync_stretch(
1860 TaskSlice {
1861 tasks: &tasks[idx..stretch_end],
1862 offset: idx,
1863 gate,
1864 list,
1865 },
1866 message,
1867 trace.as_deref_mut(),
1868 pass,
1869 )? {
1870 SliceOutcome::Halted => return Ok(true),
1871 SliceOutcome::JumpTo(target) => {
1874 idx = target;
1875 continue;
1876 }
1877 SliceOutcome::Completed => idx = stretch_end,
1878 }
1879 }
1880
1881 if idx < tasks.len() {
1882 let task = &tasks[idx];
1884
1885 match admit_task(
1886 list,
1887 task,
1888 idx,
1889 gate,
1890 trace.as_deref_mut(),
1891 pass,
1892 |compiled| evaluate_condition(&self.engine, compiled, &message.context),
1893 )? {
1894 Admission::Halt => return Ok(true),
1895 Admission::Jump(target) => {
1896 idx = target;
1897 continue;
1898 }
1899 Admission::Skip => {
1900 idx += 1;
1901 continue;
1902 }
1903 Admission::Run => {}
1904 }
1905
1906 if let Some(for_each) = task.for_each.as_ref() {
1911 let flow = self
1912 .run_for_each(
1913 workflow,
1914 task,
1915 for_each,
1916 message,
1917 trace.as_deref_mut(),
1918 pass,
1919 )
1920 .await?;
1921 if matches!(flow, TaskControlFlow::HaltWorkflow) {
1922 return Ok(true);
1923 }
1924 idx += 1;
1925 continue;
1926 }
1927
1928 let clocks = self.open_task_clocks(trace.is_some(), message);
1929
1930 let result = self
1931 .task_executor
1932 .execute_in_workflow(
1933 task,
1934 message,
1935 Some(TaskIdentity {
1936 workflow_id: &workflow.id_arc,
1937 task_id: &task.id_arc,
1938 }),
1939 CallStamp {
1940 loop_counter: pass.loop_counter,
1941 element_index: None,
1942 },
1943 )
1944 .await;
1945
1946 self.emit_task_event(workflow, task, &result, clocks.obs_start);
1948
1949 let control_flow = self.handle_task_result(
1953 result,
1954 &workflow.id_arc,
1955 &task.id_arc,
1956 TaskPass {
1957 continue_on_error: task.continue_on_error,
1958 terminal: task.terminal,
1959 halt_on: task.halt_on,
1960 errors_before: clocks.errors_before,
1961 },
1962 message,
1963 pass,
1964 )?;
1965
1966 pass.note_executed(
1969 trace.as_deref_mut(),
1970 &workflow.id,
1971 &task.id,
1972 message,
1973 clocks,
1974 None,
1975 );
1976
1977 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1978 return Ok(true);
1979 }
1980 idx += 1;
1981 }
1982 }
1983
1984 Ok(gate.close_through(tasks.len()))
1988 }
1989
1990 fn run_sync_stretch(
2000 &self,
2001 slice: TaskSlice<'_, '_>,
2002 message: &mut Message,
2003 trace: Option<&mut ExecutionTrace>,
2004 pass: PassCtx,
2005 ) -> Result<SliceOutcome> {
2006 with_arena(|arena| -> Result<SliceOutcome> {
2007 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2008 self.run_tasks_slice_in_arena(slice, message, &mut arena_ctx, trace, pass)
2009 })
2010 }
2011
2012 fn run_tasks_slice_in_arena<'arena>(
2022 &self,
2023 slice: TaskSlice<'_, 'arena>,
2024 message: &mut Message,
2025 arena_ctx: &mut ArenaContext<'arena>,
2026 mut trace: Option<&mut ExecutionTrace>,
2027 pass: PassCtx,
2028 ) -> Result<SliceOutcome> {
2029 let arena = arena_ctx.arena();
2030 let TaskSlice {
2031 tasks,
2032 offset,
2033 gate,
2034 list,
2035 } = slice;
2036 let workflow = list.workflow;
2037 let slice_end = offset + tasks.len();
2038
2039 let mut i = 0;
2040 while i < tasks.len() {
2041 let task = &tasks[i];
2042 let abs = offset + i;
2043
2044 match admit_task(
2049 list,
2050 task,
2051 abs,
2052 gate,
2053 trace.as_deref_mut(),
2054 pass,
2055 |compiled| {
2056 evaluate_condition_in_arena(
2057 &self.engine,
2058 compiled,
2059 arena_ctx.as_data_value(),
2060 arena,
2061 )
2062 },
2063 )? {
2064 Admission::Halt => return Ok(SliceOutcome::Halted),
2065 Admission::Jump(target) => {
2066 if target >= slice_end {
2069 return Ok(SliceOutcome::JumpTo(target));
2070 }
2071 i = target - offset;
2072 continue;
2073 }
2074 Admission::Skip => {
2075 i += 1;
2076 continue;
2077 }
2078 Admission::Run => {}
2079 }
2080
2081 let mut mapping_snapshots: Vec<Value> = Vec::new();
2085 let want_mapping_contexts = trace
2086 .as_deref()
2087 .is_some_and(|t| t.options().mapping_contexts);
2088 let mapping_snapshots_buf = if want_mapping_contexts {
2089 Some(&mut mapping_snapshots)
2090 } else {
2091 None
2092 };
2093
2094 let clocks = self.open_task_clocks(trace.is_some(), message);
2095
2096 let result =
2097 self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
2098
2099 self.emit_task_event(workflow, task, &result, clocks.obs_start);
2101
2102 let flow = self.handle_task_result(
2103 result,
2104 &workflow.id_arc,
2105 &task.id_arc,
2106 TaskPass {
2107 continue_on_error: task.continue_on_error,
2108 terminal: task.terminal,
2109 halt_on: task.halt_on,
2110 errors_before: clocks.errors_before,
2111 },
2112 message,
2113 pass,
2114 );
2115
2116 arena_ctx.refresh_for_path(&message.context, "metadata.progress");
2129 if let Some(cfg) = self.error_context_refresh(message, clocks.errors_before) {
2133 arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
2134 }
2135
2136 let control_flow = flow?;
2137
2138 pass.note_executed(
2139 trace.as_deref_mut(),
2140 &workflow.id,
2141 &task.id,
2142 message,
2143 clocks,
2144 Some(mapping_snapshots).filter(|s| !s.is_empty()),
2147 );
2148
2149 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
2150 return Ok(SliceOutcome::Halted);
2151 }
2152 i += 1;
2153 }
2154 Ok(SliceOutcome::Completed)
2155 }
2156
2157 pub async fn run_all(
2169 &self,
2170 workflows: &[&Workflow],
2171 message: &mut Message,
2172 trace: Option<&mut ExecutionTrace>,
2173 now: DateTime<Utc>,
2174 ) -> Result<()> {
2175 self.run_all_borrowed(workflows, message, trace, now).await
2176 }
2177
2178 pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
2183 &self,
2184 workflows: &[W],
2185 message: &mut Message,
2186 trace: Option<&mut ExecutionTrace>,
2187 now: DateTime<Utc>,
2188 ) -> Result<()> {
2189 let started_at = self.observer_clock();
2190 if let Some(observer) = self.observer.as_ref() {
2191 observer.message_started(&MessageStarted {
2192 message_id: message.id(),
2193 workflows_considered: workflows.len(),
2194 });
2195 }
2196 let outcome = self.run_all_inner(workflows, message, trace, now).await;
2197 if let Some(observer) = self.observer.as_ref() {
2198 observer.message_finished(&MessageFinished {
2199 message_id: message.id(),
2200 duration: started_at
2201 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
2202 .unwrap_or_default(),
2203 errors: message.errors().len(),
2204 stopped_early: outcome.is_err(),
2205 });
2206 }
2207 outcome
2208 }
2209
2210 async fn run_all_inner<W: std::borrow::Borrow<Workflow>>(
2214 &self,
2215 workflows: &[W],
2216 message: &mut Message,
2217 mut trace: Option<&mut ExecutionTrace>,
2218 now: DateTime<Utc>,
2219 ) -> Result<()> {
2220 let mut i = 0;
2221 while i < workflows.len() {
2222 if joins_sync_run(workflows[i].borrow()) {
2223 let mut j = i + 1;
2226 while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
2227 j += 1;
2228 }
2229 self.execute_sync_workflow_run(
2230 &workflows[i..j],
2231 message,
2232 trace.as_deref_mut(),
2233 now,
2234 )?;
2235 i = j;
2236 } else {
2237 self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
2240 .await?;
2241 i += 1;
2242 }
2243 }
2244 Ok(())
2245 }
2246
2247 fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
2265 &self,
2266 workflows: &[W],
2267 message: &mut Message,
2268 mut trace: Option<&mut ExecutionTrace>,
2269 now: DateTime<Utc>,
2270 ) -> Result<()> {
2271 debug_assert!(
2274 workflows.iter().all(|w| joins_sync_run(w.borrow())),
2275 "only non-looping fully-sync workflows may join a shared-arena run"
2276 );
2277 let pass = PassCtx::once(now);
2278
2279 with_arena(|arena| -> Result<()> {
2280 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2281
2282 for workflow in workflows {
2283 let workflow: &Workflow = workflow.borrow();
2284
2285 if !rollout_admits(workflow, message) {
2291 note_workflow_skip(
2292 trace.as_deref_mut(),
2293 &workflow.id,
2294 "outside rollout bucket",
2295 );
2296 continue;
2297 }
2298
2299 let should_execute = match workflow.compiled_condition.as_ref() {
2303 None => true,
2304 Some(compiled) => evaluate_condition_in_arena(
2305 &self.engine,
2306 Some(compiled),
2307 arena_ctx.as_data_value(),
2308 arena,
2309 )?,
2310 };
2311
2312 if !should_execute {
2313 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
2314 continue;
2315 }
2316
2317 let mut span = WorkflowSpan::default();
2320 self.begin_workflow(&mut span, workflow);
2321
2322 let mut gate = GroupGate::default();
2325 match self.run_tasks_slice_in_arena(
2326 TaskSlice {
2327 tasks: &workflow.tasks,
2328 offset: 0,
2329 gate: &mut gate,
2330 list: PassList::body(workflow),
2331 },
2332 message,
2333 &mut arena_ctx,
2334 trace.as_deref_mut(),
2335 pass,
2336 ) {
2337 Ok(outcome) => {
2341 self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
2342 info!("Successfully completed workflow: {}", workflow.id);
2343 }
2344 Err(e) => {
2345 self.end_workflow(&span, workflow, false);
2349 if self.record_workflow_error(workflow, message, &e) {
2351 return Err(e);
2352 }
2353 }
2354 }
2355 }
2356 Ok(())
2357 })
2358 }
2359
2360 fn execute_sync_task_in_arena<'arena>(
2368 &self,
2369 task: &'arena Task,
2370 message: &mut Message,
2371 arena_ctx: &mut ArenaContext<'arena>,
2372 mapping_snapshots: Option<&mut Vec<Value>>,
2373 ) -> Result<(TaskOutcome, Vec<Change>)> {
2374 debug!(
2375 "Executing sync task in arena: {} ({})",
2376 task.id,
2377 task.function.function_name()
2378 );
2379 debug_assert!(
2380 task.function.is_sync_builtin(),
2381 "execute_sync_task_in_arena called with non-sync-builtin task: {}",
2382 task.function.function_name()
2383 );
2384 task.function
2388 .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
2389 .ok_or_else(|| {
2390 DataflowError::Task(format!(
2391 "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
2392 (engine bug — sync-stretch should only contain sync-builtin tasks)",
2393 task.function.function_name()
2394 ))
2395 })?
2396 }
2397
2398 #[inline]
2417 fn mirror_task_errors(
2418 &self,
2419 message: &mut Message,
2420 workflow_id: &str,
2421 task_id: &str,
2422 status: u16,
2423 task: TaskPass,
2424 ) {
2425 let Some(cfg) = self.error_context.as_ref() else {
2426 return;
2427 };
2428 let Message {
2431 context, errors, ..
2432 } = message;
2433 let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
2434 append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
2435 }
2436
2437 #[inline]
2441 fn error_context_refresh<'a>(
2442 &'a self,
2443 message: &Message,
2444 errors_before: usize,
2445 ) -> Option<&'a Arc<ErrorContextConfig>> {
2446 let cfg = self.error_context.as_ref()?;
2447 if message.errors.len() > errors_before {
2448 Some(cfg)
2449 } else {
2450 None
2451 }
2452 }
2453
2454 fn handle_task_result(
2460 &self,
2461 result: Result<(TaskOutcome, Vec<Change>)>,
2462 workflow_id_arc: &Arc<str>,
2463 task_id_arc: &Arc<str>,
2464 task: TaskPass,
2465 message: &mut Message,
2466 pass: PassCtx,
2467 ) -> Result<TaskControlFlow> {
2468 let workflow_id: &str = workflow_id_arc;
2469 let task_id: &str = task_id_arc;
2470 let continue_on_error = task.continue_on_error;
2471 match result {
2472 Ok((TaskOutcome::Skip, _)) => {
2473 debug!("Task {} signaled skip", task_id);
2482 Ok(TaskControlFlow::Continue)
2483 }
2484 Ok((outcome, changes)) => {
2485 let status = outcome
2489 .audit_status()
2490 .expect("Skip handled above; remaining variants emit audit status");
2491 let halt_requested = outcome.halts_workflow() || task.halts_at(status);
2499
2500 message.record_audit(AuditTrail {
2505 timestamp: pass.now,
2506 workflow_id: Arc::clone(workflow_id_arc),
2507 task_id: Arc::clone(task_id_arc),
2508 status: status as usize,
2509 changes,
2510 loop_counter: pass.loop_counter,
2511 element_index: pass.element_index,
2512 });
2513
2514 write_progress_metadata(&mut message.context, workflow_id, task_id, status);
2525
2526 let flow = if (400..500).contains(&status) {
2532 warn!("Task {} returned client error status: {}", task_id, status);
2533 Ok(TaskControlFlow::Continue)
2534 } else if status >= 500 {
2535 error!("Task {} returned server error status: {}", task_id, status);
2536 let mut info = ErrorInfo::builder(
2541 "TASK_STATUS_ERROR",
2542 format!("Task {} returned status {}", task_id, status),
2543 )
2544 .workflow_id(workflow_id)
2545 .task_id(task_id);
2546 if let Some(index) = pass.element_index {
2547 info = info.element_index(index);
2548 }
2549 message.errors.push(info.build());
2550 if continue_on_error {
2551 Ok(TaskControlFlow::Continue)
2552 } else {
2553 Err(DataflowError::Task(format!(
2554 "Task {} failed with status {}",
2555 task_id, status
2556 )))
2557 }
2558 } else {
2559 Ok(TaskControlFlow::Continue)
2560 };
2561
2562 let flow = match flow {
2566 Ok(TaskControlFlow::Continue) if halt_requested => {
2567 info!("Task {} halted workflow {}", task_id, workflow_id);
2568 Ok(TaskControlFlow::HaltWorkflow)
2569 }
2570 other => other,
2571 };
2572
2573 self.mirror_task_errors(message, workflow_id, task_id, status, task);
2576 flow
2577 }
2578 Err(e) => {
2579 error!("Task {} failed: {:?}", task_id, e);
2580
2581 message.record_audit(AuditTrail {
2583 timestamp: pass.now,
2584 workflow_id: Arc::clone(workflow_id_arc),
2585 task_id: Arc::clone(task_id_arc),
2586 status: 500,
2587 changes: vec![],
2588 loop_counter: pass.loop_counter,
2589 element_index: pass.element_index,
2590 });
2591
2592 write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
2596
2597 let mut info = ErrorInfo::builder(
2610 service_error_code(&e),
2611 format!("Task {} error: {}", task_id, e),
2612 )
2613 .workflow_id(workflow_id)
2614 .task_id(task_id);
2615 if let Some(detail) = e.detail() {
2616 info = info.detail(detail);
2617 }
2618 if let Some(index) = pass.element_index {
2619 info = info.element_index(index);
2620 }
2621 message.errors.push(info.build());
2622
2623 self.mirror_task_errors(message, workflow_id, task_id, 500, task);
2626
2627 if !continue_on_error {
2628 Err(e)
2629 } else if task.halts_at(500) {
2630 info!(
2637 "Task {} halted workflow {} after failing",
2638 task_id, workflow_id
2639 );
2640 Ok(TaskControlFlow::HaltWorkflow)
2641 } else {
2642 Ok(TaskControlFlow::Continue)
2643 }
2644 }
2645 }
2646 }
2647}
2648
2649#[cfg(test)]
2650mod tests {
2651 use super::*;
2652 use crate::engine::compiler::LogicCompiler;
2653 use serde_json::json;
2654 use std::collections::HashMap;
2655
2656 fn dv(v: serde_json::Value) -> OwnedDataValue {
2658 OwnedDataValue::from(&v)
2659 }
2660
2661 fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
2663 let compiler = LogicCompiler::new();
2664 let workflow = Workflow::from_json(json).expect("workflow should parse");
2665 let compiled = compiler
2666 .compile_workflows(vec![workflow])
2667 .expect("workflow should compile");
2668 (
2669 compiled.into_iter().next().expect("one workflow"),
2670 compiler.into_engine(),
2671 )
2672 }
2673
2674 fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2676 let task_executor = Arc::new(TaskExecutor::new(
2677 Arc::new(HashMap::new()),
2678 Arc::clone(&engine),
2679 ));
2680 WorkflowExecutor::new(task_executor, engine)
2681 }
2682
2683 fn executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2685 let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
2686 .expect("metadata.errors is a valid path");
2687 executor(engine).with_error_context(Arc::new(cfg))
2688 }
2689
2690 #[tokio::test]
2691 async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
2692 let (workflow, engine) = compiled(
2704 r#"{ "id": "w", "name": "w", "tasks": [
2705 { "id": "check", "name": "check", "continue_on_error": true,
2706 "function": {"name": "validation", "input": {"rules": [
2707 {"logic": false, "message": "nope"}]}}},
2708 { "id": "react", "name": "react",
2709 "function": {"name": "map", "input": {"mappings": [
2710 {"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
2711 ]}"#,
2712 );
2713 let mut message = Message::from_value(&json!({}));
2714
2715 executor_with_error_context(engine)
2716 .execute(&workflow, &mut message, Utc::now())
2717 .await
2718 .expect("continue_on_error keeps the workflow running");
2719
2720 assert_eq!(
2721 message.context["data"].get("seen"),
2722 Some(&dv(json!("VALIDATION_ERROR"))),
2723 "the map must read the record the validation appended in the same stretch"
2724 );
2725 }
2726
2727 #[tokio::test]
2728 async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
2729 let (workflow, engine) = compiled(&format!(
2730 r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
2731 ));
2732 let mut message = Message::from_value(&json!({}));
2733
2734 executor_with_error_context(engine)
2735 .execute(&workflow, &mut message, Utc::now())
2736 .await
2737 .expect("workflow should complete");
2738
2739 assert_eq!(
2740 message.context["metadata"].get("errors"),
2741 None,
2742 "a clean run leaves the key absent, not an empty array"
2743 );
2744 }
2745
2746 fn counters(message: &Message) -> Vec<Option<i64>> {
2748 message
2749 .audit_trail
2750 .iter()
2751 .map(|entry| entry.loop_counter)
2752 .collect()
2753 }
2754
2755 const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2757 "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
2758
2759 #[tokio::test]
2760 async fn loop_without_a_condition_runs_exactly_max_sweeps() {
2761 let (workflow, engine) = compiled(&format!(
2762 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2763 "tasks": [{COUNTER_BODY}] }}"#
2764 ));
2765 let mut message = Message::from_value(&json!({}));
2766
2767 let executed = executor(engine)
2768 .execute(&workflow, &mut message, Utc::now())
2769 .await
2770 .expect("loop should complete");
2771
2772 assert!(executed);
2773 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2775 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2777 assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
2779 }
2780
2781 #[tokio::test]
2782 async fn loop_exits_early_when_the_condition_goes_false() {
2783 let (workflow, engine) = compiled(&format!(
2785 r#"{{ "id": "w", "name": "w",
2786 "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
2787 "loop": {{"counter": "i", "max": 10}},
2788 "tasks": [{COUNTER_BODY}] }}"#
2789 ));
2790 let mut message = Message::from_value(&json!({}));
2791
2792 executor(engine)
2793 .execute(&workflow, &mut message, Utc::now())
2794 .await
2795 .expect("loop should complete");
2796
2797 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
2798 }
2799
2800 #[tokio::test]
2801 async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
2802 let (workflow, engine) = compiled(&format!(
2803 r#"{{ "id": "w", "name": "w", "condition": false,
2804 "loop": {{"counter": "i", "max": 5}},
2805 "tasks": [{COUNTER_BODY}] }}"#
2806 ));
2807 let mut message = Message::from_value(&json!({}));
2808
2809 let executed = executor(engine)
2810 .execute(&workflow, &mut message, Utc::now())
2811 .await
2812 .expect("a skip is not an error");
2813
2814 assert!(!executed, "a never-entered loop reports as skipped");
2815 assert!(message.audit_trail.is_empty());
2816 }
2817
2818 const OVER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2824 "input": {"mappings": [
2825 {"path": "data.last", "logic": {"var": "temp_data.item.id"}},
2826 {"path": "data.last_i", "logic": {"var": "temp_data.i"}}]}}}"#;
2827
2828 fn items_message() -> Message {
2829 Message::builder()
2830 .data(dv(
2831 json!({"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}),
2832 ))
2833 .build()
2834 }
2835
2836 #[tokio::test]
2837 async fn an_over_loop_visits_each_element_with_its_index() {
2838 let (workflow, engine) = compiled(&format!(
2839 r#"{{ "id": "w", "name": "w",
2840 "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2841 "tasks": [{OVER_BODY}] }}"#
2842 ));
2843 let mut message = items_message();
2844
2845 let executed = executor(engine)
2846 .execute(&workflow, &mut message, Utc::now())
2847 .await
2848 .expect("loop should complete");
2849
2850 assert!(executed);
2851 assert_eq!(
2852 counters(&message),
2853 vec![Some(0), Some(1), Some(2)],
2854 "one sweep per element, not one per max"
2855 );
2856 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
2857 assert_eq!(message.context["data"].get("last_i"), Some(&dv(json!(2))));
2858 assert_eq!(
2859 message.context["temp_data"].get("item"),
2860 Some(&dv(json!({"id": "c"}))),
2861 "the element slot is left holding the last element, like the counter"
2862 );
2863 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2864 }
2865
2866 #[tokio::test]
2867 async fn max_still_bounds_an_over_loop() {
2868 let (workflow, engine) = compiled(&format!(
2869 r#"{{ "id": "w", "name": "w",
2870 "loop": {{"counter": "i", "max": 2, "over": {{"var": "data.items"}}, "as": "item"}},
2871 "tasks": [{OVER_BODY}] }}"#
2872 ));
2873 let mut message = items_message();
2874 executor(engine)
2875 .execute(&workflow, &mut message, Utc::now())
2876 .await
2877 .unwrap();
2878 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2879 }
2880
2881 #[tokio::test]
2882 async fn init_offsets_and_increment_strides_the_element_index() {
2883 let (workflow, engine) = compiled(&format!(
2884 r#"{{ "id": "w", "name": "w",
2885 "loop": {{"counter": "i", "init": 1, "increment": 2, "max": 100,
2886 "over": {{"var": "data.items"}}, "as": "item"}},
2887 "tasks": [{OVER_BODY}] }}"#
2888 ));
2889 let mut message = Message::builder()
2890 .data(dv(json!({
2891 "items": [{"id": "a"}, {"id": "b"}, {"id": "c"}, {"id": "d"}, {"id": "e"}, {"id": "f"}]
2892 })))
2893 .build();
2894 executor(engine)
2895 .execute(&workflow, &mut message, Utc::now())
2896 .await
2897 .unwrap();
2898 assert_eq!(counters(&message), vec![Some(1), Some(3), Some(5)]);
2899 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("f"))));
2900 }
2901
2902 #[tokio::test]
2903 async fn an_empty_over_runs_no_iteration_but_the_workflow_still_executed() {
2904 let (workflow, engine) = compiled(&format!(
2905 r#"{{ "id": "w", "name": "w",
2906 "loop": {{"counter": "i", "max": 100, "over": [], "as": "item"}},
2907 "tasks": [{OVER_BODY}] }}"#
2908 ));
2909 let mut message = Message::from_value(&json!({}));
2910 let executed = executor(engine)
2911 .execute(&workflow, &mut message, Utc::now())
2912 .await
2913 .unwrap();
2914 assert!(
2915 executed,
2916 "admitted by its condition, zero elements to visit"
2917 );
2918 assert!(message.audit_trail.is_empty());
2919 assert_eq!(
2920 message.context["temp_data"].get("item"),
2921 None,
2922 "never written"
2923 );
2924 }
2925
2926 #[tokio::test]
2927 async fn a_non_array_over_is_a_workflow_error() {
2928 let make = |continue_on_error: bool| {
2929 compiled(&format!(
2930 r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
2931 "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2932 "tasks": [{OVER_BODY}] }}"#
2933 ))
2934 };
2935 for (items, kind) in [
2936 (json!("nope"), "a string"),
2937 (json!(null), "null"),
2938 (json!({"a": 1}), "an object"),
2939 ] {
2940 let (workflow, engine) = make(false);
2941 let mut message = Message::builder().data(dv(json!({"items": items}))).build();
2942 let err = executor(engine)
2943 .execute(&workflow, &mut message, Utc::now())
2944 .await
2945 .expect_err("not an array");
2946 assert!(
2947 err.to_string().contains("loop.over"),
2948 "names the loop: {err}"
2949 );
2950 assert!(err.to_string().contains(kind), "says what it got: {err}");
2951 assert_eq!(message.errors.len(), 1);
2952 assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2953 assert!(message.audit_trail.is_empty(), "no sweep ran");
2954 }
2955
2956 let (workflow, engine) = make(true);
2959 let mut message = Message::builder().data(dv(json!({"items": 5}))).build();
2960 let executed = executor(engine)
2961 .execute(&workflow, &mut message, Utc::now())
2962 .await
2963 .unwrap();
2964 assert!(executed);
2965 assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2966 assert!(message.audit_trail.is_empty());
2967 }
2968
2969 const SETUP: &str = r#"[{"id": "read", "name": "read", "function": {"name": "map",
2972 "input": {"mappings": [
2973 {"path": "temp_data.batch", "logic": {"var": "data.items"}},
2974 {"path": "data.setup_ran", "logic": true}]}}}]"#;
2975
2976 #[tokio::test]
2977 async fn setup_runs_once_before_the_first_iteration_without_a_loop_counter() {
2978 let (workflow, engine) = compiled(&format!(
2979 r#"{{ "id": "w", "name": "w",
2980 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
2981 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
2982 "tasks": [{OVER_BODY}] }}"#
2983 ));
2984 let mut message = items_message();
2985 executor(engine)
2986 .execute(&workflow, &mut message, Utc::now())
2987 .await
2988 .unwrap();
2989
2990 assert_eq!(
2991 counters(&message),
2992 vec![None, Some(0), Some(1), Some(2)],
2993 "setup is stamped like a non-looping task, then one entry per element"
2994 );
2995 assert_eq!(message.audit_trail[0].task_id.as_ref(), "read");
2996 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
2997 }
2998
2999 #[tokio::test]
3000 async fn a_false_condition_skips_setup_and_the_loop_alike() {
3001 let (workflow, engine) = compiled(&format!(
3002 r#"{{ "id": "w", "name": "w", "condition": false,
3003 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3004 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3005 "tasks": [{OVER_BODY}] }}"#
3006 ));
3007 let mut message = items_message();
3008 let executed = executor(engine)
3009 .execute(&workflow, &mut message, Utc::now())
3010 .await
3011 .unwrap();
3012 assert!(!executed);
3013 assert!(message.audit_trail.is_empty(), "setup did not run");
3014 assert_eq!(message.context["data"].get("setup_ran"), None);
3015 }
3016
3017 #[tokio::test]
3018 async fn the_condition_is_re_evaluated_per_iteration_after_setup() {
3019 let (workflow, engine) = compiled(&format!(
3020 r#"{{ "id": "w", "name": "w",
3021 "condition": {{"!=": [{{"var": "temp_data.i"}}, 2]}},
3022 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3023 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3024 "tasks": [{OVER_BODY}] }}"#
3025 ));
3026 let mut message = items_message();
3027 let executed = executor(engine)
3030 .execute(&workflow, &mut message, Utc::now())
3031 .await
3032 .unwrap();
3033 assert!(executed);
3034 assert_eq!(counters(&message), vec![None, Some(0), Some(1)]);
3035 }
3036
3037 #[tokio::test]
3038 async fn a_condition_false_on_the_first_iteration_after_setup_still_reports_executed() {
3039 let (workflow, engine) = compiled(&format!(
3040 r#"{{ "id": "w", "name": "w",
3041 "condition": {{"!": [{{"var": "temp_data.batch"}}]}},
3042 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3043 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3044 "tasks": [{OVER_BODY}] }}"#
3045 ));
3046 let mut message = items_message();
3047 let executed = executor(engine)
3051 .execute(&workflow, &mut message, Utc::now())
3052 .await
3053 .unwrap();
3054 assert!(executed);
3055 assert_eq!(counters(&message), vec![None]);
3056 }
3057
3058 #[tokio::test]
3059 async fn a_terminal_setup_step_ends_the_workflow_before_any_iteration() {
3060 let (workflow, engine) = compiled(&format!(
3061 r#"{{ "id": "w", "name": "w",
3062 "loop": {{"counter": "i", "max": 100,
3063 "setup": [{{"id": "stop", "name": "stop", "terminal": true,
3064 "function": {{"name": "map", "input": {{"mappings": []}}}}}}],
3065 "over": {{"var": "data.items"}}, "as": "item"}},
3066 "tasks": [{OVER_BODY}] }}"#
3067 ));
3068 let mut message = items_message();
3069 let executed = executor(engine)
3070 .execute(&workflow, &mut message, Utc::now())
3071 .await
3072 .unwrap();
3073 assert!(executed);
3074 assert_eq!(counters(&message), vec![None], "only the setup step ran");
3075 assert!(message.errors.is_empty());
3076 }
3077
3078 #[tokio::test]
3079 async fn a_setup_error_stops_the_message_unless_the_workflow_continues_on_error() {
3080 let make = |continue_on_error: bool| {
3081 compiled(&format!(
3082 r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
3083 "loop": {{"counter": "i", "max": 100,
3084 "setup": [{{"id": "boom", "name": "boom",
3085 "function": {{"name": "map", "input": {{"mappings": [
3086 {{"path": {{"var": "data.root"}}, "unset": true}}]}}}}}}],
3087 "over": {{"var": "data.items"}}, "as": "item"}},
3088 "tasks": [{OVER_BODY}] }}"#
3089 ))
3090 };
3091 let data = || dv(json!({"items": [1], "root": "temp_data"}));
3095
3096 let (workflow, engine) = make(false);
3097 let mut message = Message::builder().data(data()).build();
3098 assert!(
3099 executor(engine)
3100 .execute(&workflow, &mut message, Utc::now())
3101 .await
3102 .is_err()
3103 );
3104 assert_eq!(
3105 counters(&message),
3106 vec![None],
3107 "setup recorded, no iteration"
3108 );
3109
3110 let (workflow, engine) = make(true);
3111 let mut message = Message::builder().data(data()).build();
3112 let executed = executor(engine)
3113 .execute(&workflow, &mut message, Utc::now())
3114 .await
3115 .unwrap();
3116 assert!(executed);
3117 assert_eq!(
3118 counters(&message),
3119 vec![None],
3120 "recorded and ended — no first iteration without setup"
3121 );
3122 assert!(message.errors.iter().any(|e| e.code == "WORKFLOW_ERROR"));
3123 }
3124
3125 const SCRATCH_BODY: &str = r#"
3128 {"id": "flag", "name": "flag", "condition": {"==": [{"var": "temp_data.i"}, 0]},
3129 "function": {"name": "map", "input": {"mappings": [
3130 {"path": "temp_data.it.flag", "logic": true}]}}},
3131 {"id": "read", "name": "read",
3132 "function": {"name": "map", "input": {"mappings": [
3133 {"path": {"cat": ["data.f", {"var": "temp_data.i"}]},
3134 "logic": {"!!": [{"var": "temp_data.it.flag"}]}}]}}}"#;
3135
3136 async fn run_scratch_body(scratch: &str) -> Message {
3137 let (workflow, engine) = compiled(&format!(
3138 r#"{{ "id": "w", "name": "w",
3139 "loop": {{"counter": "i", "max": 3{scratch}}},
3140 "tasks": [{SCRATCH_BODY}] }}"#
3141 ));
3142 let mut message = Message::builder()
3143 .temp_data(dv(json!({"keep": "me"})))
3144 .build();
3145 executor(engine)
3146 .execute(&workflow, &mut message, Utc::now())
3147 .await
3148 .unwrap();
3149 message
3150 }
3151
3152 #[tokio::test]
3153 async fn scratch_is_reset_at_the_start_of_every_iteration() {
3154 let leaky = run_scratch_body("").await;
3155 assert_eq!(
3156 leaky.context["data"].get("f1"),
3157 Some(&dv(json!(true))),
3158 "without scratch the slot leaks into the next iteration"
3159 );
3160
3161 let clean = run_scratch_body(r#", "scratch": "it""#).await;
3162 assert_eq!(clean.context["data"].get("f0"), Some(&dv(json!(true))));
3163 assert_eq!(clean.context["data"].get("f1"), Some(&dv(json!(false))));
3164 assert_eq!(clean.context["data"].get("f2"), Some(&dv(json!(false))));
3165 assert_eq!(
3166 clean.context["temp_data"].get("keep"),
3167 Some(&dv(json!("me"))),
3168 "the reset is narrow"
3169 );
3170 assert_eq!(
3171 clean.context["temp_data"].get("it"),
3172 Some(&dv(json!({}))),
3173 "left holding the last iteration's (empty) scratch"
3174 );
3175 }
3176
3177 #[tokio::test]
3178 async fn an_uncompiled_over_is_an_error_not_a_panic() {
3179 let workflow = Workflow::from_json(&format!(
3181 r#"{{ "id": "w", "name": "w",
3182 "loop": {{"counter": "i", "max": 3, "over": {{"var": "data.items"}}}},
3183 "tasks": [{COUNTER_BODY}] }}"#
3184 ))
3185 .unwrap();
3186 let engine = LogicCompiler::new().into_engine();
3187 let mut message = items_message();
3188 let err = executor(engine)
3189 .execute(&workflow, &mut message, Utc::now())
3190 .await
3191 .expect_err("not compiled");
3192 assert!(err.to_string().contains("loop.over"), "got: {err}");
3193 }
3194
3195 #[tokio::test]
3196 async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
3197 let (workflow, engine) = compiled(
3198 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
3199 "tasks": [
3200 {"id": "gate", "name": "gate", "function": {"name": "filter",
3201 "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
3202 "on_reject": "halt"}}},
3203 {"id": "body", "name": "body", "function": {"name": "map",
3204 "input": {"mappings": [
3205 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3206 );
3207 let mut message = Message::from_value(&json!({}));
3208
3209 executor(engine)
3210 .execute(&workflow, &mut message, Utc::now())
3211 .await
3212 .expect("a halt is not an error");
3213
3214 let ids: Vec<&str> = message
3217 .audit_trail
3218 .iter()
3219 .map(|entry| entry.task_id.as_ref())
3220 .collect();
3221 assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
3222 assert_eq!(
3223 counters(&message),
3224 vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
3225 );
3226 }
3227
3228 #[tokio::test]
3229 async fn init_and_increment_drive_the_counter() {
3230 let (workflow, engine) = compiled(&format!(
3231 r#"{{ "id": "w", "name": "w",
3232 "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
3233 "tasks": [{COUNTER_BODY}] }}"#
3234 ));
3235 let mut message = Message::from_value(&json!({}));
3236
3237 executor(engine)
3238 .execute(&workflow, &mut message, Utc::now())
3239 .await
3240 .expect("loop should complete");
3241
3242 assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
3243 }
3244
3245 #[tokio::test]
3246 async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
3247 let (workflow, engine) = compiled(
3248 r#"{ "id": "w", "name": "w", "loop": {"max": 2},
3249 "tasks": [{"id": "t", "name": "t",
3250 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3251 );
3252 let mut message = Message::from_value(&json!({}));
3253
3254 executor(engine)
3255 .execute(&workflow, &mut message, Utc::now())
3256 .await
3257 .expect("loop should complete");
3258
3259 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3260 assert_eq!(message.context["temp_data"], dv(json!({})));
3262 }
3263
3264 #[tokio::test]
3265 async fn a_non_looping_workflow_records_no_loop_counter() {
3266 let (workflow, engine) = compiled(
3267 r#"{ "id": "w", "name": "w",
3268 "tasks": [{"id": "t", "name": "t",
3269 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3270 );
3271 let mut message = Message::from_value(&json!({}));
3272
3273 executor(engine)
3274 .execute(&workflow, &mut message, Utc::now())
3275 .await
3276 .expect("should complete");
3277
3278 assert_eq!(counters(&message), vec![None]);
3279 }
3280
3281 #[tokio::test]
3282 async fn progress_metadata_is_written_on_every_sweep() {
3283 let (workflow, engine) = compiled(&format!(
3286 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
3287 "tasks": [{COUNTER_BODY}] }}"#
3288 ));
3289 let mut message = Message::from_value(&json!({}));
3290
3291 executor(engine)
3292 .execute(&workflow, &mut message, Utc::now())
3293 .await
3294 .expect("loop should complete");
3295
3296 let progress = message.context["metadata"]
3297 .get("progress")
3298 .expect("progress must be written");
3299 assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
3300 assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
3301 assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
3302 }
3303
3304 #[tokio::test]
3305 async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
3306 let (workflow, engine) = compiled(
3309 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3310 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3311 "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
3312 );
3313 let mut message = Message::from_value(&json!({}));
3314
3315 executor(engine)
3316 .execute(&workflow, &mut message, Utc::now())
3317 .await
3318 .expect("loop should complete");
3319
3320 assert_eq!(
3321 counters(&message),
3322 vec![Some(0), Some(1), Some(2)],
3323 "the body's write must not stall or skew the loop"
3324 );
3325 }
3326
3327 async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
3330 let (workflow, engine) = compiled(&format!(
3331 r#"{{ "id": "w", "name": "w",
3332 "loop": {{"counter": "i", "init": {init},
3333 "increment": {increment}, "max": {max}}},
3334 "tasks": [{{"id": "t", "name": "t",
3335 "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
3336 ));
3337 let mut message = Message::from_value(&json!({}));
3338 executor(engine)
3339 .execute(&workflow, &mut message, Utc::now())
3340 .await
3341 .expect("loop should complete");
3342 counters(&message)
3343 }
3344
3345 #[tokio::test]
3346 async fn counter_sequence_matrix_over_init_increment_and_max() {
3347 let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
3350 (0, 1, 1, vec![0]),
3352 (0, 1, 2, vec![0, 1]),
3353 (0, 1, 5, vec![0, 1, 2, 3, 4]),
3354 (0, 2, 6, vec![0, 2, 4]),
3356 (0, 3, 10, vec![0, 3, 6, 9]),
3357 (0, 5, 3, vec![0]),
3358 (0, 100, 1, vec![0]),
3359 (10, 5, 25, vec![10, 15, 20]),
3361 (3, 1, 6, vec![3, 4, 5]),
3362 (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
3364 (-4, 2, 1, vec![-4, -2, 0]),
3365 (-10, 5, -5, vec![-10]),
3366 ];
3367
3368 for (init, increment, max, expected) in cases {
3369 let got = counter_sequence(init, increment, max).await;
3370 let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
3371 assert_eq!(got, expected, "init={init} increment={increment} max={max}");
3372 }
3373 }
3374
3375 #[tokio::test]
3376 async fn the_counter_advance_saturates_instead_of_overflowing() {
3377 assert_eq!(
3381 counter_sequence(0, i64::MAX, 5).await,
3382 vec![Some(0)],
3383 "one sweep, then the advance saturates past max"
3384 );
3385 assert_eq!(
3386 counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
3387 vec![Some(i64::MAX - 1)],
3388 "the last representable sweep still terminates"
3389 );
3390 assert_eq!(
3391 counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
3392 vec![Some(i64::MAX - 2)]
3393 );
3394 }
3395
3396 #[tokio::test]
3397 async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
3398 let (workflow, engine) = compiled(
3401 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
3402 "tasks": [
3403 {"id": "evens", "name": "evens",
3404 "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
3405 "function": {"name": "map", "input": {"mappings": []}}},
3406 {"id": "always", "name": "always",
3407 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3408 );
3409 let mut message = Message::from_value(&json!({}));
3410
3411 executor(engine)
3412 .execute(&workflow, &mut message, Utc::now())
3413 .await
3414 .expect("loop should complete");
3415
3416 let entries: Vec<(&str, Option<i64>)> = message
3417 .audit_trail
3418 .iter()
3419 .map(|e| (e.task_id.as_ref(), e.loop_counter))
3420 .collect();
3421 assert_eq!(
3422 entries,
3423 [
3424 ("evens", Some(0)),
3425 ("always", Some(0)),
3426 ("always", Some(1)),
3427 ("evens", Some(2)),
3428 ("always", Some(2)),
3429 ("always", Some(3)),
3430 ],
3431 "the gated task runs only on even counters"
3432 );
3433 }
3434
3435 #[tokio::test]
3436 async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
3437 let (workflow, engine) = compiled(
3441 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3442 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
3443 "input": {"condition": false, "on_reject": "skip"}}}] }"#,
3444 );
3445 let mut message = Message::from_value(&json!({}));
3446
3447 let executed = executor(engine)
3448 .execute(&workflow, &mut message, Utc::now())
3449 .await
3450 .expect("skip is not an error");
3451
3452 assert!(executed, "sweeps ran even though every task skipped");
3453 assert!(message.audit_trail.is_empty(), "Skip records no entry");
3454 assert_eq!(
3455 message.context["temp_data"].get("i"),
3456 Some(&dv(json!(3))),
3457 "the loop still ran to its bound"
3458 );
3459 }
3460
3461 #[tokio::test]
3462 async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
3463 let (workflow, engine) = compiled(
3465 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3466 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
3467 "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
3468 );
3469 let mut message = Message::from_value(&json!({}));
3470
3471 executor(engine)
3472 .execute(&workflow, &mut message, Utc::now())
3473 .await
3474 .expect("a 4xx does not stop the workflow");
3475
3476 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
3477 assert!(
3478 message.audit_trail.iter().all(|e| e.status == 400),
3479 "every sweep recorded the 4xx"
3480 );
3481 }
3482
3483 #[tokio::test]
3484 async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
3485 let (workflow, engine) = compiled(
3488 r#"{ "id": "w", "name": "w",
3489 "rollout": {"bucket_start": 0, "bucket_end": 50},
3490 "loop": {"counter": "i", "max": 5},
3491 "tasks": [{"id": "t", "name": "t",
3492 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3493 );
3494 let mut message = Message::builder().routing_bucket(75).build();
3495
3496 let executed = executor(engine)
3497 .execute(&workflow, &mut message, Utc::now())
3498 .await
3499 .expect("an excluded workflow is not an error");
3500
3501 assert!(!executed);
3502 assert!(message.audit_trail.is_empty());
3503 assert_eq!(
3504 message.context["temp_data"].get("i"),
3505 None,
3506 "no counter is written for an excluded workflow"
3507 );
3508 }
3509
3510 #[tokio::test]
3511 async fn a_nested_counter_path_is_created_and_advanced() {
3512 let (workflow, engine) = compiled(
3513 r#"{ "id": "w", "name": "w",
3514 "loop": {"counter": "cursor.index", "max": 3},
3515 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3516 "input": {"mappings": [
3517 {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
3518 );
3519 let mut message = Message::from_value(&json!({}));
3520
3521 executor(engine)
3522 .execute(&workflow, &mut message, Utc::now())
3523 .await
3524 .expect("loop should complete");
3525
3526 assert_eq!(
3527 message.context["temp_data"]["cursor"].get("index"),
3528 Some(&dv(json!(3)))
3529 );
3530 assert_eq!(
3531 message.context["data"].get("seen"),
3532 Some(&dv(json!(2))),
3533 "the body read the nested counter"
3534 );
3535 }
3536
3537 #[tokio::test]
3538 async fn writing_the_counter_preserves_unrelated_temp_data() {
3539 let (workflow, engine) = compiled(
3540 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3541 "tasks": [{"id": "t", "name": "t",
3542 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3543 );
3544 let mut message = Message::builder()
3545 .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
3546 .build();
3547
3548 executor(engine)
3549 .execute(&workflow, &mut message, Utc::now())
3550 .await
3551 .expect("loop should complete");
3552
3553 assert_eq!(
3554 message.context["temp_data"].get("keep"),
3555 Some(&dv(json!("me")))
3556 );
3557 assert_eq!(
3558 message.context["temp_data"]["nested"].get("a"),
3559 Some(&dv(json!(1)))
3560 );
3561 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
3562 }
3563
3564 #[tokio::test]
3565 async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
3566 let (workflow, engine) = compiled(
3569 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
3570 "tasks": [{"id": "t", "name": "t",
3571 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3572 );
3573 let mut message = Message::builder()
3574 .temp_data(dv(json!({"i": "not a number"})))
3575 .build();
3576
3577 executor(engine)
3578 .execute(&workflow, &mut message, Utc::now())
3579 .await
3580 .expect("loop should complete");
3581
3582 assert_eq!(counters(&message), vec![Some(5), Some(6)]);
3583 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
3584 }
3585
3586 #[tokio::test]
3587 async fn a_loop_records_audit_entries_with_capture_changes_off() {
3588 let (workflow, engine) = compiled(
3591 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3592 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3593 "input": {"mappings": [
3594 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3595 );
3596 let mut message = Message::builder().capture_changes(false).build();
3597
3598 executor(engine)
3599 .execute(&workflow, &mut message, Utc::now())
3600 .await
3601 .expect("loop should complete");
3602
3603 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3604 assert!(
3605 message.audit_trail.iter().all(|e| e.changes.is_empty()),
3606 "no diffs captured, but the entries are still there"
3607 );
3608 }
3609
3610 #[tokio::test]
3611 async fn two_loops_sharing_a_counter_name_do_not_interfere() {
3612 let first = r#"{ "id": "a", "name": "a", "priority": 0,
3615 "loop": {"counter": "i", "max": 2},
3616 "tasks": [{"id": "t", "name": "t",
3617 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3618 let second = r#"{ "id": "b", "name": "b", "priority": 1,
3619 "loop": {"counter": "i", "init": 10, "max": 12},
3620 "tasks": [{"id": "t", "name": "t",
3621 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3622
3623 let compiler = LogicCompiler::new();
3624 let workflows = compiler
3625 .compile_workflows(vec![
3626 Workflow::from_json(first).unwrap(),
3627 Workflow::from_json(second).unwrap(),
3628 ])
3629 .expect("should compile");
3630 let exec = executor(compiler.into_engine());
3631 let mut message = Message::from_value(&json!({}));
3632
3633 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3634 .await
3635 .expect("both loops should complete");
3636
3637 let per_workflow: Vec<(&str, Option<i64>)> = message
3638 .audit_trail
3639 .iter()
3640 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3641 .collect();
3642 assert_eq!(
3643 per_workflow,
3644 [
3645 ("a", Some(0)),
3646 ("a", Some(1)),
3647 ("b", Some(10)),
3648 ("b", Some(11)),
3649 ]
3650 );
3651 }
3652
3653 #[tokio::test]
3654 async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
3655 let sync_wf = |id: &str, priority: u32| {
3659 format!(
3660 r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
3661 "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
3662 "input": {{"mappings": [
3663 {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
3664 )
3665 };
3666 let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
3667 "loop": {"counter": "i", "max": 2},
3668 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3669 "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
3670
3671 let compiler = LogicCompiler::new();
3672 let workflows = compiler
3673 .compile_workflows(vec![
3674 Workflow::from_json(&sync_wf("before", 0)).unwrap(),
3675 Workflow::from_json(loop_wf).unwrap(),
3676 Workflow::from_json(&sync_wf("after", 2)).unwrap(),
3677 ])
3678 .expect("should compile");
3679 assert!(workflows.iter().all(|w| w.fully_sync));
3681 assert!(!joins_sync_run(&workflows[1]));
3682
3683 let exec = executor(compiler.into_engine());
3684 let mut message = Message::from_value(&json!({}));
3685
3686 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3687 .await
3688 .expect("all three should run");
3689
3690 for id in ["before", "mid", "after"] {
3691 assert_eq!(
3692 message.context["data"].get(id),
3693 Some(&dv(json!(true))),
3694 "workflow {id} must have run"
3695 );
3696 }
3697 let order: Vec<(&str, Option<i64>)> = message
3698 .audit_trail
3699 .iter()
3700 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3701 .collect();
3702 assert_eq!(
3703 order,
3704 [
3705 ("before", None),
3706 ("mid", Some(0)),
3707 ("mid", Some(1)),
3708 ("after", None),
3709 ],
3710 "priority order is preserved across the split"
3711 );
3712 }
3713
3714 #[tokio::test]
3715 async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
3716 let compiler = LogicCompiler::new();
3719 let workflows = compiler
3720 .compile_workflows(vec![
3721 Workflow::from_json(
3722 r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
3723 "function": {"name": "map", "input": {"mappings": [
3724 {"path": "data.a", "logic": 1}]}}}] }"#,
3725 )
3726 .unwrap(),
3727 Workflow::from_json(
3728 r#"{ "id": "b", "name": "b", "priority": 1,
3729 "condition": {"==": [{"var": "data.a"}, 1]},
3730 "tasks": [{"id": "t", "name": "t",
3731 "function": {"name": "map", "input": {"mappings": [
3732 {"path": "data.b", "logic": 2}]}}}] }"#,
3733 )
3734 .unwrap(),
3735 ])
3736 .expect("should compile");
3737 assert!(workflows.iter().all(joins_sync_run));
3738
3739 let exec = executor(compiler.into_engine());
3740 let mut message = Message::from_value(&json!({}));
3741 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3742 .await
3743 .expect("both should run");
3744
3745 assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
3748 assert_eq!(counters(&message), vec![None, None]);
3749 }
3750
3751 #[tokio::test]
3752 async fn a_loop_body_can_index_an_array_by_its_counter() {
3753 let (workflow, engine) = compiled(
3755 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3756 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
3757 "input": {"mappings": [
3758 {"path": "data.picked",
3759 "logic": {"merge": [{"var": "data.picked"},
3760 [{"val": [["data", "items",
3761 {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
3762 );
3763 let mut message = Message::builder()
3764 .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
3765 .build();
3766
3767 executor(engine)
3768 .execute(&workflow, &mut message, Utc::now())
3769 .await
3770 .expect("loop should complete");
3771
3772 assert_eq!(
3773 serde_json::Value::from(&message.context["data"]["picked"]),
3774 json!(["a", "b", "c"]),
3775 "each sweep appended the item at its own index"
3776 );
3777 }
3778
3779 #[tokio::test]
3780 async fn test_workflow_executor_skip_condition() {
3781 let workflow_json = r#"{
3783 "id": "test_workflow",
3784 "name": "Test Workflow",
3785 "condition": false,
3786 "tasks": [{
3787 "id": "dummy_task",
3788 "name": "Dummy Task",
3789 "function": {
3790 "name": "map",
3791 "input": {"mappings": []}
3792 }
3793 }]
3794 }"#;
3795
3796 let compiler = LogicCompiler::new();
3797 let mut workflow = Workflow::from_json(workflow_json).unwrap();
3798
3799 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3801 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3802 workflow = compiled_workflow.clone();
3803 }
3804
3805 let engine = compiler.into_engine();
3806 let task_executor = Arc::new(TaskExecutor::new(
3807 Arc::new(HashMap::new()),
3808 Arc::clone(&engine),
3809 ));
3810 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3811
3812 let mut message = Message::from_value(&json!({}));
3813
3814 let executed = workflow_executor
3816 .execute(&workflow, &mut message, Utc::now())
3817 .await
3818 .unwrap();
3819 assert!(!executed);
3820 assert_eq!(message.audit_trail.len(), 0);
3821 }
3822
3823 #[tokio::test]
3824 async fn test_workflow_executor_execute_success() {
3825 let workflow_json = r#"{
3827 "id": "test_workflow",
3828 "name": "Test Workflow",
3829 "condition": true,
3830 "tasks": [{
3831 "id": "dummy_task",
3832 "name": "Dummy Task",
3833 "function": {
3834 "name": "map",
3835 "input": {"mappings": []}
3836 }
3837 }]
3838 }"#;
3839
3840 let compiler = LogicCompiler::new();
3841 let mut workflow = Workflow::from_json(workflow_json).unwrap();
3842
3843 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3845 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3846 workflow = compiled_workflow.clone();
3847 }
3848
3849 let engine = compiler.into_engine();
3850 let task_executor = Arc::new(TaskExecutor::new(
3851 Arc::new(HashMap::new()),
3852 Arc::clone(&engine),
3853 ));
3854 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3855
3856 let mut message = Message::from_value(&json!({}));
3857
3858 let executed = workflow_executor
3860 .execute(&workflow, &mut message, Utc::now())
3861 .await
3862 .unwrap();
3863 assert!(executed);
3864 }
3865}