Skip to main content

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