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, get_nested_value_parts, remove_nested_value, set_nested_value,
28 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 describe_kind(value: &OwnedDataValue) -> &'static str {
566 match value {
567 OwnedDataValue::Null => "null",
568 OwnedDataValue::Bool(_) => "a boolean",
569 OwnedDataValue::Number(_) => "a number",
570 OwnedDataValue::String(_) => "a string",
571 OwnedDataValue::Array(_) => "an array",
572 OwnedDataValue::Object(_) => "an object",
573 #[allow(unreachable_patterns)]
575 _ => "a non-array value",
576 }
577}
578
579fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
581 OwnedDataValue::Object(vec![
582 (
583 "workflow_id".to_string(),
584 OwnedDataValue::String(workflow_id.to_string()),
585 ),
586 (
587 "task_id".to_string(),
588 OwnedDataValue::String(task_id.to_string()),
589 ),
590 (
591 "status_code".to_string(),
592 OwnedDataValue::from(u64::from(status)),
593 ),
594 ])
595}
596
597fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
604 match slot {
605 OwnedDataValue::String(existing) => {
606 if existing != value {
607 existing.clear();
608 existing.push_str(value);
609 }
610 }
611 _ => *slot = OwnedDataValue::String(value.to_string()),
612 }
613}
614
615fn overwrite_progress_in_place(
621 fields: &mut [(String, OwnedDataValue)],
622 workflow_id: &str,
623 task_id: &str,
624 status: u16,
625) -> bool {
626 if fields.len() != 3 {
627 return false;
628 }
629 let mut matched = 0;
630 for (k, v) in fields.iter_mut() {
631 match k.as_str() {
632 "workflow_id" => {
633 overwrite_str_in_place(v, workflow_id);
634 matched += 1;
635 }
636 "task_id" => {
637 overwrite_str_in_place(v, task_id);
638 matched += 1;
639 }
640 "status_code" => {
641 *v = OwnedDataValue::from(u64::from(status));
642 matched += 1;
643 }
644 _ => {}
645 }
646 }
647 matched == 3
648}
649
650fn write_progress_metadata(
659 context: &mut OwnedDataValue,
660 workflow_id: &str,
661 task_id: &str,
662 status: u16,
663) {
664 if let OwnedDataValue::Object(top) = context
665 && let Some((_, OwnedDataValue::Object(meta))) =
666 top.iter_mut().find(|(k, _)| k == "metadata")
667 {
668 match meta.iter_mut().find(|(k, _)| k == "progress") {
669 Some((_, slot)) => {
670 if let OwnedDataValue::Object(fields) = slot
671 && overwrite_progress_in_place(fields, workflow_id, task_id, status)
672 {
673 return;
674 }
675 *slot = new_progress_object(workflow_id, task_id, status);
676 }
677 None => {
678 meta.push((
679 "progress".to_string(),
680 new_progress_object(workflow_id, task_id, status),
681 ));
682 }
683 }
684 return;
685 }
686 set_nested_value(
687 context,
688 "metadata.progress",
689 new_progress_object(workflow_id, task_id, status),
690 );
691}
692
693fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
702 OwnedDataValue::Object(vec![
703 ("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
704 ("task_id".to_string(), OwnedDataValue::from(task_id)),
705 ("code".to_string(), OwnedDataValue::from(code)),
706 (
707 "status".to_string(),
708 OwnedDataValue::from(u64::from(status)),
709 ),
710 ])
711}
712
713fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
719 if !matches!(node, OwnedDataValue::Object(_)) {
720 *node = OwnedDataValue::Object(Vec::new());
721 }
722 match node {
723 OwnedDataValue::Object(fields) => fields,
724 _ => unreachable!("just normalised to an Object"),
725 }
726}
727
728fn append_error_records(
742 context: &mut OwnedDataValue,
743 cfg: &ErrorContextConfig,
744 workflow_id: &str,
745 task_id: &str,
746 status: u16,
747 new_errors: &[ErrorInfo],
748) {
749 if new_errors.is_empty() {
750 return;
751 }
752 let Some((last, parents)) = cfg.path_parts.split_last() else {
755 return;
756 };
757
758 let mut node = context;
759 for part in parents {
760 let key = strip_hash_prefix(part);
761 if !matches!(node, OwnedDataValue::Object(_)) {
762 warn!(
767 "error context path `{}` runs through a non-object at `{}` — replacing it",
768 cfg.path, key
769 );
770 }
771 let fields = as_object_slot(node);
772 let idx = match fields.iter().position(|(k, _)| k == key) {
773 Some(i) => i,
774 None => {
775 fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
776 fields.len() - 1
777 }
778 };
779 node = &mut fields[idx].1;
780 }
781
782 let key = strip_hash_prefix(last);
783 let fields = as_object_slot(node);
784 let idx = match fields.iter().position(|(k, _)| k == key) {
785 Some(i) => i,
786 None => {
787 fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
788 fields.len() - 1
789 }
790 };
791 let slot = &mut fields[idx].1;
792 if !matches!(slot, OwnedDataValue::Array(_)) {
793 warn!(
794 "error context path `{}` held a non-array — replacing it",
795 cfg.path
796 );
797 *slot = OwnedDataValue::Array(Vec::new());
798 }
799 let OwnedDataValue::Array(items) = slot else {
800 unreachable!("just ensured an Array");
801 };
802
803 for error in new_errors {
804 items.push(new_error_record(workflow_id, task_id, &error.code, status));
805 }
806 if items.len() > cfg.limit {
810 items.drain(..items.len() - cfg.limit);
811 }
812}
813
814pub struct WorkflowExecutor {
822 task_executor: Arc<TaskExecutor>,
824 engine: Arc<Engine>,
826 observer: Option<Arc<dyn ExecutionObserver>>,
829 error_context: Option<Arc<ErrorContextConfig>>,
832}
833
834impl WorkflowExecutor {
835 pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
837 Self {
838 task_executor,
839 engine,
840 observer: None,
841 error_context: None,
842 }
843 }
844
845 pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
847 self.observer = Some(observer);
848 self
849 }
850
851 pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
856 self.observer.as_ref()
857 }
858
859 pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
862 self.error_context = Some(cfg);
863 self
864 }
865
866 pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
872 self.error_context.as_ref()
873 }
874
875 #[inline]
881 fn emit_task_event(
882 &self,
883 workflow: &Workflow,
884 task: &Task,
885 result: &Result<(TaskOutcome, Vec<Change>)>,
886 started_at: Option<DateTime<Utc>>,
887 ) {
888 if self.observer.is_some() {
889 let duration = started_at
890 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
891 .unwrap_or_default();
892 self.emit_task_finished(workflow, task, result, duration);
893 }
894 }
895
896 fn emit_task_finished(
900 &self,
901 workflow: &Workflow,
902 task: &Task,
903 result: &Result<(TaskOutcome, Vec<Change>)>,
904 duration: Duration,
905 ) {
906 if let Some(observer) = self.observer.as_ref() {
907 let status = match result {
908 Ok((outcome, _)) => outcome.audit_status(),
909 Err(_) => Some(500),
910 };
911 observer.task_finished(&TaskEvent {
912 workflow_id: &workflow.id,
913 task_id: &task.id,
914 function: task.function.function_name(),
915 status,
916 duration,
917 });
918 }
919 }
920
921 fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
925 span.sweeps += 1;
926 let Some(observer) = self.observer.as_ref() else {
930 return;
931 };
932 if span.started_at.is_some() {
933 return;
934 }
935 span.started_at = Some(Utc::now());
936 observer.workflow_started(&WorkflowStarted {
937 workflow_id: &workflow.id,
938 });
939 }
940
941 fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
944 let Some(observer) = self.observer.as_ref() else {
945 return;
946 };
947 let Some(started) = span.started_at else {
948 return;
949 };
950 observer.workflow_finished(&WorkflowFinished {
951 workflow_id: &workflow.id,
952 duration: Duration::from_micros(duration_us_between(started, Utc::now())),
953 sweeps: span.sweeps,
954 halted,
955 });
956 }
957
958 #[inline]
963 fn observer_clock(&self) -> Option<DateTime<Utc>> {
964 self.observer.as_ref().map(|_| Utc::now())
965 }
966
967 #[inline]
970 fn open_task_clocks(&self, tracing: bool, message: &Message) -> TaskClocks {
971 let trace_start = if tracing { Some(Utc::now()) } else { None };
975 TaskClocks {
976 trace_start,
977 obs_start: trace_start.or_else(|| self.observer_clock()),
978 errors_before: message.errors.len(),
982 }
983 }
984
985 pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
987 self.task_executor.task_functions()
988 }
989
990 pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
993 self.task_executor.registry()
994 }
995
996 pub async fn execute(
1011 &self,
1012 workflow: &Workflow,
1013 message: &mut Message,
1014 now: DateTime<Utc>,
1015 ) -> Result<bool> {
1016 self.execute_inner(workflow, message, None, now).await
1017 }
1018
1019 pub async fn execute_with_trace(
1023 &self,
1024 workflow: &Workflow,
1025 message: &mut Message,
1026 trace: &mut ExecutionTrace,
1027 now: DateTime<Utc>,
1028 ) -> Result<bool> {
1029 self.execute_inner(workflow, message, Some(trace), now)
1030 .await
1031 }
1032
1033 async fn execute_inner(
1040 &self,
1041 workflow: &Workflow,
1042 message: &mut Message,
1043 mut trace: Option<&mut ExecutionTrace>,
1044 now: DateTime<Utc>,
1045 ) -> Result<bool> {
1046 if !rollout_admits(workflow, message) {
1051 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
1052 return Ok(false);
1053 }
1054
1055 if let Some(loop_config) = workflow.loop_config.as_ref() {
1056 return self
1057 .execute_loop(workflow, loop_config, message, trace, now)
1058 .await;
1059 }
1060
1061 let mut span = WorkflowSpan::default();
1064 let outcome = self
1065 .execute_pass(
1066 PassList::body(workflow),
1067 message,
1068 trace.as_deref_mut(),
1069 PassCtx::once(now),
1070 &mut span,
1071 )
1072 .await;
1073 self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
1074
1075 match outcome {
1076 Ok(PassOutcome::ConditionFalse) => {
1077 note_workflow_skip(trace, &workflow.id, "condition not met");
1079 Ok(false)
1080 }
1081 Ok(_) => {
1082 info!("Successfully completed workflow: {}", workflow.id);
1083 Ok(true)
1084 }
1085 Err(e) => {
1086 if self.record_workflow_error(workflow, message, &e) {
1092 Err(e)
1093 } else {
1094 Ok(true)
1095 }
1096 }
1097 }
1098 }
1099
1100 async fn execute_loop(
1117 &self,
1118 workflow: &Workflow,
1119 config: &LoopConfig,
1120 message: &mut Message,
1121 mut trace: Option<&mut ExecutionTrace>,
1122 now: DateTime<Utc>,
1123 ) -> Result<bool> {
1124 let counter_parts = resolve_parts(config.counter.as_deref(), &config.counter_parts);
1125 let item_parts = resolve_parts(config.item.as_deref(), &config.item_parts);
1126 let scratch_parts = resolve_parts(config.scratch.as_deref(), &config.scratch_parts);
1127 let body = PassList::body(workflow);
1128 let mut span = WorkflowSpan::default();
1131 let mut halted = false;
1132
1133 let mut admitted = false;
1138 let mut items: Option<Vec<OwnedDataValue>> = None;
1139 if !config.setup.is_empty() || config.over.is_some() {
1140 let setup = PassList {
1141 workflow,
1142 tasks: &config.setup,
1143 };
1144 match self
1145 .execute_pass(
1146 setup,
1147 message,
1148 trace.as_deref_mut(),
1149 PassCtx::once(now),
1150 &mut span,
1151 )
1152 .await
1153 {
1154 Ok(PassOutcome::ConditionFalse) => {
1155 note_workflow_skip(trace, &workflow.id, "condition not met");
1156 return Ok(false);
1157 }
1158 Ok(PassOutcome::Halted) => {
1159 span.sweeps = 0;
1162 self.end_workflow(&span, workflow, true);
1163 info!(
1164 "Successfully completed workflow: {} (halted in loop setup)",
1165 workflow.id
1166 );
1167 return Ok(true);
1168 }
1169 Ok(PassOutcome::Completed) => {}
1170 Err(e) => {
1171 span.sweeps = 0;
1175 let stop = self.record_workflow_error(workflow, message, &e);
1176 self.end_workflow(&span, workflow, false);
1177 return if stop { Err(e) } else { Ok(true) };
1178 }
1179 }
1180 admitted = true;
1181 span.sweeps = 0;
1184
1185 if config.over.is_some() {
1186 match self.evaluate_over(workflow, config, message) {
1187 Ok(list) => items = Some(list),
1188 Err(e) => {
1189 let stop = self.record_workflow_error(workflow, message, &e);
1190 self.end_workflow(&span, workflow, false);
1191 return if stop { Err(e) } else { Ok(true) };
1192 }
1193 }
1194 }
1195 }
1196
1197 let mut counter = config.init;
1198 let mut sweeps_run: u32 = 0;
1199
1200 loop {
1201 set_nested_value_parts(
1205 &mut message.context,
1206 &counter_parts,
1207 OwnedDataValue::from_i64(counter),
1208 );
1209
1210 if counter >= config.max {
1217 if workflow.compiled_condition.is_some() {
1222 warn!(
1223 "Workflow {} stopped at its loop bound (max {}) with the condition \
1224 still true after {} sweep(s)",
1225 workflow.id, config.max, sweeps_run
1226 );
1227 }
1228 if let Some(items) = &items
1229 && let Ok(next) = usize::try_from(counter)
1230 && next < items.len()
1231 {
1232 warn!(
1233 "Workflow {} stopped at its loop bound (max {}) with {} of {} \
1234 element(s) of loop.over unvisited",
1235 workflow.id,
1236 config.max,
1237 items.len() - next,
1238 items.len()
1239 );
1240 }
1241 break;
1242 }
1243
1244 if let Some(items) = items.as_mut() {
1247 let Some(slot) = usize::try_from(counter).ok().and_then(|i| items.get_mut(i))
1248 else {
1249 break;
1250 };
1251 let element = std::mem::take(slot);
1254 set_nested_value_parts(&mut message.context, &item_parts, element);
1255 }
1256
1257 set_nested_value_parts(
1262 &mut message.context,
1263 &scratch_parts,
1264 OwnedDataValue::Object(Vec::new()),
1265 );
1266
1267 let pass = PassCtx {
1268 now,
1269 loop_counter: Some(counter),
1270 element_index: None,
1271 };
1272
1273 match self
1274 .execute_pass(body, message, trace.as_deref_mut(), pass, &mut span)
1275 .await
1276 {
1277 Ok(PassOutcome::ConditionFalse) => {
1278 if sweeps_run == 0 && !admitted {
1279 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1282 } else {
1283 debug!(
1284 "Workflow {} loop exited at counter {} - condition no longer met",
1285 workflow.id, counter
1286 );
1287 }
1288 break;
1289 }
1290 Ok(PassOutcome::Halted) => {
1291 sweeps_run += 1;
1292 debug!(
1293 "Workflow {} loop halted at counter {}",
1294 workflow.id, counter
1295 );
1296 halted = true;
1297 break;
1298 }
1299 Ok(PassOutcome::Completed) => {
1300 sweeps_run += 1;
1301 }
1302 Err(e) => {
1303 sweeps_run += 1;
1304 if self.record_workflow_error(workflow, message, &e) {
1309 self.end_workflow(&span, workflow, halted);
1313 return Err(e);
1314 }
1315 }
1316 }
1317
1318 counter = counter.saturating_add(config.increment);
1319 }
1320
1321 self.end_workflow(&span, workflow, halted);
1322
1323 if sweeps_run > 0 {
1324 info!(
1325 "Successfully completed workflow: {} ({} loop sweep(s))",
1326 workflow.id, sweeps_run
1327 );
1328 }
1329 Ok(sweeps_run > 0 || admitted)
1330 }
1331
1332 async fn run_for_each(
1341 &self,
1342 workflow: &Workflow,
1343 task: &Task,
1344 for_each: &ForEach,
1345 message: &mut Message,
1346 mut trace: Option<&mut ExecutionTrace>,
1347 pass: PassCtx,
1348 ) -> Result<TaskControlFlow> {
1349 use futures_util::stream::{FuturesUnordered, StreamExt};
1350
1351 let tracing = trace.is_some();
1352
1353 let items = match self.evaluate_for_each_over(task, for_each, message) {
1358 Ok(items) if !items.is_empty() => items,
1359 other => {
1360 let failed = other.is_err();
1361 if other.is_ok() && !for_each.into_parts.is_empty() {
1362 self.write_empty_results(for_each, message);
1363 }
1364 let clocks = self.open_task_clocks(tracing, message);
1365 let result = other.map(|_| {
1366 let changes = self.empty_results_change(for_each, message);
1367 (TaskOutcome::Success, changes)
1368 });
1369 self.emit_task_event(workflow, task, &result, clocks.obs_start);
1370 let flow = self.handle_task_result(
1371 result,
1372 &workflow.id_arc,
1373 &task.id_arc,
1374 fan_out_pass(task, clocks.errors_before),
1375 message,
1376 pass,
1377 )?;
1378 pass.note_executed(trace, &workflow.id, &task.id, message, clocks, None);
1379 let halt = matches!(flow, TaskControlFlow::HaltWorkflow)
1380 || (failed && task.halt_on == HaltOn::Failure);
1381 return Ok(fan_out_flow(task, halt));
1382 }
1383 };
1384 let n = items.len();
1385
1386 let call = ElementCall {
1388 workflow,
1389 task,
1390 for_each,
1391 loop_counter: pass.loop_counter,
1392 timed: tracing || self.observer.is_some(),
1393 };
1394 let mut outcomes: Vec<Option<ElementOutcome>> = (0..n).map(|_| None).collect();
1395 {
1396 let base: &Message = message;
1399 let mut pending = items.into_iter().enumerate();
1400 let mut in_flight = FuturesUnordered::new();
1401 let mut stop = false;
1402 loop {
1403 while !stop && in_flight.len() < for_each.max_concurrency {
1404 let Some((index, item)) = pending.next() else {
1405 break;
1406 };
1407 in_flight.push(self.run_element(call, base, index, item));
1408 }
1409 let Some(outcome) = in_flight.next().await else {
1410 break;
1411 };
1412 stop |= outcome.stops_fan_out(task.continue_on_error);
1413 let index = outcome.index;
1414 outcomes[index] = Some(outcome);
1415 }
1416 }
1417
1418 if !for_each.into_parts.is_empty() {
1422 set_nested_value_parts(
1425 &mut message.context,
1426 &for_each.into_parts,
1427 OwnedDataValue::Array(vec![OwnedDataValue::Null; n]),
1428 );
1429 }
1430 let mut halt = false;
1431 let mut any_failed = false;
1432 for outcome in outcomes.into_iter().flatten() {
1433 let failed = outcome.failed();
1434 let ElementOutcome {
1435 index,
1436 result,
1437 changes,
1438 new_errors,
1439 collected,
1440 timing,
1441 } = outcome;
1442
1443 let errors_before = message.errors.len();
1447 message
1448 .errors
1449 .extend(new_errors.into_iter().map(|mut error| {
1450 error.element_index.get_or_insert(index);
1451 error
1452 }));
1453
1454 for change in &changes {
1455 if change.removed {
1456 remove_nested_value(&mut message.context, &change.path);
1457 } else {
1458 set_nested_value(&mut message.context, &change.path, change.new_value.clone());
1459 }
1460 }
1461
1462 let mut changes = if message.capture_changes {
1463 changes
1464 } else {
1465 Vec::new()
1466 };
1467 if let Some(into) = for_each.into.as_deref() {
1468 let value = if failed {
1469 OwnedDataValue::Null
1470 } else {
1471 collected
1472 };
1473 if message.capture_changes {
1474 changes.push(Change {
1475 path: Arc::from(format!("{into}.{index}")),
1476 old_value: OwnedDataValue::Null,
1477 new_value: value.clone(),
1478 removed: false,
1479 });
1480 }
1481 let mut parts = for_each.into_parts.to_vec();
1482 parts.push(Arc::from(index.to_string()));
1483 set_nested_value_parts(&mut message.context, &parts, value);
1484 }
1485
1486 any_failed |= failed;
1487 let result = result.map(|outcome| (outcome, changes));
1488 let element = PassCtx {
1489 element_index: Some(index),
1490 ..pass
1491 };
1492 let duration = timing
1493 .map(|t| Duration::from_micros(t.duration_us))
1494 .unwrap_or_default();
1495 self.emit_task_finished(workflow, task, &result, duration);
1496 let flow = self.handle_task_result(
1497 result,
1498 &workflow.id_arc,
1499 &task.id_arc,
1500 fan_out_pass(task, errors_before),
1501 message,
1502 element,
1503 )?;
1504 if let Some(timing) = timing {
1505 element.note_executed_timed(
1506 trace.as_deref_mut(),
1507 &workflow.id,
1508 &task.id,
1509 message,
1510 timing,
1511 );
1512 }
1513 if matches!(flow, TaskControlFlow::HaltWorkflow) {
1518 halt = true;
1519 break;
1520 }
1521 }
1522
1523 if halt {
1524 info!(
1525 "Task {} halted workflow {} during its fan-out",
1526 task.id, workflow.id
1527 );
1528 }
1529 Ok(fan_out_flow(
1530 task,
1531 halt || (task.halt_on == HaltOn::Failure && any_failed),
1532 ))
1533 }
1534
1535 async fn run_element(
1543 &self,
1544 call: ElementCall<'_>,
1545 base: &Message,
1546 index: usize,
1547 item: OwnedDataValue,
1548 ) -> ElementOutcome {
1549 let fe = call.for_each;
1550 let mut copy = base.clone();
1551 copy.capture_changes = true;
1552 set_nested_value_parts(&mut copy.context, &fe.item_parts, item);
1553 set_nested_value_parts(
1554 &mut copy.context,
1555 &fe.index_parts,
1556 OwnedDataValue::from(index as u64),
1557 );
1558 let errors_before = copy.errors.len();
1559
1560 let started_at = call.timed.then(Utc::now);
1561 let result = self
1562 .task_executor
1563 .execute_in_workflow(
1564 call.task,
1565 &mut copy,
1566 Some(TaskIdentity {
1567 workflow_id: &call.workflow.id_arc,
1568 task_id: &call.task.id_arc,
1569 }),
1570 CallStamp {
1571 loop_counter: call.loop_counter,
1572 element_index: Some(index),
1573 },
1574 )
1575 .await;
1576 let timing = started_at.map(|started_at| StepTiming {
1577 started_at,
1578 duration_us: duration_us_between(started_at, Utc::now()),
1579 });
1580
1581 let (result, changes) = match result {
1582 Ok((outcome, changes)) => (Ok(outcome), changes),
1583 Err(e) => (Err(e), Vec::new()),
1584 };
1585 let collected = if fe.collect_parts.is_empty() {
1586 OwnedDataValue::Null
1587 } else {
1588 get_nested_value_parts(©.context, &fe.collect_parts)
1589 .cloned()
1590 .unwrap_or(OwnedDataValue::Null)
1591 };
1592 ElementOutcome {
1593 index,
1594 result,
1595 changes,
1596 new_errors: copy.errors.split_off(errors_before),
1597 collected,
1598 timing,
1599 }
1600 }
1601
1602 fn evaluate_for_each_over(
1608 &self,
1609 task: &Task,
1610 for_each: &ForEach,
1611 message: &Message,
1612 ) -> Result<Vec<OwnedDataValue>> {
1613 let Some(compiled) = for_each.compiled_over.as_ref() else {
1614 return Err(DataflowError::Task(format!(
1615 "Task {}: for_each.over is not compiled — build the engine through \
1616 Engine::builder or Engine::new rather than running the workflow directly",
1617 task.id
1618 )));
1619 };
1620 match eval_to_owned(&self.engine, compiled, &message.context) {
1621 Ok(OwnedDataValue::Array(items)) => Ok(items),
1622 Ok(other) => Err(DataflowError::Task(format!(
1623 "Task {}: for_each.over must evaluate to an array, got {}",
1624 task.id,
1625 describe_kind(&other)
1626 ))),
1627 Err(e) => match from_datalogic_eval(&e) {
1628 budget @ DataflowError::BudgetExceeded(_) => Err(budget),
1629 other => Err(DataflowError::Task(format!(
1630 "Task {}: for_each.over failed to evaluate: {other}",
1631 task.id
1632 ))),
1633 },
1634 }
1635 }
1636
1637 fn write_empty_results(&self, for_each: &ForEach, message: &mut Message) {
1639 set_nested_value_parts(
1640 &mut message.context,
1641 &for_each.into_parts,
1642 OwnedDataValue::Array(Vec::new()),
1643 );
1644 }
1645
1646 fn empty_results_change(&self, for_each: &ForEach, message: &Message) -> Vec<Change> {
1650 match for_each.into.as_deref() {
1651 Some(into) if message.capture_changes => vec![Change {
1652 path: Arc::from(into),
1653 old_value: OwnedDataValue::Null,
1654 new_value: OwnedDataValue::Array(Vec::new()),
1655 removed: false,
1656 }],
1657 _ => Vec::new(),
1658 }
1659 }
1660
1661 fn evaluate_over(
1667 &self,
1668 workflow: &Workflow,
1669 config: &LoopConfig,
1670 message: &Message,
1671 ) -> Result<Vec<OwnedDataValue>> {
1672 let Some(compiled) = config.compiled_over.as_ref() else {
1673 return Err(DataflowError::Workflow(format!(
1674 "Workflow {}: loop.over is not compiled — build the engine through \
1675 Engine::builder or Engine::new rather than running the workflow directly",
1676 workflow.id
1677 )));
1678 };
1679 match eval_to_owned(&self.engine, compiled, &message.context) {
1680 Ok(OwnedDataValue::Array(items)) => Ok(items),
1681 Ok(other) => Err(DataflowError::Workflow(format!(
1682 "Workflow {}: loop.over must evaluate to an array, got {}",
1683 workflow.id,
1684 describe_kind(&other)
1685 ))),
1686 Err(e) => Err(DataflowError::Workflow(format!(
1687 "Workflow {}: loop.over failed to evaluate: {}",
1688 workflow.id,
1689 from_datalogic_eval(&e)
1690 ))),
1691 }
1692 }
1693
1694 async fn execute_pass(
1709 &self,
1710 list: PassList<'_>,
1711 message: &mut Message,
1712 mut trace: Option<&mut ExecutionTrace>,
1713 pass: PassCtx,
1714 span: &mut WorkflowSpan,
1715 ) -> Result<PassOutcome> {
1716 enum FirstStretch {
1718 Skipped,
1720 Halted,
1722 Continue(usize),
1726 }
1727
1728 let PassList { workflow, tasks } = list;
1729 let first_boundary = next_async_boundary(tasks, 0);
1730 let mut gate = GroupGate::default();
1733
1734 let first: Result<FirstStretch> =
1735 if workflow.compiled_condition.is_none() && first_boundary == 0 {
1736 self.begin_workflow(span, workflow);
1740 Ok(FirstStretch::Continue(0))
1741 } else {
1742 with_arena(|arena| -> Result<FirstStretch> {
1743 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1744
1745 let should_execute = match workflow.compiled_condition.as_ref() {
1746 None => true,
1747 Some(compiled) => evaluate_condition_in_arena(
1748 &self.engine,
1749 Some(compiled),
1750 arena_ctx.as_data_value(),
1751 arena,
1752 )?,
1753 };
1754 if !should_execute {
1755 return Ok(FirstStretch::Skipped);
1756 }
1757 self.begin_workflow(span, workflow);
1761 if first_boundary == 0 {
1762 return Ok(FirstStretch::Continue(0));
1763 }
1764 let outcome = self.run_tasks_slice_in_arena(
1765 TaskSlice {
1766 tasks: &tasks[..first_boundary],
1767 offset: 0,
1768 gate: &mut gate,
1769 list,
1770 },
1771 message,
1772 &mut arena_ctx,
1773 trace.as_deref_mut(),
1774 pass,
1775 )?;
1776 Ok(match outcome {
1777 SliceOutcome::Halted => FirstStretch::Halted,
1778 SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
1779 SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
1780 })
1781 })
1782 };
1783
1784 match first? {
1788 FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
1789 FirstStretch::Halted => Ok(PassOutcome::Halted),
1790 FirstStretch::Continue(resume_at) => {
1791 let halted = self
1792 .execute_tasks(list, message, trace, pass, resume_at, &mut gate)
1793 .await?;
1794 Ok(if halted {
1795 PassOutcome::Halted
1796 } else {
1797 PassOutcome::Completed
1798 })
1799 }
1800 }
1801 }
1802
1803 fn record_workflow_error(
1812 &self,
1813 workflow: &Workflow,
1814 message: &mut Message,
1815 e: &DataflowError,
1816 ) -> bool {
1817 message.errors.push(
1818 ErrorInfo::builder(
1819 "WORKFLOW_ERROR",
1820 format!("Workflow {} error: {}", workflow.id, e),
1821 )
1822 .workflow_id(&workflow.id)
1823 .build(),
1824 );
1825
1826 if workflow.continue_on_error {
1827 warn!(
1828 "Workflow {} encountered error but continuing: {:?}",
1829 workflow.id, e
1830 );
1831 false
1832 } else {
1833 error!("Workflow {} failed: {:?}", workflow.id, e);
1834 true
1835 }
1836 }
1837
1838 async fn execute_tasks(
1858 &self,
1859 list: PassList<'_>,
1860 message: &mut Message,
1861 mut trace: Option<&mut ExecutionTrace>,
1862 pass: PassCtx,
1863 start: usize,
1864 gate: &mut GroupGate,
1865 ) -> Result<bool> {
1866 let PassList { workflow, tasks } = list;
1867 let mut idx = start;
1868 while idx < tasks.len() {
1869 let stretch_end = next_async_boundary(tasks, idx);
1870
1871 if stretch_end > idx {
1872 match self.run_sync_stretch(
1874 TaskSlice {
1875 tasks: &tasks[idx..stretch_end],
1876 offset: idx,
1877 gate,
1878 list,
1879 },
1880 message,
1881 trace.as_deref_mut(),
1882 pass,
1883 )? {
1884 SliceOutcome::Halted => return Ok(true),
1885 SliceOutcome::JumpTo(target) => {
1888 idx = target;
1889 continue;
1890 }
1891 SliceOutcome::Completed => idx = stretch_end,
1892 }
1893 }
1894
1895 if idx < tasks.len() {
1896 let task = &tasks[idx];
1898
1899 match admit_task(
1900 list,
1901 task,
1902 idx,
1903 gate,
1904 trace.as_deref_mut(),
1905 pass,
1906 |compiled| evaluate_condition(&self.engine, compiled, &message.context),
1907 )? {
1908 Admission::Halt => return Ok(true),
1909 Admission::Jump(target) => {
1910 idx = target;
1911 continue;
1912 }
1913 Admission::Skip => {
1914 idx += 1;
1915 continue;
1916 }
1917 Admission::Run => {}
1918 }
1919
1920 if let Some(for_each) = task.for_each.as_ref() {
1925 let flow = self
1926 .run_for_each(
1927 workflow,
1928 task,
1929 for_each,
1930 message,
1931 trace.as_deref_mut(),
1932 pass,
1933 )
1934 .await?;
1935 if matches!(flow, TaskControlFlow::HaltWorkflow) {
1936 return Ok(true);
1937 }
1938 idx += 1;
1939 continue;
1940 }
1941
1942 let clocks = self.open_task_clocks(trace.is_some(), message);
1943
1944 let result = self
1945 .task_executor
1946 .execute_in_workflow(
1947 task,
1948 message,
1949 Some(TaskIdentity {
1950 workflow_id: &workflow.id_arc,
1951 task_id: &task.id_arc,
1952 }),
1953 CallStamp {
1954 loop_counter: pass.loop_counter,
1955 element_index: None,
1956 },
1957 )
1958 .await;
1959
1960 self.emit_task_event(workflow, task, &result, clocks.obs_start);
1962
1963 let control_flow = self.handle_task_result(
1967 result,
1968 &workflow.id_arc,
1969 &task.id_arc,
1970 TaskPass {
1971 continue_on_error: task.continue_on_error,
1972 terminal: task.terminal,
1973 halt_on: task.halt_on,
1974 errors_before: clocks.errors_before,
1975 },
1976 message,
1977 pass,
1978 )?;
1979
1980 pass.note_executed(
1983 trace.as_deref_mut(),
1984 &workflow.id,
1985 &task.id,
1986 message,
1987 clocks,
1988 None,
1989 );
1990
1991 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1992 return Ok(true);
1993 }
1994 idx += 1;
1995 }
1996 }
1997
1998 Ok(gate.close_through(tasks.len()))
2002 }
2003
2004 fn run_sync_stretch(
2014 &self,
2015 slice: TaskSlice<'_, '_>,
2016 message: &mut Message,
2017 trace: Option<&mut ExecutionTrace>,
2018 pass: PassCtx,
2019 ) -> Result<SliceOutcome> {
2020 with_arena(|arena| -> Result<SliceOutcome> {
2021 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2022 self.run_tasks_slice_in_arena(slice, message, &mut arena_ctx, trace, pass)
2023 })
2024 }
2025
2026 fn run_tasks_slice_in_arena<'arena>(
2036 &self,
2037 slice: TaskSlice<'_, 'arena>,
2038 message: &mut Message,
2039 arena_ctx: &mut ArenaContext<'arena>,
2040 mut trace: Option<&mut ExecutionTrace>,
2041 pass: PassCtx,
2042 ) -> Result<SliceOutcome> {
2043 let arena = arena_ctx.arena();
2044 let TaskSlice {
2045 tasks,
2046 offset,
2047 gate,
2048 list,
2049 } = slice;
2050 let workflow = list.workflow;
2051 let slice_end = offset + tasks.len();
2052
2053 let mut i = 0;
2054 while i < tasks.len() {
2055 let task = &tasks[i];
2056 let abs = offset + i;
2057
2058 match admit_task(
2063 list,
2064 task,
2065 abs,
2066 gate,
2067 trace.as_deref_mut(),
2068 pass,
2069 |compiled| {
2070 evaluate_condition_in_arena(
2071 &self.engine,
2072 compiled,
2073 arena_ctx.as_data_value(),
2074 arena,
2075 )
2076 },
2077 )? {
2078 Admission::Halt => return Ok(SliceOutcome::Halted),
2079 Admission::Jump(target) => {
2080 if target >= slice_end {
2083 return Ok(SliceOutcome::JumpTo(target));
2084 }
2085 i = target - offset;
2086 continue;
2087 }
2088 Admission::Skip => {
2089 i += 1;
2090 continue;
2091 }
2092 Admission::Run => {}
2093 }
2094
2095 let mut mapping_snapshots: Vec<Value> = Vec::new();
2099 let want_mapping_contexts = trace
2100 .as_deref()
2101 .is_some_and(|t| t.options().mapping_contexts);
2102 let mapping_snapshots_buf = if want_mapping_contexts {
2103 Some(&mut mapping_snapshots)
2104 } else {
2105 None
2106 };
2107
2108 let clocks = self.open_task_clocks(trace.is_some(), message);
2109
2110 let result =
2111 self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
2112
2113 self.emit_task_event(workflow, task, &result, clocks.obs_start);
2115
2116 let flow = self.handle_task_result(
2117 result,
2118 &workflow.id_arc,
2119 &task.id_arc,
2120 TaskPass {
2121 continue_on_error: task.continue_on_error,
2122 terminal: task.terminal,
2123 halt_on: task.halt_on,
2124 errors_before: clocks.errors_before,
2125 },
2126 message,
2127 pass,
2128 );
2129
2130 arena_ctx.refresh_for_path(&message.context, "metadata.progress");
2143 if let Some(cfg) = self.error_context_refresh(message, clocks.errors_before) {
2147 arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
2148 }
2149
2150 let control_flow = flow?;
2151
2152 pass.note_executed(
2153 trace.as_deref_mut(),
2154 &workflow.id,
2155 &task.id,
2156 message,
2157 clocks,
2158 Some(mapping_snapshots).filter(|s| !s.is_empty()),
2161 );
2162
2163 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
2164 return Ok(SliceOutcome::Halted);
2165 }
2166 i += 1;
2167 }
2168 Ok(SliceOutcome::Completed)
2169 }
2170
2171 pub async fn run_all(
2183 &self,
2184 workflows: &[&Workflow],
2185 message: &mut Message,
2186 trace: Option<&mut ExecutionTrace>,
2187 now: DateTime<Utc>,
2188 ) -> Result<()> {
2189 self.run_all_borrowed(workflows, message, trace, now).await
2190 }
2191
2192 pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
2197 &self,
2198 workflows: &[W],
2199 message: &mut Message,
2200 trace: Option<&mut ExecutionTrace>,
2201 now: DateTime<Utc>,
2202 ) -> Result<()> {
2203 let started_at = self.observer_clock();
2204 if let Some(observer) = self.observer.as_ref() {
2205 observer.message_started(&MessageStarted {
2206 message_id: message.id(),
2207 workflows_considered: workflows.len(),
2208 });
2209 }
2210 let outcome = self.run_all_inner(workflows, message, trace, now).await;
2211 if let Some(observer) = self.observer.as_ref() {
2212 observer.message_finished(&MessageFinished {
2213 message_id: message.id(),
2214 duration: started_at
2215 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
2216 .unwrap_or_default(),
2217 errors: message.errors().len(),
2218 stopped_early: outcome.is_err(),
2219 });
2220 }
2221 outcome
2222 }
2223
2224 async fn run_all_inner<W: std::borrow::Borrow<Workflow>>(
2228 &self,
2229 workflows: &[W],
2230 message: &mut Message,
2231 mut trace: Option<&mut ExecutionTrace>,
2232 now: DateTime<Utc>,
2233 ) -> Result<()> {
2234 let mut i = 0;
2235 while i < workflows.len() {
2236 if joins_sync_run(workflows[i].borrow()) {
2237 let mut j = i + 1;
2240 while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
2241 j += 1;
2242 }
2243 self.execute_sync_workflow_run(
2244 &workflows[i..j],
2245 message,
2246 trace.as_deref_mut(),
2247 now,
2248 )?;
2249 i = j;
2250 } else {
2251 self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
2254 .await?;
2255 i += 1;
2256 }
2257 }
2258 Ok(())
2259 }
2260
2261 fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
2279 &self,
2280 workflows: &[W],
2281 message: &mut Message,
2282 mut trace: Option<&mut ExecutionTrace>,
2283 now: DateTime<Utc>,
2284 ) -> Result<()> {
2285 debug_assert!(
2288 workflows.iter().all(|w| joins_sync_run(w.borrow())),
2289 "only non-looping fully-sync workflows may join a shared-arena run"
2290 );
2291 let pass = PassCtx::once(now);
2292
2293 with_arena(|arena| -> Result<()> {
2294 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2295
2296 for workflow in workflows {
2297 let workflow: &Workflow = workflow.borrow();
2298
2299 if !rollout_admits(workflow, message) {
2305 note_workflow_skip(
2306 trace.as_deref_mut(),
2307 &workflow.id,
2308 "outside rollout bucket",
2309 );
2310 continue;
2311 }
2312
2313 let should_execute = match workflow.compiled_condition.as_ref() {
2317 None => true,
2318 Some(compiled) => evaluate_condition_in_arena(
2319 &self.engine,
2320 Some(compiled),
2321 arena_ctx.as_data_value(),
2322 arena,
2323 )?,
2324 };
2325
2326 if !should_execute {
2327 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
2328 continue;
2329 }
2330
2331 let mut span = WorkflowSpan::default();
2334 self.begin_workflow(&mut span, workflow);
2335
2336 let mut gate = GroupGate::default();
2339 match self.run_tasks_slice_in_arena(
2340 TaskSlice {
2341 tasks: &workflow.tasks,
2342 offset: 0,
2343 gate: &mut gate,
2344 list: PassList::body(workflow),
2345 },
2346 message,
2347 &mut arena_ctx,
2348 trace.as_deref_mut(),
2349 pass,
2350 ) {
2351 Ok(outcome) => {
2355 self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
2356 info!("Successfully completed workflow: {}", workflow.id);
2357 }
2358 Err(e) => {
2359 self.end_workflow(&span, workflow, false);
2363 if self.record_workflow_error(workflow, message, &e) {
2365 return Err(e);
2366 }
2367 }
2368 }
2369 }
2370 Ok(())
2371 })
2372 }
2373
2374 fn execute_sync_task_in_arena<'arena>(
2382 &self,
2383 task: &'arena Task,
2384 message: &mut Message,
2385 arena_ctx: &mut ArenaContext<'arena>,
2386 mapping_snapshots: Option<&mut Vec<Value>>,
2387 ) -> Result<(TaskOutcome, Vec<Change>)> {
2388 debug!(
2389 "Executing sync task in arena: {} ({})",
2390 task.id,
2391 task.function.function_name()
2392 );
2393 debug_assert!(
2394 task.function.is_sync_builtin(),
2395 "execute_sync_task_in_arena called with non-sync-builtin task: {}",
2396 task.function.function_name()
2397 );
2398 task.function
2402 .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
2403 .ok_or_else(|| {
2404 DataflowError::Task(format!(
2405 "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
2406 (engine bug — sync-stretch should only contain sync-builtin tasks)",
2407 task.function.function_name()
2408 ))
2409 })?
2410 }
2411
2412 #[inline]
2431 fn mirror_task_errors(
2432 &self,
2433 message: &mut Message,
2434 workflow_id: &str,
2435 task_id: &str,
2436 status: u16,
2437 task: TaskPass,
2438 ) {
2439 let Some(cfg) = self.error_context.as_ref() else {
2440 return;
2441 };
2442 let Message {
2445 context, errors, ..
2446 } = message;
2447 let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
2448 append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
2449 }
2450
2451 #[inline]
2455 fn error_context_refresh<'a>(
2456 &'a self,
2457 message: &Message,
2458 errors_before: usize,
2459 ) -> Option<&'a Arc<ErrorContextConfig>> {
2460 let cfg = self.error_context.as_ref()?;
2461 if message.errors.len() > errors_before {
2462 Some(cfg)
2463 } else {
2464 None
2465 }
2466 }
2467
2468 fn handle_task_result(
2474 &self,
2475 result: Result<(TaskOutcome, Vec<Change>)>,
2476 workflow_id_arc: &Arc<str>,
2477 task_id_arc: &Arc<str>,
2478 task: TaskPass,
2479 message: &mut Message,
2480 pass: PassCtx,
2481 ) -> Result<TaskControlFlow> {
2482 let workflow_id: &str = workflow_id_arc;
2483 let task_id: &str = task_id_arc;
2484 let continue_on_error = task.continue_on_error;
2485 match result {
2486 Ok((TaskOutcome::Skip, _)) => {
2487 debug!("Task {} signaled skip", task_id);
2496 Ok(TaskControlFlow::Continue)
2497 }
2498 Ok((outcome, changes)) => {
2499 let status = outcome
2503 .audit_status()
2504 .expect("Skip handled above; remaining variants emit audit status");
2505 let halt_requested = outcome.halts_workflow() || task.halts_at(status);
2513
2514 message.audit_trail.push(AuditTrail {
2519 timestamp: pass.now,
2520 workflow_id: Arc::clone(workflow_id_arc),
2521 task_id: Arc::clone(task_id_arc),
2522 status: status as usize,
2523 changes,
2524 loop_counter: pass.loop_counter,
2525 element_index: pass.element_index,
2526 });
2527
2528 write_progress_metadata(&mut message.context, workflow_id, task_id, status);
2539
2540 let flow = if (400..500).contains(&status) {
2546 warn!("Task {} returned client error status: {}", task_id, status);
2547 Ok(TaskControlFlow::Continue)
2548 } else if status >= 500 {
2549 error!("Task {} returned server error status: {}", task_id, status);
2550 let mut info = ErrorInfo::builder(
2555 "TASK_STATUS_ERROR",
2556 format!("Task {} returned status {}", task_id, status),
2557 )
2558 .workflow_id(workflow_id)
2559 .task_id(task_id);
2560 if let Some(index) = pass.element_index {
2561 info = info.element_index(index);
2562 }
2563 message.errors.push(info.build());
2564 if continue_on_error {
2565 Ok(TaskControlFlow::Continue)
2566 } else {
2567 Err(DataflowError::Task(format!(
2568 "Task {} failed with status {}",
2569 task_id, status
2570 )))
2571 }
2572 } else {
2573 Ok(TaskControlFlow::Continue)
2574 };
2575
2576 let flow = match flow {
2580 Ok(TaskControlFlow::Continue) if halt_requested => {
2581 info!("Task {} halted workflow {}", task_id, workflow_id);
2582 Ok(TaskControlFlow::HaltWorkflow)
2583 }
2584 other => other,
2585 };
2586
2587 self.mirror_task_errors(message, workflow_id, task_id, status, task);
2590 flow
2591 }
2592 Err(e) => {
2593 error!("Task {} failed: {:?}", task_id, e);
2594
2595 message.audit_trail.push(AuditTrail {
2597 timestamp: pass.now,
2598 workflow_id: Arc::clone(workflow_id_arc),
2599 task_id: Arc::clone(task_id_arc),
2600 status: 500,
2601 changes: vec![],
2602 loop_counter: pass.loop_counter,
2603 element_index: pass.element_index,
2604 });
2605
2606 write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
2610
2611 let mut info = ErrorInfo::builder(
2624 service_error_code(&e),
2625 format!("Task {} error: {}", task_id, e),
2626 )
2627 .workflow_id(workflow_id)
2628 .task_id(task_id);
2629 if let Some(detail) = e.detail() {
2630 info = info.detail(detail);
2631 }
2632 if let Some(index) = pass.element_index {
2633 info = info.element_index(index);
2634 }
2635 message.errors.push(info.build());
2636
2637 self.mirror_task_errors(message, workflow_id, task_id, 500, task);
2640
2641 if !continue_on_error {
2642 Err(e)
2643 } else if task.halts_at(500) {
2644 info!(
2651 "Task {} halted workflow {} after failing",
2652 task_id, workflow_id
2653 );
2654 Ok(TaskControlFlow::HaltWorkflow)
2655 } else {
2656 Ok(TaskControlFlow::Continue)
2657 }
2658 }
2659 }
2660 }
2661}
2662
2663#[cfg(test)]
2664mod tests {
2665 use super::*;
2666 use crate::engine::compiler::LogicCompiler;
2667 use serde_json::json;
2668 use std::collections::HashMap;
2669
2670 fn dv(v: serde_json::Value) -> OwnedDataValue {
2672 OwnedDataValue::from(&v)
2673 }
2674
2675 fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
2677 let compiler = LogicCompiler::new();
2678 let workflow = Workflow::from_json(json).expect("workflow should parse");
2679 let compiled = compiler
2680 .compile_workflows(vec![workflow])
2681 .expect("workflow should compile");
2682 (
2683 compiled.into_iter().next().expect("one workflow"),
2684 compiler.into_engine(),
2685 )
2686 }
2687
2688 fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2690 let task_executor = Arc::new(TaskExecutor::new(
2691 Arc::new(HashMap::new()),
2692 Arc::clone(&engine),
2693 ));
2694 WorkflowExecutor::new(task_executor, engine)
2695 }
2696
2697 fn executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2699 let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
2700 .expect("metadata.errors is a valid path");
2701 executor(engine).with_error_context(Arc::new(cfg))
2702 }
2703
2704 #[tokio::test]
2705 async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
2706 let (workflow, engine) = compiled(
2718 r#"{ "id": "w", "name": "w", "tasks": [
2719 { "id": "check", "name": "check", "continue_on_error": true,
2720 "function": {"name": "validation", "input": {"rules": [
2721 {"logic": false, "message": "nope"}]}}},
2722 { "id": "react", "name": "react",
2723 "function": {"name": "map", "input": {"mappings": [
2724 {"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
2725 ]}"#,
2726 );
2727 let mut message = Message::from_value(&json!({}));
2728
2729 executor_with_error_context(engine)
2730 .execute(&workflow, &mut message, Utc::now())
2731 .await
2732 .expect("continue_on_error keeps the workflow running");
2733
2734 assert_eq!(
2735 message.context["data"].get("seen"),
2736 Some(&dv(json!("VALIDATION_ERROR"))),
2737 "the map must read the record the validation appended in the same stretch"
2738 );
2739 }
2740
2741 #[tokio::test]
2742 async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
2743 let (workflow, engine) = compiled(&format!(
2744 r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
2745 ));
2746 let mut message = Message::from_value(&json!({}));
2747
2748 executor_with_error_context(engine)
2749 .execute(&workflow, &mut message, Utc::now())
2750 .await
2751 .expect("workflow should complete");
2752
2753 assert_eq!(
2754 message.context["metadata"].get("errors"),
2755 None,
2756 "a clean run leaves the key absent, not an empty array"
2757 );
2758 }
2759
2760 fn counters(message: &Message) -> Vec<Option<i64>> {
2762 message
2763 .audit_trail
2764 .iter()
2765 .map(|entry| entry.loop_counter)
2766 .collect()
2767 }
2768
2769 const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2771 "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
2772
2773 #[tokio::test]
2774 async fn loop_without_a_condition_runs_exactly_max_sweeps() {
2775 let (workflow, engine) = compiled(&format!(
2776 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2777 "tasks": [{COUNTER_BODY}] }}"#
2778 ));
2779 let mut message = Message::from_value(&json!({}));
2780
2781 let executed = executor(engine)
2782 .execute(&workflow, &mut message, Utc::now())
2783 .await
2784 .expect("loop should complete");
2785
2786 assert!(executed);
2787 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2789 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2791 assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
2793 }
2794
2795 #[tokio::test]
2796 async fn loop_exits_early_when_the_condition_goes_false() {
2797 let (workflow, engine) = compiled(&format!(
2799 r#"{{ "id": "w", "name": "w",
2800 "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
2801 "loop": {{"counter": "i", "max": 10}},
2802 "tasks": [{COUNTER_BODY}] }}"#
2803 ));
2804 let mut message = Message::from_value(&json!({}));
2805
2806 executor(engine)
2807 .execute(&workflow, &mut message, Utc::now())
2808 .await
2809 .expect("loop should complete");
2810
2811 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
2812 }
2813
2814 #[tokio::test]
2815 async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
2816 let (workflow, engine) = compiled(&format!(
2817 r#"{{ "id": "w", "name": "w", "condition": false,
2818 "loop": {{"counter": "i", "max": 5}},
2819 "tasks": [{COUNTER_BODY}] }}"#
2820 ));
2821 let mut message = Message::from_value(&json!({}));
2822
2823 let executed = executor(engine)
2824 .execute(&workflow, &mut message, Utc::now())
2825 .await
2826 .expect("a skip is not an error");
2827
2828 assert!(!executed, "a never-entered loop reports as skipped");
2829 assert!(message.audit_trail.is_empty());
2830 }
2831
2832 const OVER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2838 "input": {"mappings": [
2839 {"path": "data.last", "logic": {"var": "temp_data.item.id"}},
2840 {"path": "data.last_i", "logic": {"var": "temp_data.i"}}]}}}"#;
2841
2842 fn items_message() -> Message {
2843 Message::builder()
2844 .data(dv(
2845 json!({"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}),
2846 ))
2847 .build()
2848 }
2849
2850 #[tokio::test]
2851 async fn an_over_loop_visits_each_element_with_its_index() {
2852 let (workflow, engine) = compiled(&format!(
2853 r#"{{ "id": "w", "name": "w",
2854 "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2855 "tasks": [{OVER_BODY}] }}"#
2856 ));
2857 let mut message = items_message();
2858
2859 let executed = executor(engine)
2860 .execute(&workflow, &mut message, Utc::now())
2861 .await
2862 .expect("loop should complete");
2863
2864 assert!(executed);
2865 assert_eq!(
2866 counters(&message),
2867 vec![Some(0), Some(1), Some(2)],
2868 "one sweep per element, not one per max"
2869 );
2870 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
2871 assert_eq!(message.context["data"].get("last_i"), Some(&dv(json!(2))));
2872 assert_eq!(
2873 message.context["temp_data"].get("item"),
2874 Some(&dv(json!({"id": "c"}))),
2875 "the element slot is left holding the last element, like the counter"
2876 );
2877 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2878 }
2879
2880 #[tokio::test]
2881 async fn max_still_bounds_an_over_loop() {
2882 let (workflow, engine) = compiled(&format!(
2883 r#"{{ "id": "w", "name": "w",
2884 "loop": {{"counter": "i", "max": 2, "over": {{"var": "data.items"}}, "as": "item"}},
2885 "tasks": [{OVER_BODY}] }}"#
2886 ));
2887 let mut message = items_message();
2888 executor(engine)
2889 .execute(&workflow, &mut message, Utc::now())
2890 .await
2891 .unwrap();
2892 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2893 }
2894
2895 #[tokio::test]
2896 async fn init_offsets_and_increment_strides_the_element_index() {
2897 let (workflow, engine) = compiled(&format!(
2898 r#"{{ "id": "w", "name": "w",
2899 "loop": {{"counter": "i", "init": 1, "increment": 2, "max": 100,
2900 "over": {{"var": "data.items"}}, "as": "item"}},
2901 "tasks": [{OVER_BODY}] }}"#
2902 ));
2903 let mut message = Message::builder()
2904 .data(dv(json!({
2905 "items": [{"id": "a"}, {"id": "b"}, {"id": "c"}, {"id": "d"}, {"id": "e"}, {"id": "f"}]
2906 })))
2907 .build();
2908 executor(engine)
2909 .execute(&workflow, &mut message, Utc::now())
2910 .await
2911 .unwrap();
2912 assert_eq!(counters(&message), vec![Some(1), Some(3), Some(5)]);
2913 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("f"))));
2914 }
2915
2916 #[tokio::test]
2917 async fn an_empty_over_runs_no_iteration_but_the_workflow_still_executed() {
2918 let (workflow, engine) = compiled(&format!(
2919 r#"{{ "id": "w", "name": "w",
2920 "loop": {{"counter": "i", "max": 100, "over": [], "as": "item"}},
2921 "tasks": [{OVER_BODY}] }}"#
2922 ));
2923 let mut message = Message::from_value(&json!({}));
2924 let executed = executor(engine)
2925 .execute(&workflow, &mut message, Utc::now())
2926 .await
2927 .unwrap();
2928 assert!(
2929 executed,
2930 "admitted by its condition, zero elements to visit"
2931 );
2932 assert!(message.audit_trail.is_empty());
2933 assert_eq!(
2934 message.context["temp_data"].get("item"),
2935 None,
2936 "never written"
2937 );
2938 }
2939
2940 #[tokio::test]
2941 async fn a_non_array_over_is_a_workflow_error() {
2942 let make = |continue_on_error: bool| {
2943 compiled(&format!(
2944 r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
2945 "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2946 "tasks": [{OVER_BODY}] }}"#
2947 ))
2948 };
2949 for (items, kind) in [
2950 (json!("nope"), "a string"),
2951 (json!(null), "null"),
2952 (json!({"a": 1}), "an object"),
2953 ] {
2954 let (workflow, engine) = make(false);
2955 let mut message = Message::builder().data(dv(json!({"items": items}))).build();
2956 let err = executor(engine)
2957 .execute(&workflow, &mut message, Utc::now())
2958 .await
2959 .expect_err("not an array");
2960 assert!(
2961 err.to_string().contains("loop.over"),
2962 "names the loop: {err}"
2963 );
2964 assert!(err.to_string().contains(kind), "says what it got: {err}");
2965 assert_eq!(message.errors.len(), 1);
2966 assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2967 assert!(message.audit_trail.is_empty(), "no sweep ran");
2968 }
2969
2970 let (workflow, engine) = make(true);
2973 let mut message = Message::builder().data(dv(json!({"items": 5}))).build();
2974 let executed = executor(engine)
2975 .execute(&workflow, &mut message, Utc::now())
2976 .await
2977 .unwrap();
2978 assert!(executed);
2979 assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2980 assert!(message.audit_trail.is_empty());
2981 }
2982
2983 const SETUP: &str = r#"[{"id": "read", "name": "read", "function": {"name": "map",
2986 "input": {"mappings": [
2987 {"path": "temp_data.batch", "logic": {"var": "data.items"}},
2988 {"path": "data.setup_ran", "logic": true}]}}}]"#;
2989
2990 #[tokio::test]
2991 async fn setup_runs_once_before_the_first_iteration_without_a_loop_counter() {
2992 let (workflow, engine) = compiled(&format!(
2993 r#"{{ "id": "w", "name": "w",
2994 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
2995 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
2996 "tasks": [{OVER_BODY}] }}"#
2997 ));
2998 let mut message = items_message();
2999 executor(engine)
3000 .execute(&workflow, &mut message, Utc::now())
3001 .await
3002 .unwrap();
3003
3004 assert_eq!(
3005 counters(&message),
3006 vec![None, Some(0), Some(1), Some(2)],
3007 "setup is stamped like a non-looping task, then one entry per element"
3008 );
3009 assert_eq!(message.audit_trail[0].task_id.as_ref(), "read");
3010 assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
3011 }
3012
3013 #[tokio::test]
3014 async fn a_false_condition_skips_setup_and_the_loop_alike() {
3015 let (workflow, engine) = compiled(&format!(
3016 r#"{{ "id": "w", "name": "w", "condition": false,
3017 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3018 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3019 "tasks": [{OVER_BODY}] }}"#
3020 ));
3021 let mut message = items_message();
3022 let executed = executor(engine)
3023 .execute(&workflow, &mut message, Utc::now())
3024 .await
3025 .unwrap();
3026 assert!(!executed);
3027 assert!(message.audit_trail.is_empty(), "setup did not run");
3028 assert_eq!(message.context["data"].get("setup_ran"), None);
3029 }
3030
3031 #[tokio::test]
3032 async fn the_condition_is_re_evaluated_per_iteration_after_setup() {
3033 let (workflow, engine) = compiled(&format!(
3034 r#"{{ "id": "w", "name": "w",
3035 "condition": {{"!=": [{{"var": "temp_data.i"}}, 2]}},
3036 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3037 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3038 "tasks": [{OVER_BODY}] }}"#
3039 ));
3040 let mut message = items_message();
3041 let executed = executor(engine)
3044 .execute(&workflow, &mut message, Utc::now())
3045 .await
3046 .unwrap();
3047 assert!(executed);
3048 assert_eq!(counters(&message), vec![None, Some(0), Some(1)]);
3049 }
3050
3051 #[tokio::test]
3052 async fn a_condition_false_on_the_first_iteration_after_setup_still_reports_executed() {
3053 let (workflow, engine) = compiled(&format!(
3054 r#"{{ "id": "w", "name": "w",
3055 "condition": {{"!": [{{"var": "temp_data.batch"}}]}},
3056 "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3057 "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3058 "tasks": [{OVER_BODY}] }}"#
3059 ));
3060 let mut message = items_message();
3061 let executed = executor(engine)
3065 .execute(&workflow, &mut message, Utc::now())
3066 .await
3067 .unwrap();
3068 assert!(executed);
3069 assert_eq!(counters(&message), vec![None]);
3070 }
3071
3072 #[tokio::test]
3073 async fn a_terminal_setup_step_ends_the_workflow_before_any_iteration() {
3074 let (workflow, engine) = compiled(&format!(
3075 r#"{{ "id": "w", "name": "w",
3076 "loop": {{"counter": "i", "max": 100,
3077 "setup": [{{"id": "stop", "name": "stop", "terminal": true,
3078 "function": {{"name": "map", "input": {{"mappings": []}}}}}}],
3079 "over": {{"var": "data.items"}}, "as": "item"}},
3080 "tasks": [{OVER_BODY}] }}"#
3081 ));
3082 let mut message = items_message();
3083 let executed = executor(engine)
3084 .execute(&workflow, &mut message, Utc::now())
3085 .await
3086 .unwrap();
3087 assert!(executed);
3088 assert_eq!(counters(&message), vec![None], "only the setup step ran");
3089 assert!(message.errors.is_empty());
3090 }
3091
3092 #[tokio::test]
3093 async fn a_setup_error_stops_the_message_unless_the_workflow_continues_on_error() {
3094 let make = |continue_on_error: bool| {
3095 compiled(&format!(
3096 r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
3097 "loop": {{"counter": "i", "max": 100,
3098 "setup": [{{"id": "boom", "name": "boom",
3099 "function": {{"name": "map", "input": {{"mappings": [
3100 {{"path": {{"var": "data.root"}}, "unset": true}}]}}}}}}],
3101 "over": {{"var": "data.items"}}, "as": "item"}},
3102 "tasks": [{OVER_BODY}] }}"#
3103 ))
3104 };
3105 let data = || dv(json!({"items": [1], "root": "temp_data"}));
3109
3110 let (workflow, engine) = make(false);
3111 let mut message = Message::builder().data(data()).build();
3112 assert!(
3113 executor(engine)
3114 .execute(&workflow, &mut message, Utc::now())
3115 .await
3116 .is_err()
3117 );
3118 assert_eq!(
3119 counters(&message),
3120 vec![None],
3121 "setup recorded, no iteration"
3122 );
3123
3124 let (workflow, engine) = make(true);
3125 let mut message = Message::builder().data(data()).build();
3126 let executed = executor(engine)
3127 .execute(&workflow, &mut message, Utc::now())
3128 .await
3129 .unwrap();
3130 assert!(executed);
3131 assert_eq!(
3132 counters(&message),
3133 vec![None],
3134 "recorded and ended — no first iteration without setup"
3135 );
3136 assert!(message.errors.iter().any(|e| e.code == "WORKFLOW_ERROR"));
3137 }
3138
3139 const SCRATCH_BODY: &str = r#"
3142 {"id": "flag", "name": "flag", "condition": {"==": [{"var": "temp_data.i"}, 0]},
3143 "function": {"name": "map", "input": {"mappings": [
3144 {"path": "temp_data.it.flag", "logic": true}]}}},
3145 {"id": "read", "name": "read",
3146 "function": {"name": "map", "input": {"mappings": [
3147 {"path": {"cat": ["data.f", {"var": "temp_data.i"}]},
3148 "logic": {"!!": [{"var": "temp_data.it.flag"}]}}]}}}"#;
3149
3150 async fn run_scratch_body(scratch: &str) -> Message {
3151 let (workflow, engine) = compiled(&format!(
3152 r#"{{ "id": "w", "name": "w",
3153 "loop": {{"counter": "i", "max": 3{scratch}}},
3154 "tasks": [{SCRATCH_BODY}] }}"#
3155 ));
3156 let mut message = Message::builder()
3157 .temp_data(dv(json!({"keep": "me"})))
3158 .build();
3159 executor(engine)
3160 .execute(&workflow, &mut message, Utc::now())
3161 .await
3162 .unwrap();
3163 message
3164 }
3165
3166 #[tokio::test]
3167 async fn scratch_is_reset_at_the_start_of_every_iteration() {
3168 let leaky = run_scratch_body("").await;
3169 assert_eq!(
3170 leaky.context["data"].get("f1"),
3171 Some(&dv(json!(true))),
3172 "without scratch the slot leaks into the next iteration"
3173 );
3174
3175 let clean = run_scratch_body(r#", "scratch": "it""#).await;
3176 assert_eq!(clean.context["data"].get("f0"), Some(&dv(json!(true))));
3177 assert_eq!(clean.context["data"].get("f1"), Some(&dv(json!(false))));
3178 assert_eq!(clean.context["data"].get("f2"), Some(&dv(json!(false))));
3179 assert_eq!(
3180 clean.context["temp_data"].get("keep"),
3181 Some(&dv(json!("me"))),
3182 "the reset is narrow"
3183 );
3184 assert_eq!(
3185 clean.context["temp_data"].get("it"),
3186 Some(&dv(json!({}))),
3187 "left holding the last iteration's (empty) scratch"
3188 );
3189 }
3190
3191 #[tokio::test]
3192 async fn an_uncompiled_over_is_an_error_not_a_panic() {
3193 let workflow = Workflow::from_json(&format!(
3195 r#"{{ "id": "w", "name": "w",
3196 "loop": {{"counter": "i", "max": 3, "over": {{"var": "data.items"}}}},
3197 "tasks": [{COUNTER_BODY}] }}"#
3198 ))
3199 .unwrap();
3200 let engine = LogicCompiler::new().into_engine();
3201 let mut message = items_message();
3202 let err = executor(engine)
3203 .execute(&workflow, &mut message, Utc::now())
3204 .await
3205 .expect_err("not compiled");
3206 assert!(err.to_string().contains("loop.over"), "got: {err}");
3207 }
3208
3209 #[tokio::test]
3210 async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
3211 let (workflow, engine) = compiled(
3212 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
3213 "tasks": [
3214 {"id": "gate", "name": "gate", "function": {"name": "filter",
3215 "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
3216 "on_reject": "halt"}}},
3217 {"id": "body", "name": "body", "function": {"name": "map",
3218 "input": {"mappings": [
3219 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3220 );
3221 let mut message = Message::from_value(&json!({}));
3222
3223 executor(engine)
3224 .execute(&workflow, &mut message, Utc::now())
3225 .await
3226 .expect("a halt is not an error");
3227
3228 let ids: Vec<&str> = message
3231 .audit_trail
3232 .iter()
3233 .map(|entry| entry.task_id.as_ref())
3234 .collect();
3235 assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
3236 assert_eq!(
3237 counters(&message),
3238 vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
3239 );
3240 }
3241
3242 #[tokio::test]
3243 async fn init_and_increment_drive_the_counter() {
3244 let (workflow, engine) = compiled(&format!(
3245 r#"{{ "id": "w", "name": "w",
3246 "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
3247 "tasks": [{COUNTER_BODY}] }}"#
3248 ));
3249 let mut message = Message::from_value(&json!({}));
3250
3251 executor(engine)
3252 .execute(&workflow, &mut message, Utc::now())
3253 .await
3254 .expect("loop should complete");
3255
3256 assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
3257 }
3258
3259 #[tokio::test]
3260 async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
3261 let (workflow, engine) = compiled(
3262 r#"{ "id": "w", "name": "w", "loop": {"max": 2},
3263 "tasks": [{"id": "t", "name": "t",
3264 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3265 );
3266 let mut message = Message::from_value(&json!({}));
3267
3268 executor(engine)
3269 .execute(&workflow, &mut message, Utc::now())
3270 .await
3271 .expect("loop should complete");
3272
3273 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3274 assert_eq!(message.context["temp_data"], dv(json!({})));
3276 }
3277
3278 #[tokio::test]
3279 async fn a_non_looping_workflow_records_no_loop_counter() {
3280 let (workflow, engine) = compiled(
3281 r#"{ "id": "w", "name": "w",
3282 "tasks": [{"id": "t", "name": "t",
3283 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3284 );
3285 let mut message = Message::from_value(&json!({}));
3286
3287 executor(engine)
3288 .execute(&workflow, &mut message, Utc::now())
3289 .await
3290 .expect("should complete");
3291
3292 assert_eq!(counters(&message), vec![None]);
3293 }
3294
3295 #[tokio::test]
3296 async fn progress_metadata_is_written_on_every_sweep() {
3297 let (workflow, engine) = compiled(&format!(
3300 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
3301 "tasks": [{COUNTER_BODY}] }}"#
3302 ));
3303 let mut message = Message::from_value(&json!({}));
3304
3305 executor(engine)
3306 .execute(&workflow, &mut message, Utc::now())
3307 .await
3308 .expect("loop should complete");
3309
3310 let progress = message.context["metadata"]
3311 .get("progress")
3312 .expect("progress must be written");
3313 assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
3314 assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
3315 assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
3316 }
3317
3318 #[tokio::test]
3319 async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
3320 let (workflow, engine) = compiled(
3323 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3324 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3325 "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
3326 );
3327 let mut message = Message::from_value(&json!({}));
3328
3329 executor(engine)
3330 .execute(&workflow, &mut message, Utc::now())
3331 .await
3332 .expect("loop should complete");
3333
3334 assert_eq!(
3335 counters(&message),
3336 vec![Some(0), Some(1), Some(2)],
3337 "the body's write must not stall or skew the loop"
3338 );
3339 }
3340
3341 async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
3344 let (workflow, engine) = compiled(&format!(
3345 r#"{{ "id": "w", "name": "w",
3346 "loop": {{"counter": "i", "init": {init},
3347 "increment": {increment}, "max": {max}}},
3348 "tasks": [{{"id": "t", "name": "t",
3349 "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
3350 ));
3351 let mut message = Message::from_value(&json!({}));
3352 executor(engine)
3353 .execute(&workflow, &mut message, Utc::now())
3354 .await
3355 .expect("loop should complete");
3356 counters(&message)
3357 }
3358
3359 #[tokio::test]
3360 async fn counter_sequence_matrix_over_init_increment_and_max() {
3361 let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
3364 (0, 1, 1, vec![0]),
3366 (0, 1, 2, vec![0, 1]),
3367 (0, 1, 5, vec![0, 1, 2, 3, 4]),
3368 (0, 2, 6, vec![0, 2, 4]),
3370 (0, 3, 10, vec![0, 3, 6, 9]),
3371 (0, 5, 3, vec![0]),
3372 (0, 100, 1, vec![0]),
3373 (10, 5, 25, vec![10, 15, 20]),
3375 (3, 1, 6, vec![3, 4, 5]),
3376 (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
3378 (-4, 2, 1, vec![-4, -2, 0]),
3379 (-10, 5, -5, vec![-10]),
3380 ];
3381
3382 for (init, increment, max, expected) in cases {
3383 let got = counter_sequence(init, increment, max).await;
3384 let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
3385 assert_eq!(got, expected, "init={init} increment={increment} max={max}");
3386 }
3387 }
3388
3389 #[tokio::test]
3390 async fn the_counter_advance_saturates_instead_of_overflowing() {
3391 assert_eq!(
3395 counter_sequence(0, i64::MAX, 5).await,
3396 vec![Some(0)],
3397 "one sweep, then the advance saturates past max"
3398 );
3399 assert_eq!(
3400 counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
3401 vec![Some(i64::MAX - 1)],
3402 "the last representable sweep still terminates"
3403 );
3404 assert_eq!(
3405 counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
3406 vec![Some(i64::MAX - 2)]
3407 );
3408 }
3409
3410 #[tokio::test]
3411 async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
3412 let (workflow, engine) = compiled(
3415 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
3416 "tasks": [
3417 {"id": "evens", "name": "evens",
3418 "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
3419 "function": {"name": "map", "input": {"mappings": []}}},
3420 {"id": "always", "name": "always",
3421 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3422 );
3423 let mut message = Message::from_value(&json!({}));
3424
3425 executor(engine)
3426 .execute(&workflow, &mut message, Utc::now())
3427 .await
3428 .expect("loop should complete");
3429
3430 let entries: Vec<(&str, Option<i64>)> = message
3431 .audit_trail
3432 .iter()
3433 .map(|e| (e.task_id.as_ref(), e.loop_counter))
3434 .collect();
3435 assert_eq!(
3436 entries,
3437 [
3438 ("evens", Some(0)),
3439 ("always", Some(0)),
3440 ("always", Some(1)),
3441 ("evens", Some(2)),
3442 ("always", Some(2)),
3443 ("always", Some(3)),
3444 ],
3445 "the gated task runs only on even counters"
3446 );
3447 }
3448
3449 #[tokio::test]
3450 async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
3451 let (workflow, engine) = compiled(
3455 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3456 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
3457 "input": {"condition": false, "on_reject": "skip"}}}] }"#,
3458 );
3459 let mut message = Message::from_value(&json!({}));
3460
3461 let executed = executor(engine)
3462 .execute(&workflow, &mut message, Utc::now())
3463 .await
3464 .expect("skip is not an error");
3465
3466 assert!(executed, "sweeps ran even though every task skipped");
3467 assert!(message.audit_trail.is_empty(), "Skip records no entry");
3468 assert_eq!(
3469 message.context["temp_data"].get("i"),
3470 Some(&dv(json!(3))),
3471 "the loop still ran to its bound"
3472 );
3473 }
3474
3475 #[tokio::test]
3476 async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
3477 let (workflow, engine) = compiled(
3479 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3480 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
3481 "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
3482 );
3483 let mut message = Message::from_value(&json!({}));
3484
3485 executor(engine)
3486 .execute(&workflow, &mut message, Utc::now())
3487 .await
3488 .expect("a 4xx does not stop the workflow");
3489
3490 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
3491 assert!(
3492 message.audit_trail.iter().all(|e| e.status == 400),
3493 "every sweep recorded the 4xx"
3494 );
3495 }
3496
3497 #[tokio::test]
3498 async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
3499 let (workflow, engine) = compiled(
3502 r#"{ "id": "w", "name": "w",
3503 "rollout": {"bucket_start": 0, "bucket_end": 50},
3504 "loop": {"counter": "i", "max": 5},
3505 "tasks": [{"id": "t", "name": "t",
3506 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3507 );
3508 let mut message = Message::builder().routing_bucket(75).build();
3509
3510 let executed = executor(engine)
3511 .execute(&workflow, &mut message, Utc::now())
3512 .await
3513 .expect("an excluded workflow is not an error");
3514
3515 assert!(!executed);
3516 assert!(message.audit_trail.is_empty());
3517 assert_eq!(
3518 message.context["temp_data"].get("i"),
3519 None,
3520 "no counter is written for an excluded workflow"
3521 );
3522 }
3523
3524 #[tokio::test]
3525 async fn a_nested_counter_path_is_created_and_advanced() {
3526 let (workflow, engine) = compiled(
3527 r#"{ "id": "w", "name": "w",
3528 "loop": {"counter": "cursor.index", "max": 3},
3529 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3530 "input": {"mappings": [
3531 {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
3532 );
3533 let mut message = Message::from_value(&json!({}));
3534
3535 executor(engine)
3536 .execute(&workflow, &mut message, Utc::now())
3537 .await
3538 .expect("loop should complete");
3539
3540 assert_eq!(
3541 message.context["temp_data"]["cursor"].get("index"),
3542 Some(&dv(json!(3)))
3543 );
3544 assert_eq!(
3545 message.context["data"].get("seen"),
3546 Some(&dv(json!(2))),
3547 "the body read the nested counter"
3548 );
3549 }
3550
3551 #[tokio::test]
3552 async fn writing_the_counter_preserves_unrelated_temp_data() {
3553 let (workflow, engine) = compiled(
3554 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3555 "tasks": [{"id": "t", "name": "t",
3556 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3557 );
3558 let mut message = Message::builder()
3559 .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
3560 .build();
3561
3562 executor(engine)
3563 .execute(&workflow, &mut message, Utc::now())
3564 .await
3565 .expect("loop should complete");
3566
3567 assert_eq!(
3568 message.context["temp_data"].get("keep"),
3569 Some(&dv(json!("me")))
3570 );
3571 assert_eq!(
3572 message.context["temp_data"]["nested"].get("a"),
3573 Some(&dv(json!(1)))
3574 );
3575 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
3576 }
3577
3578 #[tokio::test]
3579 async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
3580 let (workflow, engine) = compiled(
3583 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
3584 "tasks": [{"id": "t", "name": "t",
3585 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3586 );
3587 let mut message = Message::builder()
3588 .temp_data(dv(json!({"i": "not a number"})))
3589 .build();
3590
3591 executor(engine)
3592 .execute(&workflow, &mut message, Utc::now())
3593 .await
3594 .expect("loop should complete");
3595
3596 assert_eq!(counters(&message), vec![Some(5), Some(6)]);
3597 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
3598 }
3599
3600 #[tokio::test]
3601 async fn a_loop_records_audit_entries_with_capture_changes_off() {
3602 let (workflow, engine) = compiled(
3605 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3606 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3607 "input": {"mappings": [
3608 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3609 );
3610 let mut message = Message::builder().capture_changes(false).build();
3611
3612 executor(engine)
3613 .execute(&workflow, &mut message, Utc::now())
3614 .await
3615 .expect("loop should complete");
3616
3617 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3618 assert!(
3619 message.audit_trail.iter().all(|e| e.changes.is_empty()),
3620 "no diffs captured, but the entries are still there"
3621 );
3622 }
3623
3624 #[tokio::test]
3625 async fn two_loops_sharing_a_counter_name_do_not_interfere() {
3626 let first = r#"{ "id": "a", "name": "a", "priority": 0,
3629 "loop": {"counter": "i", "max": 2},
3630 "tasks": [{"id": "t", "name": "t",
3631 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3632 let second = r#"{ "id": "b", "name": "b", "priority": 1,
3633 "loop": {"counter": "i", "init": 10, "max": 12},
3634 "tasks": [{"id": "t", "name": "t",
3635 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3636
3637 let compiler = LogicCompiler::new();
3638 let workflows = compiler
3639 .compile_workflows(vec![
3640 Workflow::from_json(first).unwrap(),
3641 Workflow::from_json(second).unwrap(),
3642 ])
3643 .expect("should compile");
3644 let exec = executor(compiler.into_engine());
3645 let mut message = Message::from_value(&json!({}));
3646
3647 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3648 .await
3649 .expect("both loops should complete");
3650
3651 let per_workflow: Vec<(&str, Option<i64>)> = message
3652 .audit_trail
3653 .iter()
3654 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3655 .collect();
3656 assert_eq!(
3657 per_workflow,
3658 [
3659 ("a", Some(0)),
3660 ("a", Some(1)),
3661 ("b", Some(10)),
3662 ("b", Some(11)),
3663 ]
3664 );
3665 }
3666
3667 #[tokio::test]
3668 async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
3669 let sync_wf = |id: &str, priority: u32| {
3673 format!(
3674 r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
3675 "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
3676 "input": {{"mappings": [
3677 {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
3678 )
3679 };
3680 let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
3681 "loop": {"counter": "i", "max": 2},
3682 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3683 "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
3684
3685 let compiler = LogicCompiler::new();
3686 let workflows = compiler
3687 .compile_workflows(vec![
3688 Workflow::from_json(&sync_wf("before", 0)).unwrap(),
3689 Workflow::from_json(loop_wf).unwrap(),
3690 Workflow::from_json(&sync_wf("after", 2)).unwrap(),
3691 ])
3692 .expect("should compile");
3693 assert!(workflows.iter().all(|w| w.fully_sync));
3695 assert!(!joins_sync_run(&workflows[1]));
3696
3697 let exec = executor(compiler.into_engine());
3698 let mut message = Message::from_value(&json!({}));
3699
3700 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3701 .await
3702 .expect("all three should run");
3703
3704 for id in ["before", "mid", "after"] {
3705 assert_eq!(
3706 message.context["data"].get(id),
3707 Some(&dv(json!(true))),
3708 "workflow {id} must have run"
3709 );
3710 }
3711 let order: Vec<(&str, Option<i64>)> = message
3712 .audit_trail
3713 .iter()
3714 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3715 .collect();
3716 assert_eq!(
3717 order,
3718 [
3719 ("before", None),
3720 ("mid", Some(0)),
3721 ("mid", Some(1)),
3722 ("after", None),
3723 ],
3724 "priority order is preserved across the split"
3725 );
3726 }
3727
3728 #[tokio::test]
3729 async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
3730 let compiler = LogicCompiler::new();
3733 let workflows = compiler
3734 .compile_workflows(vec![
3735 Workflow::from_json(
3736 r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
3737 "function": {"name": "map", "input": {"mappings": [
3738 {"path": "data.a", "logic": 1}]}}}] }"#,
3739 )
3740 .unwrap(),
3741 Workflow::from_json(
3742 r#"{ "id": "b", "name": "b", "priority": 1,
3743 "condition": {"==": [{"var": "data.a"}, 1]},
3744 "tasks": [{"id": "t", "name": "t",
3745 "function": {"name": "map", "input": {"mappings": [
3746 {"path": "data.b", "logic": 2}]}}}] }"#,
3747 )
3748 .unwrap(),
3749 ])
3750 .expect("should compile");
3751 assert!(workflows.iter().all(joins_sync_run));
3752
3753 let exec = executor(compiler.into_engine());
3754 let mut message = Message::from_value(&json!({}));
3755 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3756 .await
3757 .expect("both should run");
3758
3759 assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
3762 assert_eq!(counters(&message), vec![None, None]);
3763 }
3764
3765 #[tokio::test]
3766 async fn a_loop_body_can_index_an_array_by_its_counter() {
3767 let (workflow, engine) = compiled(
3769 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3770 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
3771 "input": {"mappings": [
3772 {"path": "data.picked",
3773 "logic": {"merge": [{"var": "data.picked"},
3774 [{"val": [["data", "items",
3775 {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
3776 );
3777 let mut message = Message::builder()
3778 .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
3779 .build();
3780
3781 executor(engine)
3782 .execute(&workflow, &mut message, Utc::now())
3783 .await
3784 .expect("loop should complete");
3785
3786 assert_eq!(
3787 serde_json::Value::from(&message.context["data"]["picked"]),
3788 json!(["a", "b", "c"]),
3789 "each sweep appended the item at its own index"
3790 );
3791 }
3792
3793 #[tokio::test]
3794 async fn test_workflow_executor_skip_condition() {
3795 let workflow_json = r#"{
3797 "id": "test_workflow",
3798 "name": "Test Workflow",
3799 "condition": false,
3800 "tasks": [{
3801 "id": "dummy_task",
3802 "name": "Dummy Task",
3803 "function": {
3804 "name": "map",
3805 "input": {"mappings": []}
3806 }
3807 }]
3808 }"#;
3809
3810 let compiler = LogicCompiler::new();
3811 let mut workflow = Workflow::from_json(workflow_json).unwrap();
3812
3813 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3815 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3816 workflow = compiled_workflow.clone();
3817 }
3818
3819 let engine = compiler.into_engine();
3820 let task_executor = Arc::new(TaskExecutor::new(
3821 Arc::new(HashMap::new()),
3822 Arc::clone(&engine),
3823 ));
3824 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3825
3826 let mut message = Message::from_value(&json!({}));
3827
3828 let executed = workflow_executor
3830 .execute(&workflow, &mut message, Utc::now())
3831 .await
3832 .unwrap();
3833 assert!(!executed);
3834 assert_eq!(message.audit_trail.len(), 0);
3835 }
3836
3837 #[tokio::test]
3838 async fn test_workflow_executor_execute_success() {
3839 let workflow_json = r#"{
3841 "id": "test_workflow",
3842 "name": "Test Workflow",
3843 "condition": true,
3844 "tasks": [{
3845 "id": "dummy_task",
3846 "name": "Dummy Task",
3847 "function": {
3848 "name": "map",
3849 "input": {"mappings": []}
3850 }
3851 }]
3852 }"#;
3853
3854 let compiler = LogicCompiler::new();
3855 let mut workflow = Workflow::from_json(workflow_json).unwrap();
3856
3857 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3859 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3860 workflow = compiled_workflow.clone();
3861 }
3862
3863 let engine = compiler.into_engine();
3864 let task_executor = Arc::new(TaskExecutor::new(
3865 Arc::new(HashMap::new()),
3866 Arc::clone(&engine),
3867 ));
3868 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3869
3870 let mut message = Message::from_value(&json!({}));
3871
3872 let executed = workflow_executor
3874 .execute(&workflow, &mut message, Utc::now())
3875 .await
3876 .unwrap();
3877 assert!(executed);
3878 }
3879}