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