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}