1use crate::engine::error::{DataflowError, ErrorInfo, Result, service_error_code};
7use crate::engine::executor::{
8 ArenaContext, evaluate_condition, evaluate_condition_in_arena, with_arena,
9};
10use crate::engine::functions::BoxedFunctionHandler;
11use crate::engine::message::{AuditTrail, Change, Message};
12use crate::engine::observer::{ExecutionObserver, TaskEvent};
13use crate::engine::task::Task;
14use crate::engine::task_executor::TaskExecutor;
15use crate::engine::task_outcome::TaskOutcome;
16use crate::engine::trace::{ExecutionStep, ExecutionTrace, StepTiming, duration_us_between};
17use crate::engine::utils::{compute_path_parts, set_nested_value, set_nested_value_parts};
18use crate::engine::workflow::{LoopConfig, Workflow};
19use chrono::{DateTime, Utc};
20use core::time::Duration;
21use datalogic_rs::Engine;
22use datavalue::OwnedDataValue;
23use log::{debug, error, info, warn};
24use serde_json::Value;
25use std::collections::HashMap;
26use std::sync::Arc;
27
28enum TaskControlFlow {
30 Continue,
32 HaltWorkflow,
34}
35
36#[derive(Clone, Copy)]
42struct PassCtx {
43 now: DateTime<Utc>,
46 loop_counter: Option<i64>,
49}
50
51impl PassCtx {
52 #[inline]
54 fn once(now: DateTime<Utc>) -> Self {
55 Self {
56 now,
57 loop_counter: None,
58 }
59 }
60}
61
62enum PassOutcome {
64 ConditionFalse,
66 Completed,
68 Halted,
70}
71
72fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
76 let mut i = start;
77 while i < tasks.len() && tasks[i].function.is_sync_builtin() {
78 i += 1;
79 }
80 i
81}
82
83fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
88 debug!("Skipping workflow {} - {}", workflow_id, reason);
89 if let Some(t) = trace {
90 t.add_step(ExecutionStep::workflow_skipped(workflow_id));
91 }
92}
93
94fn note_task_skip(
101 trace: Option<&mut ExecutionTrace>,
102 workflow_id: &str,
103 task_id: &str,
104 loop_counter: Option<i64>,
105) {
106 debug!("Skipping task {} - condition not met", task_id);
107 if let Some(t) = trace {
108 t.add_step(
109 ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
110 );
111 }
112}
113
114fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
124 match workflow.rollout {
125 None => true,
126 Some(r) => match message.routing_bucket() {
127 None => true,
128 Some(b) => r.accepts(b),
129 },
130 }
131}
132
133fn joins_sync_run(workflow: &Workflow) -> bool {
141 workflow.fully_sync && workflow.loop_config.is_none()
142}
143
144fn resolve_counter_parts(config: &LoopConfig) -> Arc<[Arc<str>]> {
155 match &config.counter {
156 Some(counter) if config.counter_parts.is_empty() => {
157 compute_path_parts("temp_data", counter)
158 }
159 _ => Arc::clone(&config.counter_parts),
160 }
161}
162
163fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
165 OwnedDataValue::Object(vec![
166 (
167 "workflow_id".to_string(),
168 OwnedDataValue::String(workflow_id.to_string()),
169 ),
170 (
171 "task_id".to_string(),
172 OwnedDataValue::String(task_id.to_string()),
173 ),
174 (
175 "status_code".to_string(),
176 OwnedDataValue::from(u64::from(status)),
177 ),
178 ])
179}
180
181fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
188 match slot {
189 OwnedDataValue::String(existing) => {
190 if existing != value {
191 existing.clear();
192 existing.push_str(value);
193 }
194 }
195 _ => *slot = OwnedDataValue::String(value.to_string()),
196 }
197}
198
199fn overwrite_progress_in_place(
205 fields: &mut [(String, OwnedDataValue)],
206 workflow_id: &str,
207 task_id: &str,
208 status: u16,
209) -> bool {
210 if fields.len() != 3 {
211 return false;
212 }
213 let mut matched = 0;
214 for (k, v) in fields.iter_mut() {
215 match k.as_str() {
216 "workflow_id" => {
217 overwrite_str_in_place(v, workflow_id);
218 matched += 1;
219 }
220 "task_id" => {
221 overwrite_str_in_place(v, task_id);
222 matched += 1;
223 }
224 "status_code" => {
225 *v = OwnedDataValue::from(u64::from(status));
226 matched += 1;
227 }
228 _ => {}
229 }
230 }
231 matched == 3
232}
233
234fn write_progress_metadata(
243 context: &mut OwnedDataValue,
244 workflow_id: &str,
245 task_id: &str,
246 status: u16,
247) {
248 if let OwnedDataValue::Object(top) = context {
251 if let Some((_, OwnedDataValue::Object(meta))) =
252 top.iter_mut().find(|(k, _)| k == "metadata")
253 {
254 match meta.iter_mut().find(|(k, _)| k == "progress") {
255 Some((_, slot)) => {
256 if let OwnedDataValue::Object(fields) = slot {
257 if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
258 return;
259 }
260 }
261 *slot = new_progress_object(workflow_id, task_id, status);
262 }
263 None => {
264 meta.push((
265 "progress".to_string(),
266 new_progress_object(workflow_id, task_id, status),
267 ));
268 }
269 }
270 return;
271 }
272 }
273 set_nested_value(
274 context,
275 "metadata.progress",
276 new_progress_object(workflow_id, task_id, status),
277 );
278}
279
280pub struct WorkflowExecutor {
288 task_executor: Arc<TaskExecutor>,
290 engine: Arc<Engine>,
292 observer: Option<Arc<dyn ExecutionObserver>>,
295}
296
297impl WorkflowExecutor {
298 pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
300 Self {
301 task_executor,
302 engine,
303 observer: None,
304 }
305 }
306
307 pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
309 self.observer = Some(observer);
310 self
311 }
312
313 pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
318 self.observer.as_ref()
319 }
320
321 #[inline]
327 fn emit_task_event(
328 &self,
329 workflow: &Workflow,
330 task: &Task,
331 result: &Result<(TaskOutcome, Vec<Change>)>,
332 started_at: Option<DateTime<Utc>>,
333 ) {
334 if let Some(observer) = self.observer.as_ref() {
335 let status = match result {
336 Ok((outcome, _)) => outcome.audit_status(),
337 Err(_) => Some(500),
338 };
339 let duration = started_at
340 .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
341 .unwrap_or_default();
342 observer.task_finished(&TaskEvent {
343 workflow_id: &workflow.id,
344 task_id: &task.id,
345 function: task.function.function_name(),
346 status,
347 duration,
348 });
349 }
350 }
351
352 #[inline]
357 fn observer_clock(&self) -> Option<DateTime<Utc>> {
358 self.observer.as_ref().map(|_| Utc::now())
359 }
360
361 pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
363 self.task_executor.task_functions()
364 }
365
366 pub async fn execute(
381 &self,
382 workflow: &Workflow,
383 message: &mut Message,
384 now: DateTime<Utc>,
385 ) -> Result<bool> {
386 self.execute_inner(workflow, message, None, now).await
387 }
388
389 pub async fn execute_with_trace(
393 &self,
394 workflow: &Workflow,
395 message: &mut Message,
396 trace: &mut ExecutionTrace,
397 now: DateTime<Utc>,
398 ) -> Result<bool> {
399 self.execute_inner(workflow, message, Some(trace), now)
400 .await
401 }
402
403 async fn execute_inner(
410 &self,
411 workflow: &Workflow,
412 message: &mut Message,
413 mut trace: Option<&mut ExecutionTrace>,
414 now: DateTime<Utc>,
415 ) -> Result<bool> {
416 if !rollout_admits(workflow, message) {
421 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
422 return Ok(false);
423 }
424
425 if let Some(loop_config) = workflow.loop_config.as_ref() {
426 return self
427 .execute_loop(workflow, loop_config, message, trace, now)
428 .await;
429 }
430
431 match self
432 .execute_pass(workflow, message, trace.as_deref_mut(), PassCtx::once(now))
433 .await
434 {
435 Ok(PassOutcome::ConditionFalse) => {
436 note_workflow_skip(trace, &workflow.id, "condition not met");
438 Ok(false)
439 }
440 Ok(_) => {
441 info!("Successfully completed workflow: {}", workflow.id);
442 Ok(true)
443 }
444 Err(e) => {
445 if self.record_workflow_error(workflow, message, &e) {
451 Err(e)
452 } else {
453 Ok(true)
454 }
455 }
456 }
457 }
458
459 async fn execute_loop(
470 &self,
471 workflow: &Workflow,
472 config: &LoopConfig,
473 message: &mut Message,
474 mut trace: Option<&mut ExecutionTrace>,
475 now: DateTime<Utc>,
476 ) -> Result<bool> {
477 let mut counter = config.init;
478 let mut sweeps_run: u32 = 0;
479 let counter_parts = resolve_counter_parts(config);
480
481 loop {
482 set_nested_value_parts(
487 &mut message.context,
488 &counter_parts,
489 OwnedDataValue::from_i64(counter),
490 );
491
492 if counter >= config.max {
499 if workflow.compiled_condition.is_some() {
504 warn!(
505 "Workflow {} stopped at its loop bound (max {}) with the condition \
506 still true after {} sweep(s)",
507 workflow.id, config.max, sweeps_run
508 );
509 }
510 break;
511 }
512
513 let pass = PassCtx {
514 now,
515 loop_counter: Some(counter),
516 };
517
518 match self
519 .execute_pass(workflow, message, trace.as_deref_mut(), pass)
520 .await
521 {
522 Ok(PassOutcome::ConditionFalse) => {
523 if sweeps_run == 0 {
524 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
527 } else {
528 debug!(
529 "Workflow {} loop exited at counter {} - condition no longer met",
530 workflow.id, counter
531 );
532 }
533 break;
534 }
535 Ok(PassOutcome::Halted) => {
536 sweeps_run += 1;
537 debug!(
538 "Workflow {} loop halted at counter {}",
539 workflow.id, counter
540 );
541 break;
542 }
543 Ok(PassOutcome::Completed) => {
544 sweeps_run += 1;
545 }
546 Err(e) => {
547 sweeps_run += 1;
548 if self.record_workflow_error(workflow, message, &e) {
553 return Err(e);
554 }
555 }
556 }
557
558 counter = counter.saturating_add(config.increment);
559 }
560
561 if sweeps_run > 0 {
562 info!(
563 "Successfully completed workflow: {} ({} loop sweep(s))",
564 workflow.id, sweeps_run
565 );
566 }
567 Ok(sweeps_run > 0)
568 }
569
570 async fn execute_pass(
584 &self,
585 workflow: &Workflow,
586 message: &mut Message,
587 mut trace: Option<&mut ExecutionTrace>,
588 pass: PassCtx,
589 ) -> Result<PassOutcome> {
590 enum FirstStretch {
592 Skipped,
594 Halted,
596 Continue,
599 }
600
601 let tasks = &workflow.tasks;
602 let first_boundary = next_async_boundary(tasks, 0);
603
604 let first: Result<FirstStretch> =
605 if workflow.compiled_condition.is_none() && first_boundary == 0 {
606 Ok(FirstStretch::Continue)
609 } else {
610 with_arena(|arena| -> Result<FirstStretch> {
611 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
612
613 let should_execute = match workflow.compiled_condition.as_ref() {
614 None => true,
615 Some(compiled) => evaluate_condition_in_arena(
616 &self.engine,
617 Some(compiled),
618 arena_ctx.as_data_value(),
619 arena,
620 )?,
621 };
622 if !should_execute {
623 return Ok(FirstStretch::Skipped);
624 }
625 if first_boundary == 0 {
626 return Ok(FirstStretch::Continue);
627 }
628 let halted = self.run_tasks_slice_in_arena(
629 &tasks[..first_boundary],
630 workflow,
631 message,
632 &mut arena_ctx,
633 trace.as_deref_mut(),
634 pass,
635 )?;
636 Ok(if halted {
637 FirstStretch::Halted
638 } else {
639 FirstStretch::Continue
640 })
641 })
642 };
643
644 match first? {
648 FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
649 FirstStretch::Halted => Ok(PassOutcome::Halted),
650 FirstStretch::Continue => {
651 let halted = self
652 .execute_tasks(workflow, message, trace, pass, first_boundary)
653 .await?;
654 Ok(if halted {
655 PassOutcome::Halted
656 } else {
657 PassOutcome::Completed
658 })
659 }
660 }
661 }
662
663 fn record_workflow_error(
672 &self,
673 workflow: &Workflow,
674 message: &mut Message,
675 e: &DataflowError,
676 ) -> bool {
677 message.errors.push(
678 ErrorInfo::builder(
679 "WORKFLOW_ERROR",
680 format!("Workflow {} error: {}", workflow.id, e),
681 )
682 .workflow_id(&workflow.id)
683 .build(),
684 );
685
686 if workflow.continue_on_error {
687 warn!(
688 "Workflow {} encountered error but continuing: {:?}",
689 workflow.id, e
690 );
691 false
692 } else {
693 error!("Workflow {} failed: {:?}", workflow.id, e);
694 true
695 }
696 }
697
698 async fn execute_tasks(
718 &self,
719 workflow: &Workflow,
720 message: &mut Message,
721 mut trace: Option<&mut ExecutionTrace>,
722 pass: PassCtx,
723 start: usize,
724 ) -> Result<bool> {
725 let tasks = &workflow.tasks;
726 let mut idx = start;
727 while idx < tasks.len() {
728 let stretch_end = next_async_boundary(tasks, idx);
729
730 if stretch_end > idx {
731 let halt = self.run_sync_stretch(
733 &tasks[idx..stretch_end],
734 workflow,
735 message,
736 trace.as_deref_mut(),
737 pass,
738 )?;
739 if halt {
740 return Ok(true);
741 }
742 idx = stretch_end;
743 }
744
745 if idx < tasks.len() {
746 let task = &tasks[idx];
748 let should_execute = evaluate_condition(
749 &self.engine,
750 task.compiled_condition.as_ref(),
751 &message.context,
752 )?;
753
754 if !should_execute {
755 note_task_skip(
756 trace.as_deref_mut(),
757 &workflow.id,
758 &task.id,
759 pass.loop_counter,
760 );
761 idx += 1;
762 continue;
763 }
764
765 let trace_start = if trace.is_some() {
769 Some(Utc::now())
770 } else {
771 None
772 };
773 let obs_start = trace_start.or_else(|| self.observer_clock());
774
775 let result = self.task_executor.execute(task, message).await;
776
777 self.emit_task_event(workflow, task, &result, obs_start);
779
780 let control_flow = self.handle_task_result(
781 result,
782 &workflow.id_arc,
783 &task.id_arc,
784 task.continue_on_error,
785 message,
786 pass,
787 )?;
788
789 if let Some(t) = trace.as_deref_mut() {
792 let started_at = trace_start.unwrap_or(pass.now);
793 t.add_executed_step(
794 &workflow.id,
795 &task.id,
796 message,
797 StepTiming {
798 started_at,
799 duration_us: duration_us_between(started_at, Utc::now()),
800 },
801 None,
802 pass.loop_counter,
803 );
804 }
805
806 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
807 return Ok(true);
808 }
809 idx += 1;
810 }
811 }
812
813 Ok(false)
814 }
815
816 fn run_sync_stretch(
826 &self,
827 tasks: &[Task],
828 workflow: &Workflow,
829 message: &mut Message,
830 trace: Option<&mut ExecutionTrace>,
831 pass: PassCtx,
832 ) -> Result<bool> {
833 with_arena(|arena| -> Result<bool> {
834 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
835 self.run_tasks_slice_in_arena(tasks, workflow, message, &mut arena_ctx, trace, pass)
836 })
837 }
838
839 fn run_tasks_slice_in_arena<'arena>(
849 &self,
850 tasks: &'arena [Task],
851 workflow: &Workflow,
852 message: &mut Message,
853 arena_ctx: &mut ArenaContext<'arena>,
854 mut trace: Option<&mut ExecutionTrace>,
855 pass: PassCtx,
856 ) -> Result<bool> {
857 let arena = arena_ctx.arena();
858
859 for task in tasks {
860 let should_execute = match task.compiled_condition.as_ref() {
866 None => true,
867 Some(compiled) => evaluate_condition_in_arena(
868 &self.engine,
869 Some(compiled),
870 arena_ctx.as_data_value(),
871 arena,
872 )?,
873 };
874
875 if !should_execute {
876 note_task_skip(
877 trace.as_deref_mut(),
878 &workflow.id,
879 &task.id,
880 pass.loop_counter,
881 );
882 continue;
883 }
884
885 let mut mapping_snapshots: Vec<Value> = Vec::new();
889 let want_mapping_contexts = trace
890 .as_deref()
891 .is_some_and(|t| t.options().mapping_contexts);
892 let mapping_snapshots_buf = if want_mapping_contexts {
893 Some(&mut mapping_snapshots)
894 } else {
895 None
896 };
897
898 let trace_start = if trace.is_some() {
902 Some(Utc::now())
903 } else {
904 None
905 };
906 let obs_start = trace_start.or_else(|| self.observer_clock());
907
908 let result =
909 self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
910
911 self.emit_task_event(workflow, task, &result, obs_start);
913
914 let control_flow = self.handle_task_result(
915 result,
916 &workflow.id_arc,
917 &task.id_arc,
918 task.continue_on_error,
919 message,
920 pass,
921 )?;
922
923 arena_ctx.refresh_for_path(&message.context, "metadata.progress");
930
931 if let Some(t) = trace.as_deref_mut() {
932 let started_at = trace_start.unwrap_or(pass.now);
933 let mapping_contexts = if mapping_snapshots.is_empty() {
934 None
935 } else {
936 Some(mapping_snapshots)
937 };
938 t.add_executed_step(
939 &workflow.id,
940 &task.id,
941 message,
942 StepTiming {
943 started_at,
944 duration_us: duration_us_between(started_at, Utc::now()),
945 },
946 mapping_contexts,
947 pass.loop_counter,
948 );
949 }
950
951 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
952 return Ok(true);
953 }
954 }
955 Ok(false)
956 }
957
958 pub async fn run_all(
970 &self,
971 workflows: &[&Workflow],
972 message: &mut Message,
973 trace: Option<&mut ExecutionTrace>,
974 now: DateTime<Utc>,
975 ) -> Result<()> {
976 self.run_all_borrowed(workflows, message, trace, now).await
977 }
978
979 pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
984 &self,
985 workflows: &[W],
986 message: &mut Message,
987 mut trace: Option<&mut ExecutionTrace>,
988 now: DateTime<Utc>,
989 ) -> Result<()> {
990 let mut i = 0;
991 while i < workflows.len() {
992 if joins_sync_run(workflows[i].borrow()) {
993 let mut j = i + 1;
996 while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
997 j += 1;
998 }
999 self.execute_sync_workflow_run(
1000 &workflows[i..j],
1001 message,
1002 trace.as_deref_mut(),
1003 now,
1004 )?;
1005 i = j;
1006 } else {
1007 self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
1010 .await?;
1011 i += 1;
1012 }
1013 }
1014 Ok(())
1015 }
1016
1017 fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
1035 &self,
1036 workflows: &[W],
1037 message: &mut Message,
1038 mut trace: Option<&mut ExecutionTrace>,
1039 now: DateTime<Utc>,
1040 ) -> Result<()> {
1041 debug_assert!(
1044 workflows.iter().all(|w| joins_sync_run(w.borrow())),
1045 "only non-looping fully-sync workflows may join a shared-arena run"
1046 );
1047 let pass = PassCtx::once(now);
1048
1049 with_arena(|arena| -> Result<()> {
1050 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1051
1052 for workflow in workflows {
1053 let workflow: &Workflow = workflow.borrow();
1054
1055 if !rollout_admits(workflow, message) {
1061 note_workflow_skip(
1062 trace.as_deref_mut(),
1063 &workflow.id,
1064 "outside rollout bucket",
1065 );
1066 continue;
1067 }
1068
1069 let should_execute = match workflow.compiled_condition.as_ref() {
1073 None => true,
1074 Some(compiled) => evaluate_condition_in_arena(
1075 &self.engine,
1076 Some(compiled),
1077 arena_ctx.as_data_value(),
1078 arena,
1079 )?,
1080 };
1081
1082 if !should_execute {
1083 note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1084 continue;
1085 }
1086
1087 match self.run_tasks_slice_in_arena(
1088 &workflow.tasks,
1089 workflow,
1090 message,
1091 &mut arena_ctx,
1092 trace.as_deref_mut(),
1093 pass,
1094 ) {
1095 Ok(_halted) => {
1098 info!("Successfully completed workflow: {}", workflow.id);
1099 }
1100 Err(e) => {
1101 if self.record_workflow_error(workflow, message, &e) {
1103 return Err(e);
1104 }
1105 }
1106 }
1107 }
1108 Ok(())
1109 })
1110 }
1111
1112 fn execute_sync_task_in_arena<'arena>(
1120 &self,
1121 task: &'arena Task,
1122 message: &mut Message,
1123 arena_ctx: &mut ArenaContext<'arena>,
1124 mapping_snapshots: Option<&mut Vec<Value>>,
1125 ) -> Result<(TaskOutcome, Vec<Change>)> {
1126 debug!(
1127 "Executing sync task in arena: {} ({})",
1128 task.id,
1129 task.function.function_name()
1130 );
1131 debug_assert!(
1132 task.function.is_sync_builtin(),
1133 "execute_sync_task_in_arena called with non-sync-builtin task: {}",
1134 task.function.function_name()
1135 );
1136 task.function
1140 .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
1141 .ok_or_else(|| {
1142 DataflowError::Task(format!(
1143 "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
1144 (engine bug — sync-stretch should only contain sync-builtin tasks)",
1145 task.function.function_name()
1146 ))
1147 })?
1148 }
1149
1150 fn handle_task_result(
1156 &self,
1157 result: Result<(TaskOutcome, Vec<Change>)>,
1158 workflow_id_arc: &Arc<str>,
1159 task_id_arc: &Arc<str>,
1160 continue_on_error: bool,
1161 message: &mut Message,
1162 pass: PassCtx,
1163 ) -> Result<TaskControlFlow> {
1164 let workflow_id: &str = workflow_id_arc;
1165 let task_id: &str = task_id_arc;
1166 match result {
1167 Ok((TaskOutcome::Skip, _)) => {
1168 debug!("Task {} signaled skip", task_id);
1171 Ok(TaskControlFlow::Continue)
1172 }
1173 Ok((outcome, changes)) => {
1174 let status = outcome
1178 .audit_status()
1179 .expect("Skip handled above; remaining variants emit audit status");
1180 let halt = outcome.halts_workflow();
1181
1182 message.audit_trail.push(AuditTrail {
1187 timestamp: pass.now,
1188 workflow_id: Arc::clone(workflow_id_arc),
1189 task_id: Arc::clone(task_id_arc),
1190 status: status as usize,
1191 changes,
1192 loop_counter: pass.loop_counter,
1193 });
1194
1195 write_progress_metadata(&mut message.context, workflow_id, task_id, status);
1206
1207 if halt {
1208 info!("Task {} halted workflow {}", task_id, workflow_id);
1209 return Ok(TaskControlFlow::HaltWorkflow);
1210 }
1211
1212 if (400..500).contains(&status) {
1214 warn!("Task {} returned client error status: {}", task_id, status);
1215 } else if status >= 500 {
1216 error!("Task {} returned server error status: {}", task_id, status);
1217 message.errors.push(
1222 ErrorInfo::builder(
1223 "TASK_STATUS_ERROR",
1224 format!("Task {} returned status {}", task_id, status),
1225 )
1226 .workflow_id(workflow_id)
1227 .task_id(task_id)
1228 .build(),
1229 );
1230 if !continue_on_error {
1231 return Err(DataflowError::Task(format!(
1232 "Task {} failed with status {}",
1233 task_id, status
1234 )));
1235 }
1236 }
1237 Ok(TaskControlFlow::Continue)
1238 }
1239 Err(e) => {
1240 error!("Task {} failed: {:?}", task_id, e);
1241
1242 message.audit_trail.push(AuditTrail {
1244 timestamp: pass.now,
1245 workflow_id: Arc::clone(workflow_id_arc),
1246 task_id: Arc::clone(task_id_arc),
1247 status: 500,
1248 changes: vec![],
1249 loop_counter: pass.loop_counter,
1250 });
1251
1252 write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
1256
1257 let mut info = ErrorInfo::builder(
1270 service_error_code(&e),
1271 format!("Task {} error: {}", task_id, e),
1272 )
1273 .workflow_id(workflow_id)
1274 .task_id(task_id);
1275 if let Some(detail) = e.detail() {
1277 info = info.detail(detail);
1278 }
1279 message.errors.push(info.build());
1280
1281 if !continue_on_error {
1282 Err(e)
1283 } else {
1284 Ok(TaskControlFlow::Continue)
1285 }
1286 }
1287 }
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use crate::engine::compiler::LogicCompiler;
1295 use serde_json::json;
1296 use std::collections::HashMap;
1297
1298 fn dv(v: serde_json::Value) -> OwnedDataValue {
1300 OwnedDataValue::from(&v)
1301 }
1302
1303 fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
1305 let compiler = LogicCompiler::new();
1306 let workflow = Workflow::from_json(json).expect("workflow should parse");
1307 let compiled = compiler
1308 .compile_workflows(vec![workflow])
1309 .expect("workflow should compile");
1310 (
1311 compiled.into_iter().next().expect("one workflow"),
1312 compiler.into_engine(),
1313 )
1314 }
1315
1316 fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
1318 let task_executor = Arc::new(TaskExecutor::new(
1319 Arc::new(HashMap::new()),
1320 Arc::clone(&engine),
1321 ));
1322 WorkflowExecutor::new(task_executor, engine)
1323 }
1324
1325 fn counters(message: &Message) -> Vec<Option<i64>> {
1327 message
1328 .audit_trail
1329 .iter()
1330 .map(|entry| entry.loop_counter)
1331 .collect()
1332 }
1333
1334 const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
1336 "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
1337
1338 #[tokio::test]
1339 async fn loop_without_a_condition_runs_exactly_max_sweeps() {
1340 let (workflow, engine) = compiled(&format!(
1341 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
1342 "tasks": [{COUNTER_BODY}] }}"#
1343 ));
1344 let mut message = Message::from_value(&json!({}));
1345
1346 let executed = executor(engine)
1347 .execute(&workflow, &mut message, Utc::now())
1348 .await
1349 .expect("loop should complete");
1350
1351 assert!(executed);
1352 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
1354 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
1356 assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
1358 }
1359
1360 #[tokio::test]
1361 async fn loop_exits_early_when_the_condition_goes_false() {
1362 let (workflow, engine) = compiled(&format!(
1364 r#"{{ "id": "w", "name": "w",
1365 "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
1366 "loop": {{"counter": "i", "max": 10}},
1367 "tasks": [{COUNTER_BODY}] }}"#
1368 ));
1369 let mut message = Message::from_value(&json!({}));
1370
1371 executor(engine)
1372 .execute(&workflow, &mut message, Utc::now())
1373 .await
1374 .expect("loop should complete");
1375
1376 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
1377 }
1378
1379 #[tokio::test]
1380 async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
1381 let (workflow, engine) = compiled(&format!(
1382 r#"{{ "id": "w", "name": "w", "condition": false,
1383 "loop": {{"counter": "i", "max": 5}},
1384 "tasks": [{COUNTER_BODY}] }}"#
1385 ));
1386 let mut message = Message::from_value(&json!({}));
1387
1388 let executed = executor(engine)
1389 .execute(&workflow, &mut message, Utc::now())
1390 .await
1391 .expect("a skip is not an error");
1392
1393 assert!(!executed, "a never-entered loop reports as skipped");
1394 assert!(message.audit_trail.is_empty());
1395 }
1396
1397 #[tokio::test]
1398 async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
1399 let (workflow, engine) = compiled(
1400 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
1401 "tasks": [
1402 {"id": "gate", "name": "gate", "function": {"name": "filter",
1403 "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
1404 "on_reject": "halt"}}},
1405 {"id": "body", "name": "body", "function": {"name": "map",
1406 "input": {"mappings": [
1407 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
1408 );
1409 let mut message = Message::from_value(&json!({}));
1410
1411 executor(engine)
1412 .execute(&workflow, &mut message, Utc::now())
1413 .await
1414 .expect("a halt is not an error");
1415
1416 let ids: Vec<&str> = message
1419 .audit_trail
1420 .iter()
1421 .map(|entry| entry.task_id.as_ref())
1422 .collect();
1423 assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
1424 assert_eq!(
1425 counters(&message),
1426 vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
1427 );
1428 }
1429
1430 #[tokio::test]
1431 async fn init_and_increment_drive_the_counter() {
1432 let (workflow, engine) = compiled(&format!(
1433 r#"{{ "id": "w", "name": "w",
1434 "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
1435 "tasks": [{COUNTER_BODY}] }}"#
1436 ));
1437 let mut message = Message::from_value(&json!({}));
1438
1439 executor(engine)
1440 .execute(&workflow, &mut message, Utc::now())
1441 .await
1442 .expect("loop should complete");
1443
1444 assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
1445 }
1446
1447 #[tokio::test]
1448 async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
1449 let (workflow, engine) = compiled(
1450 r#"{ "id": "w", "name": "w", "loop": {"max": 2},
1451 "tasks": [{"id": "t", "name": "t",
1452 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1453 );
1454 let mut message = Message::from_value(&json!({}));
1455
1456 executor(engine)
1457 .execute(&workflow, &mut message, Utc::now())
1458 .await
1459 .expect("loop should complete");
1460
1461 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
1462 assert_eq!(message.context["temp_data"], dv(json!({})));
1464 }
1465
1466 #[tokio::test]
1467 async fn a_non_looping_workflow_records_no_loop_counter() {
1468 let (workflow, engine) = compiled(
1469 r#"{ "id": "w", "name": "w",
1470 "tasks": [{"id": "t", "name": "t",
1471 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1472 );
1473 let mut message = Message::from_value(&json!({}));
1474
1475 executor(engine)
1476 .execute(&workflow, &mut message, Utc::now())
1477 .await
1478 .expect("should complete");
1479
1480 assert_eq!(counters(&message), vec![None]);
1481 }
1482
1483 #[tokio::test]
1484 async fn progress_metadata_is_written_on_every_sweep() {
1485 let (workflow, engine) = compiled(&format!(
1488 r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
1489 "tasks": [{COUNTER_BODY}] }}"#
1490 ));
1491 let mut message = Message::from_value(&json!({}));
1492
1493 executor(engine)
1494 .execute(&workflow, &mut message, Utc::now())
1495 .await
1496 .expect("loop should complete");
1497
1498 let progress = message.context["metadata"]
1499 .get("progress")
1500 .expect("progress must be written");
1501 assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
1502 assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
1503 assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
1504 }
1505
1506 #[tokio::test]
1507 async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
1508 let (workflow, engine) = compiled(
1511 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
1512 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
1513 "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
1514 );
1515 let mut message = Message::from_value(&json!({}));
1516
1517 executor(engine)
1518 .execute(&workflow, &mut message, Utc::now())
1519 .await
1520 .expect("loop should complete");
1521
1522 assert_eq!(
1523 counters(&message),
1524 vec![Some(0), Some(1), Some(2)],
1525 "the body's write must not stall or skew the loop"
1526 );
1527 }
1528
1529 async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
1532 let (workflow, engine) = compiled(&format!(
1533 r#"{{ "id": "w", "name": "w",
1534 "loop": {{"counter": "i", "init": {init},
1535 "increment": {increment}, "max": {max}}},
1536 "tasks": [{{"id": "t", "name": "t",
1537 "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
1538 ));
1539 let mut message = Message::from_value(&json!({}));
1540 executor(engine)
1541 .execute(&workflow, &mut message, Utc::now())
1542 .await
1543 .expect("loop should complete");
1544 counters(&message)
1545 }
1546
1547 #[tokio::test]
1548 async fn counter_sequence_matrix_over_init_increment_and_max() {
1549 let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
1552 (0, 1, 1, vec![0]),
1554 (0, 1, 2, vec![0, 1]),
1555 (0, 1, 5, vec![0, 1, 2, 3, 4]),
1556 (0, 2, 6, vec![0, 2, 4]),
1558 (0, 3, 10, vec![0, 3, 6, 9]),
1559 (0, 5, 3, vec![0]),
1560 (0, 100, 1, vec![0]),
1561 (10, 5, 25, vec![10, 15, 20]),
1563 (3, 1, 6, vec![3, 4, 5]),
1564 (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
1566 (-4, 2, 1, vec![-4, -2, 0]),
1567 (-10, 5, -5, vec![-10]),
1568 ];
1569
1570 for (init, increment, max, expected) in cases {
1571 let got = counter_sequence(init, increment, max).await;
1572 let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
1573 assert_eq!(got, expected, "init={init} increment={increment} max={max}");
1574 }
1575 }
1576
1577 #[tokio::test]
1578 async fn the_counter_advance_saturates_instead_of_overflowing() {
1579 assert_eq!(
1583 counter_sequence(0, i64::MAX, 5).await,
1584 vec![Some(0)],
1585 "one sweep, then the advance saturates past max"
1586 );
1587 assert_eq!(
1588 counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
1589 vec![Some(i64::MAX - 1)],
1590 "the last representable sweep still terminates"
1591 );
1592 assert_eq!(
1593 counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
1594 vec![Some(i64::MAX - 2)]
1595 );
1596 }
1597
1598 #[tokio::test]
1599 async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
1600 let (workflow, engine) = compiled(
1603 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
1604 "tasks": [
1605 {"id": "evens", "name": "evens",
1606 "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
1607 "function": {"name": "map", "input": {"mappings": []}}},
1608 {"id": "always", "name": "always",
1609 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1610 );
1611 let mut message = Message::from_value(&json!({}));
1612
1613 executor(engine)
1614 .execute(&workflow, &mut message, Utc::now())
1615 .await
1616 .expect("loop should complete");
1617
1618 let entries: Vec<(&str, Option<i64>)> = message
1619 .audit_trail
1620 .iter()
1621 .map(|e| (e.task_id.as_ref(), e.loop_counter))
1622 .collect();
1623 assert_eq!(
1624 entries,
1625 [
1626 ("evens", Some(0)),
1627 ("always", Some(0)),
1628 ("always", Some(1)),
1629 ("evens", Some(2)),
1630 ("always", Some(2)),
1631 ("always", Some(3)),
1632 ],
1633 "the gated task runs only on even counters"
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
1639 let (workflow, engine) = compiled(
1643 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
1644 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
1645 "input": {"condition": false, "on_reject": "skip"}}}] }"#,
1646 );
1647 let mut message = Message::from_value(&json!({}));
1648
1649 let executed = executor(engine)
1650 .execute(&workflow, &mut message, Utc::now())
1651 .await
1652 .expect("skip is not an error");
1653
1654 assert!(executed, "sweeps ran even though every task skipped");
1655 assert!(message.audit_trail.is_empty(), "Skip records no entry");
1656 assert_eq!(
1657 message.context["temp_data"].get("i"),
1658 Some(&dv(json!(3))),
1659 "the loop still ran to its bound"
1660 );
1661 }
1662
1663 #[tokio::test]
1664 async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
1665 let (workflow, engine) = compiled(
1667 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
1668 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
1669 "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
1670 );
1671 let mut message = Message::from_value(&json!({}));
1672
1673 executor(engine)
1674 .execute(&workflow, &mut message, Utc::now())
1675 .await
1676 .expect("a 4xx does not stop the workflow");
1677
1678 assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
1679 assert!(
1680 message.audit_trail.iter().all(|e| e.status == 400),
1681 "every sweep recorded the 4xx"
1682 );
1683 }
1684
1685 #[tokio::test]
1686 async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
1687 let (workflow, engine) = compiled(
1690 r#"{ "id": "w", "name": "w",
1691 "rollout": {"bucket_start": 0, "bucket_end": 50},
1692 "loop": {"counter": "i", "max": 5},
1693 "tasks": [{"id": "t", "name": "t",
1694 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1695 );
1696 let mut message = Message::builder().routing_bucket(75).build();
1697
1698 let executed = executor(engine)
1699 .execute(&workflow, &mut message, Utc::now())
1700 .await
1701 .expect("an excluded workflow is not an error");
1702
1703 assert!(!executed);
1704 assert!(message.audit_trail.is_empty());
1705 assert_eq!(
1706 message.context["temp_data"].get("i"),
1707 None,
1708 "no counter is written for an excluded workflow"
1709 );
1710 }
1711
1712 #[tokio::test]
1713 async fn a_nested_counter_path_is_created_and_advanced() {
1714 let (workflow, engine) = compiled(
1715 r#"{ "id": "w", "name": "w",
1716 "loop": {"counter": "cursor.index", "max": 3},
1717 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
1718 "input": {"mappings": [
1719 {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
1720 );
1721 let mut message = Message::from_value(&json!({}));
1722
1723 executor(engine)
1724 .execute(&workflow, &mut message, Utc::now())
1725 .await
1726 .expect("loop should complete");
1727
1728 assert_eq!(
1729 message.context["temp_data"]["cursor"].get("index"),
1730 Some(&dv(json!(3)))
1731 );
1732 assert_eq!(
1733 message.context["data"].get("seen"),
1734 Some(&dv(json!(2))),
1735 "the body read the nested counter"
1736 );
1737 }
1738
1739 #[tokio::test]
1740 async fn writing_the_counter_preserves_unrelated_temp_data() {
1741 let (workflow, engine) = compiled(
1742 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
1743 "tasks": [{"id": "t", "name": "t",
1744 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1745 );
1746 let mut message = Message::builder()
1747 .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
1748 .build();
1749
1750 executor(engine)
1751 .execute(&workflow, &mut message, Utc::now())
1752 .await
1753 .expect("loop should complete");
1754
1755 assert_eq!(
1756 message.context["temp_data"].get("keep"),
1757 Some(&dv(json!("me")))
1758 );
1759 assert_eq!(
1760 message.context["temp_data"]["nested"].get("a"),
1761 Some(&dv(json!(1)))
1762 );
1763 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
1764 }
1765
1766 #[tokio::test]
1767 async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
1768 let (workflow, engine) = compiled(
1771 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
1772 "tasks": [{"id": "t", "name": "t",
1773 "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1774 );
1775 let mut message = Message::builder()
1776 .temp_data(dv(json!({"i": "not a number"})))
1777 .build();
1778
1779 executor(engine)
1780 .execute(&workflow, &mut message, Utc::now())
1781 .await
1782 .expect("loop should complete");
1783
1784 assert_eq!(counters(&message), vec![Some(5), Some(6)]);
1785 assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
1786 }
1787
1788 #[tokio::test]
1789 async fn a_loop_records_audit_entries_with_capture_changes_off() {
1790 let (workflow, engine) = compiled(
1793 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
1794 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
1795 "input": {"mappings": [
1796 {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
1797 );
1798 let mut message = Message::builder().capture_changes(false).build();
1799
1800 executor(engine)
1801 .execute(&workflow, &mut message, Utc::now())
1802 .await
1803 .expect("loop should complete");
1804
1805 assert_eq!(counters(&message), vec![Some(0), Some(1)]);
1806 assert!(
1807 message.audit_trail.iter().all(|e| e.changes.is_empty()),
1808 "no diffs captured, but the entries are still there"
1809 );
1810 }
1811
1812 #[tokio::test]
1813 async fn two_loops_sharing_a_counter_name_do_not_interfere() {
1814 let first = r#"{ "id": "a", "name": "a", "priority": 0,
1817 "loop": {"counter": "i", "max": 2},
1818 "tasks": [{"id": "t", "name": "t",
1819 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
1820 let second = r#"{ "id": "b", "name": "b", "priority": 1,
1821 "loop": {"counter": "i", "init": 10, "max": 12},
1822 "tasks": [{"id": "t", "name": "t",
1823 "function": {"name": "map", "input": {"mappings": []}}}] }"#;
1824
1825 let compiler = LogicCompiler::new();
1826 let workflows = compiler
1827 .compile_workflows(vec![
1828 Workflow::from_json(first).unwrap(),
1829 Workflow::from_json(second).unwrap(),
1830 ])
1831 .expect("should compile");
1832 let exec = executor(compiler.into_engine());
1833 let mut message = Message::from_value(&json!({}));
1834
1835 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
1836 .await
1837 .expect("both loops should complete");
1838
1839 let per_workflow: Vec<(&str, Option<i64>)> = message
1840 .audit_trail
1841 .iter()
1842 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
1843 .collect();
1844 assert_eq!(
1845 per_workflow,
1846 [
1847 ("a", Some(0)),
1848 ("a", Some(1)),
1849 ("b", Some(10)),
1850 ("b", Some(11)),
1851 ]
1852 );
1853 }
1854
1855 #[tokio::test]
1856 async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
1857 let sync_wf = |id: &str, priority: u32| {
1861 format!(
1862 r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
1863 "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
1864 "input": {{"mappings": [
1865 {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
1866 )
1867 };
1868 let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
1869 "loop": {"counter": "i", "max": 2},
1870 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
1871 "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
1872
1873 let compiler = LogicCompiler::new();
1874 let workflows = compiler
1875 .compile_workflows(vec![
1876 Workflow::from_json(&sync_wf("before", 0)).unwrap(),
1877 Workflow::from_json(loop_wf).unwrap(),
1878 Workflow::from_json(&sync_wf("after", 2)).unwrap(),
1879 ])
1880 .expect("should compile");
1881 assert!(workflows.iter().all(|w| w.fully_sync));
1883 assert!(!joins_sync_run(&workflows[1]));
1884
1885 let exec = executor(compiler.into_engine());
1886 let mut message = Message::from_value(&json!({}));
1887
1888 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
1889 .await
1890 .expect("all three should run");
1891
1892 for id in ["before", "mid", "after"] {
1893 assert_eq!(
1894 message.context["data"].get(id),
1895 Some(&dv(json!(true))),
1896 "workflow {id} must have run"
1897 );
1898 }
1899 let order: Vec<(&str, Option<i64>)> = message
1900 .audit_trail
1901 .iter()
1902 .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
1903 .collect();
1904 assert_eq!(
1905 order,
1906 [
1907 ("before", None),
1908 ("mid", Some(0)),
1909 ("mid", Some(1)),
1910 ("after", None),
1911 ],
1912 "priority order is preserved across the split"
1913 );
1914 }
1915
1916 #[tokio::test]
1917 async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
1918 let compiler = LogicCompiler::new();
1921 let workflows = compiler
1922 .compile_workflows(vec![
1923 Workflow::from_json(
1924 r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
1925 "function": {"name": "map", "input": {"mappings": [
1926 {"path": "data.a", "logic": 1}]}}}] }"#,
1927 )
1928 .unwrap(),
1929 Workflow::from_json(
1930 r#"{ "id": "b", "name": "b", "priority": 1,
1931 "condition": {"==": [{"var": "data.a"}, 1]},
1932 "tasks": [{"id": "t", "name": "t",
1933 "function": {"name": "map", "input": {"mappings": [
1934 {"path": "data.b", "logic": 2}]}}}] }"#,
1935 )
1936 .unwrap(),
1937 ])
1938 .expect("should compile");
1939 assert!(workflows.iter().all(joins_sync_run));
1940
1941 let exec = executor(compiler.into_engine());
1942 let mut message = Message::from_value(&json!({}));
1943 exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
1944 .await
1945 .expect("both should run");
1946
1947 assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
1950 assert_eq!(counters(&message), vec![None, None]);
1951 }
1952
1953 #[tokio::test]
1954 async fn a_loop_body_can_index_an_array_by_its_counter() {
1955 let (workflow, engine) = compiled(
1957 r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
1958 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
1959 "input": {"mappings": [
1960 {"path": "data.picked",
1961 "logic": {"merge": [{"var": "data.picked"},
1962 [{"val": [["data", "items",
1963 {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
1964 );
1965 let mut message = Message::builder()
1966 .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
1967 .build();
1968
1969 executor(engine)
1970 .execute(&workflow, &mut message, Utc::now())
1971 .await
1972 .expect("loop should complete");
1973
1974 assert_eq!(
1975 serde_json::Value::from(&message.context["data"]["picked"]),
1976 json!(["a", "b", "c"]),
1977 "each sweep appended the item at its own index"
1978 );
1979 }
1980
1981 #[tokio::test]
1982 async fn test_workflow_executor_skip_condition() {
1983 let workflow_json = r#"{
1985 "id": "test_workflow",
1986 "name": "Test Workflow",
1987 "condition": false,
1988 "tasks": [{
1989 "id": "dummy_task",
1990 "name": "Dummy Task",
1991 "function": {
1992 "name": "map",
1993 "input": {"mappings": []}
1994 }
1995 }]
1996 }"#;
1997
1998 let compiler = LogicCompiler::new();
1999 let mut workflow = Workflow::from_json(workflow_json).unwrap();
2000
2001 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2003 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2004 workflow = compiled_workflow.clone();
2005 }
2006
2007 let engine = compiler.into_engine();
2008 let task_executor = Arc::new(TaskExecutor::new(
2009 Arc::new(HashMap::new()),
2010 Arc::clone(&engine),
2011 ));
2012 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2013
2014 let mut message = Message::from_value(&json!({}));
2015
2016 let executed = workflow_executor
2018 .execute(&workflow, &mut message, Utc::now())
2019 .await
2020 .unwrap();
2021 assert!(!executed);
2022 assert_eq!(message.audit_trail.len(), 0);
2023 }
2024
2025 #[tokio::test]
2026 async fn test_workflow_executor_execute_success() {
2027 let workflow_json = r#"{
2029 "id": "test_workflow",
2030 "name": "Test Workflow",
2031 "condition": true,
2032 "tasks": [{
2033 "id": "dummy_task",
2034 "name": "Dummy Task",
2035 "function": {
2036 "name": "map",
2037 "input": {"mappings": []}
2038 }
2039 }]
2040 }"#;
2041
2042 let compiler = LogicCompiler::new();
2043 let mut workflow = Workflow::from_json(workflow_json).unwrap();
2044
2045 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2047 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2048 workflow = compiled_workflow.clone();
2049 }
2050
2051 let engine = compiler.into_engine();
2052 let task_executor = Arc::new(TaskExecutor::new(
2053 Arc::new(HashMap::new()),
2054 Arc::clone(&engine),
2055 ));
2056 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2057
2058 let mut message = Message::from_value(&json!({}));
2059
2060 let executed = workflow_executor
2062 .execute(&workflow, &mut message, Utc::now())
2063 .await
2064 .unwrap();
2065 assert!(executed);
2066 }
2067}