dataflow_rs/engine/workflow_executor.rs
1//! # Workflow Execution Module
2//!
3//! This module handles the execution of workflows and their associated tasks.
4//! It provides a clean separation between workflow orchestration and task execution.
5
6use crate::engine::error::{DataflowError, ErrorInfo, Result};
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::task::Task;
13use crate::engine::task_executor::TaskExecutor;
14use crate::engine::task_outcome::TaskOutcome;
15use crate::engine::trace::{ExecutionStep, ExecutionTrace};
16use crate::engine::utils::set_nested_value;
17use crate::engine::workflow::Workflow;
18use chrono::{DateTime, Utc};
19use datalogic_rs::Engine;
20use datavalue::OwnedDataValue;
21use log::{debug, error, info, warn};
22use serde_json::Value;
23use std::collections::HashMap;
24use std::sync::Arc;
25
26/// Result of handling a task, including possible control flow signals
27enum TaskControlFlow {
28 /// Continue executing the next task
29 Continue,
30 /// Stop executing further tasks in this workflow (filter halt)
31 HaltWorkflow,
32}
33
34/// Return the index of the first task at or after `start` that is *not* a
35/// synchronous built-in. Used to chunk `workflow.tasks` into sync-only
36/// stretches that can share a single `ArenaContext`.
37fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
38 let mut i = start;
39 while i < tasks.len() && tasks[i].function.is_sync_builtin() {
40 i += 1;
41 }
42 i
43}
44
45/// Build a fresh `metadata.progress` object value.
46fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
47 OwnedDataValue::Object(vec![
48 (
49 "workflow_id".to_string(),
50 OwnedDataValue::String(workflow_id.to_string()),
51 ),
52 (
53 "task_id".to_string(),
54 OwnedDataValue::String(task_id.to_string()),
55 ),
56 (
57 "status_code".to_string(),
58 OwnedDataValue::from(u64::from(status)),
59 ),
60 ])
61}
62
63/// Overwrite the three fields of an existing 3-key `progress` object without
64/// reallocating it. Returns `false` when the object's shape diverges from
65/// `{workflow_id, task_id, status_code}`, in which case the caller replaces
66/// the slot wholesale (partial overwrites here are harmless — the whole slot
67/// gets replaced).
68fn overwrite_progress_in_place(
69 fields: &mut [(String, OwnedDataValue)],
70 workflow_id: &str,
71 task_id: &str,
72 status: u16,
73) -> bool {
74 if fields.len() != 3 {
75 return false;
76 }
77 let mut matched = 0;
78 for (k, v) in fields.iter_mut() {
79 match k.as_str() {
80 "workflow_id" => {
81 *v = OwnedDataValue::String(workflow_id.to_string());
82 matched += 1;
83 }
84 "task_id" => {
85 *v = OwnedDataValue::String(task_id.to_string());
86 matched += 1;
87 }
88 "status_code" => {
89 *v = OwnedDataValue::from(u64::from(status));
90 matched += 1;
91 }
92 _ => {}
93 }
94 }
95 matched == 3
96}
97
98/// Write `metadata.progress = {workflow_id, task_id, status_code}` with a
99/// single tree walk. From the second task of a message onward the slot
100/// already holds the expected 3-key object, so the three values are
101/// overwritten in place — no Vec/Object/key-`String` allocations, just the
102/// two unavoidable id `String`s. First write (or any shape divergence)
103/// replaces the slot wholesale; a context whose `metadata` is missing or
104/// non-Object falls back to the generic `set_nested_value` writer, which
105/// creates intermediate containers as needed.
106fn write_progress_metadata(
107 context: &mut OwnedDataValue,
108 workflow_id: &str,
109 task_id: &str,
110 status: u16,
111) {
112 // Nested `if let` rather than a let-chain: let-chains are stable only from
113 // Rust 1.88 and this crate's MSRV is 1.85. Keep it that way.
114 if let OwnedDataValue::Object(top) = context {
115 if let Some((_, OwnedDataValue::Object(meta))) =
116 top.iter_mut().find(|(k, _)| k == "metadata")
117 {
118 match meta.iter_mut().find(|(k, _)| k == "progress") {
119 Some((_, slot)) => {
120 if let OwnedDataValue::Object(fields) = slot {
121 if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
122 return;
123 }
124 }
125 *slot = new_progress_object(workflow_id, task_id, status);
126 }
127 None => {
128 meta.push((
129 "progress".to_string(),
130 new_progress_object(workflow_id, task_id, status),
131 ));
132 }
133 }
134 return;
135 }
136 }
137 set_nested_value(
138 context,
139 "metadata.progress",
140 new_progress_object(workflow_id, task_id, status),
141 );
142}
143
144/// Handles the execution of workflows and their tasks
145///
146/// The `WorkflowExecutor` is responsible for:
147/// - Evaluating workflow conditions
148/// - Orchestrating task execution within workflows
149/// - Managing workflow-level error handling
150/// - Recording audit trails
151pub struct WorkflowExecutor {
152 /// Task executor for executing individual tasks
153 task_executor: Arc<TaskExecutor>,
154 /// Shared datalogic engine for condition evaluation
155 engine: Arc<Engine>,
156}
157
158impl WorkflowExecutor {
159 /// Create a new WorkflowExecutor
160 pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
161 Self {
162 task_executor,
163 engine,
164 }
165 }
166
167 /// Get a clone of the task_functions Arc for reuse in new engines
168 pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
169 self.task_executor.task_functions()
170 }
171
172 /// Execute a workflow if its condition is met
173 ///
174 /// This method:
175 /// 1. Evaluates the workflow condition
176 /// 2. Executes tasks sequentially if condition is met
177 /// 3. Handles error recovery based on workflow configuration
178 /// 4. Updates message metadata and audit trail
179 ///
180 /// # Arguments
181 /// * `workflow` - The workflow to execute
182 /// * `message` - The message being processed
183 ///
184 /// # Returns
185 /// * `Result<bool>` - Ok(true) if workflow was executed, Ok(false) if skipped, Err on failure
186 pub async fn execute(
187 &self,
188 workflow: &Workflow,
189 message: &mut Message,
190 now: DateTime<Utc>,
191 ) -> Result<bool> {
192 self.execute_inner(workflow, message, None, now).await
193 }
194
195 /// Execute a workflow with step-by-step tracing
196 ///
197 /// Similar to `execute` but records execution steps for debugging.
198 pub async fn execute_with_trace(
199 &self,
200 workflow: &Workflow,
201 message: &mut Message,
202 trace: &mut ExecutionTrace,
203 now: DateTime<Utc>,
204 ) -> Result<bool> {
205 self.execute_inner(workflow, message, Some(trace), now)
206 .await
207 }
208
209 /// Unified workflow-condition + task-loop driver. `trace` is `None` for
210 /// the production path and `Some(&mut trace)` for the debug path —
211 /// stepping is the only behavioural difference between them.
212 ///
213 /// The workflow condition is folded into the *first* sync stretch's arena
214 /// scope: one `ArenaContext::from_owned` walk serves both the condition
215 /// eval and the leading run of sync built-in tasks. The owned path
216 /// (`eval_to_owned`) deep-borrowed the entire context — including the
217 /// heavy `data.input` payload — for the condition, and `execute_tasks`
218 /// then walked the same context again to build the first stretch's arena
219 /// form. Mixed sync+async workflows now pay one walk where they paid two.
220 /// No `.await` occurs inside the scope, preserving the `!Send` arena
221 /// invariant.
222 async fn execute_inner(
223 &self,
224 workflow: &Workflow,
225 message: &mut Message,
226 mut trace: Option<&mut ExecutionTrace>,
227 now: DateTime<Utc>,
228 ) -> Result<bool> {
229 /// Outcome of the folded condition-plus-first-stretch arena scope.
230 enum FirstStretch {
231 /// Workflow condition evaluated false — skip the workflow.
232 Skipped,
233 /// A filter task halted the workflow inside the first stretch.
234 Halted,
235 /// Continue with the remaining tasks (from the first async
236 /// boundary onward).
237 Continue,
238 }
239
240 let tasks = &workflow.tasks;
241 let first_boundary = next_async_boundary(tasks, 0);
242
243 let first: Result<FirstStretch> =
244 if workflow.compiled_condition.is_none() && first_boundary == 0 {
245 // No condition and the workflow leads with an async task —
246 // nothing to fold; don't build an arena context for nothing.
247 Ok(FirstStretch::Continue)
248 } else {
249 with_arena(|arena| -> Result<FirstStretch> {
250 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
251
252 let should_execute = match workflow.compiled_condition.as_ref() {
253 None => true,
254 Some(compiled) => evaluate_condition_in_arena(
255 &self.engine,
256 Some(compiled),
257 arena_ctx.as_data_value(),
258 arena,
259 )?,
260 };
261 if !should_execute {
262 return Ok(FirstStretch::Skipped);
263 }
264 if first_boundary == 0 {
265 return Ok(FirstStretch::Continue);
266 }
267 let halted = self.run_tasks_slice_in_arena(
268 &tasks[..first_boundary],
269 workflow,
270 message,
271 &mut arena_ctx,
272 trace.as_deref_mut(),
273 now,
274 )?;
275 Ok(if halted {
276 FirstStretch::Halted
277 } else {
278 FirstStretch::Continue
279 })
280 })
281 };
282
283 // Drive the remaining (async-containing) tail, then apply the single
284 // workflow-level error contract to whichever half failed.
285 let run_result: Result<()> = match first {
286 Ok(FirstStretch::Skipped) => {
287 debug!("Skipping workflow {} - condition not met", workflow.id);
288 if let Some(t) = trace.as_deref_mut() {
289 t.add_step(ExecutionStep::workflow_skipped(&workflow.id));
290 }
291 return Ok(false);
292 }
293 Ok(FirstStretch::Halted) => Ok(()),
294 Ok(FirstStretch::Continue) => {
295 self.execute_tasks(workflow, message, trace, now, first_boundary)
296 .await
297 }
298 Err(e) => Err(e),
299 };
300
301 match run_result {
302 Ok(_) => {
303 info!("Successfully completed workflow: {}", workflow.id);
304 Ok(true)
305 }
306 Err(e) => {
307 // Single-channel contract: every error appears in
308 // `message.errors`. The `Result::Err` return only signals to
309 // the caller that we stopped before processing further
310 // workflows. The workflow-level wrapper records workflow
311 // context that the underlying task error doesn't carry.
312 message.errors.push(
313 ErrorInfo::builder(
314 "WORKFLOW_ERROR",
315 format!("Workflow {} error: {}", workflow.id, e),
316 )
317 .workflow_id(&workflow.id)
318 .build(),
319 );
320
321 if workflow.continue_on_error {
322 warn!(
323 "Workflow {} encountered error but continuing: {:?}",
324 workflow.id, e
325 );
326 Ok(true)
327 } else {
328 error!("Workflow {} failed: {:?}", workflow.id, e);
329 Err(e)
330 }
331 }
332 }
333 }
334
335 /// Execute the tasks of a workflow from index `start` onward.
336 ///
337 /// Groups consecutive synchronous built-in tasks into a single
338 /// `with_arena` scope so the arena form of `message.context` is built
339 /// once at the start of the stretch and reused across `parse_json`,
340 /// `map`, `validation`, `log`, and `filter`. Async tasks (HTTP, Kafka,
341 /// custom handlers) break the stretch — the arena flushes any pending
342 /// state back to `OwnedDataValue` automatically (since each sync task
343 /// already mutates `message.context` in place) and the next stretch
344 /// rebuilds the arena form.
345 ///
346 /// `start` is non-zero when `execute_inner` already ran the leading sync
347 /// stretch inside the folded condition scope.
348 ///
349 /// When `trace` is `Some`, the loop also records `ExecutionStep` entries
350 /// after each task (skipped/executed) including per-mapping snapshots
351 /// for `Map` tasks.
352 async fn execute_tasks(
353 &self,
354 workflow: &Workflow,
355 message: &mut Message,
356 mut trace: Option<&mut ExecutionTrace>,
357 now: DateTime<Utc>,
358 start: usize,
359 ) -> Result<()> {
360 let tasks = &workflow.tasks;
361 let mut idx = start;
362 while idx < tasks.len() {
363 let stretch_end = next_async_boundary(tasks, idx);
364
365 if stretch_end > idx {
366 // Run [idx, stretch_end) as a sync stretch inside one arena.
367 let halt = self.run_sync_stretch(
368 &tasks[idx..stretch_end],
369 workflow,
370 message,
371 trace.as_deref_mut(),
372 now,
373 )?;
374 if halt {
375 return Ok(());
376 }
377 idx = stretch_end;
378 }
379
380 if idx < tasks.len() {
381 // Single async task (or non-sync-builtin) at `idx`.
382 let task = &tasks[idx];
383 let should_execute = evaluate_condition(
384 &self.engine,
385 task.compiled_condition.as_ref(),
386 &message.context,
387 )?;
388
389 if !should_execute {
390 debug!("Skipping task {} - condition not met", task.id);
391 if let Some(t) = trace.as_deref_mut() {
392 t.add_step(ExecutionStep::task_skipped(&workflow.id, &task.id));
393 }
394 idx += 1;
395 continue;
396 }
397
398 let result = self.task_executor.execute(task, message).await;
399 let control_flow = self.handle_task_result(
400 result,
401 &workflow.id_arc,
402 &task.id_arc,
403 task.continue_on_error,
404 message,
405 now,
406 )?;
407
408 // Async tasks at the boundary have no per-mapping snapshots —
409 // they're either HTTP/Kafka/Enrich or a custom handler.
410 if let Some(t) = trace.as_deref_mut() {
411 t.add_step(ExecutionStep::executed(&workflow.id, &task.id, message));
412 }
413
414 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
415 return Ok(());
416 }
417 idx += 1;
418 }
419 }
420
421 Ok(())
422 }
423
424 /// Execute a contiguous run of sync-builtin tasks inside one
425 /// `with_arena` scope. The arena context is built once at the start and
426 /// refreshed in place after each mutating task. Returns `Ok(true)` if a
427 /// filter task halted the workflow.
428 ///
429 /// This is the single-workflow entry; the cross-workflow path
430 /// (`execute_sync_workflow_run`) shares the same task loop via
431 /// `run_tasks_slice_in_arena` but carries one `ArenaContext` across several
432 /// workflows.
433 fn run_sync_stretch(
434 &self,
435 tasks: &[Task],
436 workflow: &Workflow,
437 message: &mut Message,
438 trace: Option<&mut ExecutionTrace>,
439 now: DateTime<Utc>,
440 ) -> Result<bool> {
441 with_arena(|arena| -> Result<bool> {
442 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
443 self.run_tasks_slice_in_arena(tasks, workflow, message, &mut arena_ctx, trace, now)
444 })
445 }
446
447 /// Run `tasks` against an already-built `ArenaContext`, evaluating each
448 /// task's condition in-arena and refreshing the cache after each mutating
449 /// task. Returns `Ok(true)` if a filter task halted the workflow.
450 ///
451 /// Factored out of `run_sync_stretch` so both the single-workflow stretch
452 /// and the cross-workflow shared-arena run (`execute_sync_workflow_run`)
453 /// share one implementation. The caller owns the `ArenaContext` lifetime,
454 /// so the cross-workflow path can reuse the same arena form of
455 /// `message.context` across consecutive workflows instead of rebuilding it.
456 fn run_tasks_slice_in_arena<'arena>(
457 &self,
458 tasks: &'arena [Task],
459 workflow: &Workflow,
460 message: &mut Message,
461 arena_ctx: &mut ArenaContext<'arena>,
462 mut trace: Option<&mut ExecutionTrace>,
463 now: DateTime<Utc>,
464 ) -> Result<bool> {
465 let arena = arena_ctx.arena();
466
467 for task in tasks {
468 // Task condition — evaluate against the arena form so we don't
469 // re-borrow the thread-local `RefCell`. A `None` compiled
470 // condition (compiler folds the default literal `true` to
471 // `None`) skips both the eval and the per-task arena context
472 // slice build.
473 let should_execute = match task.compiled_condition.as_ref() {
474 None => true,
475 Some(compiled) => evaluate_condition_in_arena(
476 &self.engine,
477 Some(compiled),
478 arena_ctx.as_data_value(),
479 arena,
480 )?,
481 };
482
483 if !should_execute {
484 debug!("Skipping task {} - condition not met", task.id);
485 if let Some(t) = trace.as_deref_mut() {
486 t.add_step(ExecutionStep::task_skipped(&workflow.id, &task.id));
487 }
488 continue;
489 }
490
491 // Per-task snapshot buffer — only used for Map tasks in trace
492 // mode. Allocating an empty Vec is cheap and the buffer stays
493 // empty for non-Map tasks.
494 let mut mapping_snapshots: Vec<Value> = Vec::new();
495 let snapshot_buf = if trace.is_some() {
496 Some(&mut mapping_snapshots)
497 } else {
498 None
499 };
500 let result = self.execute_sync_task_in_arena(task, message, arena_ctx, snapshot_buf);
501
502 let control_flow = self.handle_task_result(
503 result,
504 &workflow.id_arc,
505 &task.id_arc,
506 task.continue_on_error,
507 message,
508 now,
509 )?;
510
511 // The only context write `handle_task_result` performs is
512 // `metadata.progress`. Refresh exactly that depth-2 slot so the
513 // next task — and, in the cross-workflow path, the next
514 // workflow's condition — sees it, without re-arenaing unrelated
515 // metadata children (mapped `metadata.routing.*`, chained
516 // workflow state, …) after every task.
517 arena_ctx.refresh_for_path(&message.context, "metadata.progress");
518
519 if let Some(t) = trace.as_deref_mut() {
520 let mut step = ExecutionStep::executed(&workflow.id, &task.id, message);
521 if !mapping_snapshots.is_empty() {
522 step = step.with_mapping_contexts(mapping_snapshots);
523 }
524 t.add_step(step);
525 }
526
527 if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
528 return Ok(true);
529 }
530 }
531 Ok(false)
532 }
533
534 /// Drive a message through `workflows` in order, grouping maximal runs of
535 /// consecutive `fully_sync` workflows into a single shared-arena scope
536 /// (`execute_sync_workflow_run`) and falling back to the per-workflow
537 /// `.await` path (`execute_inner`) for any workflow containing an async
538 /// task. This is the single orchestration entry for all four
539 /// `Engine::process_message*` variants.
540 pub async fn run_all(
541 &self,
542 workflows: &[&Workflow],
543 message: &mut Message,
544 trace: Option<&mut ExecutionTrace>,
545 now: DateTime<Utc>,
546 ) -> Result<()> {
547 self.run_all_borrowed(workflows, message, trace, now).await
548 }
549
550 /// Generic driver behind [`Self::run_all`]: accepts any slice whose
551 /// elements borrow as `Workflow` — `&[Workflow]` directly from the
552 /// engine's registry (no per-message `Vec<&Workflow>` collect) or the
553 /// `&[&Workflow]` shape the public entry keeps for compatibility.
554 pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
555 &self,
556 workflows: &[W],
557 message: &mut Message,
558 mut trace: Option<&mut ExecutionTrace>,
559 now: DateTime<Utc>,
560 ) -> Result<()> {
561 let mut i = 0;
562 while i < workflows.len() {
563 if workflows[i].borrow().fully_sync {
564 // Extend over the maximal run of consecutive fully-sync
565 // workflows and execute them in one shared arena scope.
566 let mut j = i + 1;
567 while j < workflows.len() && workflows[j].borrow().fully_sync {
568 j += 1;
569 }
570 self.execute_sync_workflow_run(
571 &workflows[i..j],
572 message,
573 trace.as_deref_mut(),
574 now,
575 )?;
576 i = j;
577 } else {
578 // Mixed sync+async (or fully-async) workflow: the existing
579 // driver interleaves per-stretch arenas with `.await`.
580 self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
581 .await?;
582 i += 1;
583 }
584 }
585 Ok(())
586 }
587
588 /// Execute a maximal run of consecutive fully-sync workflows inside ONE
589 /// shared `with_arena` scope. The message context is deep-walked into the
590 /// arena once for the whole run, then carried — with the existing
591 /// incremental `refresh_for_path` after each mutating task — across
592 /// workflow boundaries, instead of being rebuilt per workflow.
593 ///
594 /// Per-workflow semantics are preserved exactly: each workflow's condition
595 /// is evaluated (in-arena), a false condition skips only that workflow, a
596 /// filter-halt stops only that workflow, and task errors are wrapped with
597 /// the workflow id and honor `continue_on_error` (continue, or propagate
598 /// `Err` out of the run to stop the whole message) — mirroring
599 /// `execute_inner`.
600 ///
601 /// **Tokio safety:** this method is synchronous and the `fully_sync`
602 /// precondition guarantees every task is a sync built-in, so no `.await`
603 /// occurs while the `!Send` arena borrow is live. The borrow checker
604 /// enforces this — the shared `ArenaContext` cannot escape the closure.
605 fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
606 &self,
607 workflows: &[W],
608 message: &mut Message,
609 mut trace: Option<&mut ExecutionTrace>,
610 now: DateTime<Utc>,
611 ) -> Result<()> {
612 with_arena(|arena| -> Result<()> {
613 let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
614
615 for workflow in workflows {
616 let workflow: &Workflow = workflow.borrow();
617 // Workflow condition in-arena: a folded `None` skips the eval;
618 // a real condition reuses the carried context instead of the
619 // owned-path `eval_to_owned` deep-walk.
620 let should_execute = match workflow.compiled_condition.as_ref() {
621 None => true,
622 Some(compiled) => evaluate_condition_in_arena(
623 &self.engine,
624 Some(compiled),
625 arena_ctx.as_data_value(),
626 arena,
627 )?,
628 };
629
630 if !should_execute {
631 debug!("Skipping workflow {} - condition not met", workflow.id);
632 if let Some(t) = trace.as_deref_mut() {
633 t.add_step(ExecutionStep::workflow_skipped(&workflow.id));
634 }
635 continue;
636 }
637
638 match self.run_tasks_slice_in_arena(
639 &workflow.tasks,
640 workflow,
641 message,
642 &mut arena_ctx,
643 trace.as_deref_mut(),
644 now,
645 ) {
646 // Filter-halt stops only this workflow; carry on with the
647 // next one (and keep the shared arena context).
648 Ok(_halted) => {
649 info!("Successfully completed workflow: {}", workflow.id);
650 }
651 Err(e) => {
652 // Single-channel contract — mirror `execute_inner`:
653 // record the workflow-context error to `message.errors`
654 // and honor `continue_on_error`.
655 message.errors.push(
656 ErrorInfo::builder(
657 "WORKFLOW_ERROR",
658 format!("Workflow {} error: {}", workflow.id, e),
659 )
660 .workflow_id(&workflow.id)
661 .build(),
662 );
663
664 if workflow.continue_on_error {
665 warn!(
666 "Workflow {} encountered error but continuing: {:?}",
667 workflow.id, e
668 );
669 } else {
670 error!("Workflow {} failed: {:?}", workflow.id, e);
671 return Err(e);
672 }
673 }
674 }
675 }
676 Ok(())
677 })
678 }
679
680 /// Dispatch a single sync-builtin task via the consolidated
681 /// `FunctionConfig::try_execute_in_arena`. `next_async_boundary` guarantees
682 /// the stretch contents are sync built-ins, so the `None` arm is
683 /// unreachable in practice.
684 ///
685 /// `map_snapshot_buf` is only consulted by the `Map` variant; non-Map
686 /// sync builtins ignore it. Pass `None` from the production path.
687 fn execute_sync_task_in_arena<'arena>(
688 &self,
689 task: &'arena Task,
690 message: &mut Message,
691 arena_ctx: &mut ArenaContext<'arena>,
692 map_snapshot_buf: Option<&mut Vec<Value>>,
693 ) -> Result<(TaskOutcome, Vec<Change>)> {
694 debug!(
695 "Executing sync task in arena: {} ({})",
696 task.id,
697 task.function.function_name()
698 );
699 debug_assert!(
700 task.function.is_sync_builtin(),
701 "execute_sync_task_in_arena called with non-sync-builtin task: {}",
702 task.function.function_name()
703 );
704 // In debug builds the assert above catches mis-dispatch; in release
705 // we still surface the invariant violation as a recoverable engine
706 // error rather than panicking via `unreachable!`.
707 task.function
708 .try_execute_in_arena(message, arena_ctx, &self.engine, map_snapshot_buf)
709 .ok_or_else(|| {
710 DataflowError::Task(format!(
711 "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
712 (engine bug — sync-stretch should only contain sync-builtin tasks)",
713 task.function.function_name()
714 ))
715 })?
716 }
717
718 /// Handle the result of a task execution.
719 ///
720 /// `workflow_id_arc` and `task_id_arc` are the compile-time cached
721 /// `Arc<str>` mirrors of `workflow.id` / `task.id`; we Arc-clone them into
722 /// each `AuditTrail` rather than reallocating from the `&str` form.
723 fn handle_task_result(
724 &self,
725 result: Result<(TaskOutcome, Vec<Change>)>,
726 workflow_id_arc: &Arc<str>,
727 task_id_arc: &Arc<str>,
728 continue_on_error: bool,
729 message: &mut Message,
730 now: DateTime<Utc>,
731 ) -> Result<TaskControlFlow> {
732 let workflow_id: &str = workflow_id_arc;
733 let task_id: &str = task_id_arc;
734 match result {
735 Ok((TaskOutcome::Skip, _)) => {
736 // No audit trail, no progress write — task has explicitly opted
737 // out (filter gate set to `Skip`).
738 debug!("Task {} signaled skip", task_id);
739 Ok(TaskControlFlow::Continue)
740 }
741 Ok((outcome, changes)) => {
742 // `Skip` already returned above; the remaining variants all
743 // record an audit entry. `audit_status()` is `Some` for
744 // Success/Status/Halt — expect is for documentation only.
745 let status = outcome
746 .audit_status()
747 .expect("Skip handled above; remaining variants emit audit status");
748 let halt = outcome.halts_workflow();
749
750 // Record audit trail. workflow_id_arc/task_id_arc are populated
751 // by LogicCompiler at engine construction; cloning them is a
752 // refcount bump, not a string copy. `now` is shared with all
753 // other AuditTrails in this process_message call.
754 message.audit_trail.push(AuditTrail {
755 timestamp: now,
756 workflow_id: Arc::clone(workflow_id_arc),
757 task_id: Arc::clone(task_id_arc),
758 status: status as usize,
759 changes,
760 });
761
762 // Update progress metadata for workflow chaining. Always
763 // emitted: when multiple workflows are registered in the same
764 // engine, downstream workflows route on
765 // `metadata.progress.{workflow_id,task_id,status_code}` to
766 // advance through linear sequences. After the first task the
767 // slot already holds the expected 3-key object, so the write
768 // overwrites the three values in place — only the two id
769 // `String` allocs remain. (This beat both three separate
770 // `set_nested_value` calls and the batched slot replace on
771 // the realistic workload.)
772 write_progress_metadata(&mut message.context, workflow_id, task_id, status);
773
774 if halt {
775 info!("Task {} halted workflow {}", task_id, workflow_id);
776 return Ok(TaskControlFlow::HaltWorkflow);
777 }
778
779 // Check status code
780 if (400..500).contains(&status) {
781 warn!("Task {} returned client error status: {}", task_id, status);
782 } else if status >= 500 {
783 error!("Task {} returned server error status: {}", task_id, status);
784 // Single-channel contract: surface 5xx outcomes through
785 // `message.errors` as well as the audit trail, so callers
786 // that scan `errors()` see a 5xx-status task even when
787 // the workflow continues past it.
788 message.errors.push(
789 ErrorInfo::builder(
790 "TASK_STATUS_ERROR",
791 format!("Task {} returned status {}", task_id, status),
792 )
793 .workflow_id(workflow_id)
794 .task_id(task_id)
795 .build(),
796 );
797 if !continue_on_error {
798 return Err(DataflowError::Task(format!(
799 "Task {} failed with status {}",
800 task_id, status
801 )));
802 }
803 }
804 Ok(TaskControlFlow::Continue)
805 }
806 Err(e) => {
807 error!("Task {} failed: {:?}", task_id, e);
808
809 // Record error in audit trail (Arc clones are refcount bumps).
810 message.audit_trail.push(AuditTrail {
811 timestamp: now,
812 workflow_id: Arc::clone(workflow_id_arc),
813 task_id: Arc::clone(task_id_arc),
814 status: 500,
815 changes: vec![],
816 });
817
818 // Add error to message
819 message.errors.push(
820 ErrorInfo::builder("TASK_ERROR", format!("Task {} error: {}", task_id, e))
821 .workflow_id(workflow_id)
822 .task_id(task_id)
823 .build(),
824 );
825
826 if !continue_on_error {
827 Err(e)
828 } else {
829 Ok(TaskControlFlow::Continue)
830 }
831 }
832 }
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use crate::engine::compiler::LogicCompiler;
840 use serde_json::json;
841 use std::collections::HashMap;
842
843 #[tokio::test]
844 async fn test_workflow_executor_skip_condition() {
845 // Create a workflow with a false condition
846 let workflow_json = r#"{
847 "id": "test_workflow",
848 "name": "Test Workflow",
849 "condition": false,
850 "tasks": [{
851 "id": "dummy_task",
852 "name": "Dummy Task",
853 "function": {
854 "name": "map",
855 "input": {"mappings": []}
856 }
857 }]
858 }"#;
859
860 let compiler = LogicCompiler::new();
861 let mut workflow = Workflow::from_json(workflow_json).unwrap();
862
863 // Compile the workflow condition
864 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
865 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
866 workflow = compiled_workflow.clone();
867 }
868
869 let engine = compiler.into_engine();
870 let task_executor = Arc::new(TaskExecutor::new(
871 Arc::new(HashMap::new()),
872 Arc::clone(&engine),
873 ));
874 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
875
876 let mut message = Message::from_value(&json!({}));
877
878 // Execute workflow - should be skipped due to false condition
879 let executed = workflow_executor
880 .execute(&workflow, &mut message, Utc::now())
881 .await
882 .unwrap();
883 assert!(!executed);
884 assert_eq!(message.audit_trail.len(), 0);
885 }
886
887 #[tokio::test]
888 async fn test_workflow_executor_execute_success() {
889 // Create a workflow with a true condition
890 let workflow_json = r#"{
891 "id": "test_workflow",
892 "name": "Test Workflow",
893 "condition": true,
894 "tasks": [{
895 "id": "dummy_task",
896 "name": "Dummy Task",
897 "function": {
898 "name": "map",
899 "input": {"mappings": []}
900 }
901 }]
902 }"#;
903
904 let compiler = LogicCompiler::new();
905 let mut workflow = Workflow::from_json(workflow_json).unwrap();
906
907 // Compile the workflow
908 let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
909 if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
910 workflow = compiled_workflow.clone();
911 }
912
913 let engine = compiler.into_engine();
914 let task_executor = Arc::new(TaskExecutor::new(
915 Arc::new(HashMap::new()),
916 Arc::clone(&engine),
917 ));
918 let workflow_executor = WorkflowExecutor::new(task_executor, engine);
919
920 let mut message = Message::from_value(&json!({}));
921
922 // Execute workflow - should succeed with empty task list
923 let executed = workflow_executor
924 .execute(&workflow, &mut message, Utc::now())
925 .await
926 .unwrap();
927 assert!(executed);
928 }
929}