Skip to main content

dataflow_rs/engine/
mod.rs

1/*!
2# Engine Module
3
4This module implements the core async workflow engine for dataflow-rs. The engine provides
5high-performance, asynchronous message processing through workflows composed of tasks.
6
7## Architecture
8
9The engine features a clean async-first architecture built on datalogic v5:
10- **Compiler**: Pre-compiles JSONLogic expressions into `Arc<Logic>` via `Engine::compile_arc`
11- **Executor**: Handles internal function execution (map, validation) with async support
12- **Engine**: Orchestrates workflow processing with shared compiled logic
13- **Thread-Safe**: Single `datalogic_rs::Engine` shared via `Arc`, with `Arc<Logic>` entries for zero-copy sharing
14
15## Key Components
16
17- **Engine**: Async engine optimized for Tokio runtime with mixed I/O and CPU workloads
18- **LogicCompiler**: Compiles and caches JSONLogic expressions during initialization
19- **InternalExecutor**: Executes built-in map and validation functions with compiled logic
20- **Workflow**: Collection of tasks with JSONLogic conditions (can access data, metadata, temp_data)
21- **Task**: Individual processing unit that performs a specific function on a message
22- **AsyncFunctionHandler**: Trait for custom async processing logic
23- **Message**: Data structure flowing through the engine with audit trail
24
25## Performance Optimizations
26
27- **Pre-compilation**: All JSONLogic expressions compiled at startup
28- **Arc-wrapped Logic**: Zero-copy sharing of compiled logic across async tasks
29- **Bump-arena evaluation**: Per-worker thread-local `Bump` is rewound (not freed) between evals
30- **True Async**: I/O operations remain fully async
31
32## Usage
33
34```rust,no_run
35use dataflow_rs::{Engine, Workflow, engine::message::Message};
36use serde_json::json;
37
38#[tokio::main]
39async fn main() -> Result<(), Box<dyn std::error::Error>> {
40    // Define workflows
41    let workflows = vec![
42        Workflow::from_json(r#"{"id": "example", "name": "Example", "tasks": [{"id": "task1", "name": "Task 1", "function": {"name": "map", "input": {"mappings": []}}}]}"#)?
43    ];
44
45    // Create engine with defaults
46    let engine = Engine::builder().with_workflows(workflows).build()?;
47
48    // Process messages asynchronously
49    let mut message = Message::from_value(&json!({}));
50    engine.process_message(&mut message).await?;
51
52    Ok(())
53}
54```
55*/
56
57pub mod compiler;
58pub mod error;
59pub mod executor;
60pub mod functions;
61pub mod message;
62pub mod observer;
63pub mod task;
64pub mod task_context;
65pub mod task_executor;
66pub mod task_outcome;
67pub mod trace;
68pub mod utils;
69pub mod workflow;
70pub mod workflow_executor;
71
72// Re-export key types for easier access
73use error::{DEFAULT_ERROR_CONTEXT_LIMIT, ErrorContextConfig};
74pub use error::{DataflowError, ErrorInfo, Result, ServiceErrorBuilder};
75pub use functions::{
76    AsyncFunctionHandler, BoxedFunctionHandler, CompiledCustomInput, DynAsyncFunctionHandler,
77    FunctionConfig, Template, TemplateCompiler,
78};
79pub use message::Message;
80pub use observer::{ExecutionObserver, TaskEvent};
81pub use task::Task;
82pub use task_context::TaskContext;
83pub use task_outcome::{HALT_STATUS_CODE, TaskOutcome};
84pub use trace::{AuditTrailScope, ExecutionStep, ExecutionTrace, StepResult, TraceOptions};
85pub use workflow::{ConnectorRef, Rollout, Workflow, WorkflowStatus};
86
87// `EngineBuilder` is defined further down in this file but exposed here so
88// downstream paths can import it via `dataflow_rs::engine::EngineBuilder`.
89
90use chrono::Utc;
91use datalogic_rs::Engine as DatalogicEngine;
92use datavalue::OwnedDataValue;
93use std::collections::HashMap;
94use std::sync::Arc;
95
96use compiler::LogicCompiler;
97use task_executor::TaskExecutor;
98use workflow_executor::WorkflowExecutor;
99
100/// High-performance async workflow engine for message processing.
101///
102/// ## Architecture
103///
104/// The engine is designed for async-first operation with Tokio:
105/// - **Separation of Concerns**: Distinct executors for workflows and tasks
106/// - **Shared datalogic engine**: Single `datalogic_rs::Engine` wrapped in `Arc` for thread-safe sharing
107/// - **Arc<Logic>**: Pre-compiled logic shared across all async tasks
108/// - **Async Functions**: Native async support for I/O-bound operations
109///
110/// ## Performance Characteristics
111///
112/// - **Zero Runtime Compilation**: All logic compiled during initialization
113/// - **Zero-Copy Sharing**: Arc-wrapped compiled logic shared without cloning
114/// - **Optimal for Mixed Workloads**: Async I/O with blocking CPU evaluation
115/// - **Thread-Safe by Design**: All components safe to share across Tokio tasks
116pub struct Engine {
117    /// Registry of available workflows, pre-sorted by priority (immutable after initialization).
118    /// Each workflow / task / function-config holds its own `Arc<Logic>` slots
119    /// — there is no central logic cache anymore.
120    workflows: Arc<Vec<Workflow>>,
121    /// Channel index: maps channel name -> indices into workflows vec (only Active workflows)
122    channel_index: Arc<HashMap<String, Vec<usize>>>,
123    /// Workflow executor for orchestrating workflow execution
124    workflow_executor: Arc<WorkflowExecutor>,
125    /// Shared datalogic v5 engine for JSONLogic evaluation (Send + Sync)
126    datalogic: Arc<DatalogicEngine>,
127    /// Custom JSONLogic operators registered via
128    /// [`EngineBuilder::with_datalogic_operator`]. Retained here — not just
129    /// applied once — because [`Engine::with_new_workflows`] builds a fresh
130    /// datalogic engine and must re-register them; holding only the built
131    /// engine would silently drop every custom operator at the first hot
132    /// reload.
133    datalogic_operators: DatalogicOperators,
134    /// Pre-built `Arc<OwnedDataValue::String>` of the engine version.
135    /// Built once at construction. Note the per-message stamp still clones
136    /// the inner `String` — the context owns its values, so the cached
137    /// form only saves re-formatting, not the (small) allocation.
138    engine_version: Arc<OwnedDataValue>,
139}
140
141/// The custom-operator registrations an engine carries across rebuilds.
142pub type DatalogicOperators = Arc<HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>>;
143
144/// Build a channel index from pre-sorted workflows.
145/// Maps channel name -> indices into workflows vec, only for Active workflows.
146fn build_channel_index(workflows: &[Workflow]) -> HashMap<String, Vec<usize>> {
147    let mut index: HashMap<String, Vec<usize>> = HashMap::new();
148    for (i, workflow) in workflows.iter().enumerate() {
149        if workflow.status == WorkflowStatus::Active {
150            index.entry(workflow.channel.clone()).or_default().push(i);
151        }
152    }
153    index
154}
155
156impl Engine {
157    /// Creates a new Engine instance.
158    ///
159    /// Compiles every workflow / task / function-config JSONLogic expression
160    /// up-front. Returns `Err(DataflowError)` if any required expression
161    /// fails to compile — fail-loud at construction time instead of silently
162    /// dropping broken workflows at runtime.
163    ///
164    /// # Arguments
165    /// * `workflows` - The workflows to use for processing messages
166    /// * `task_functions` - Custom async function handlers (use
167    ///   `HashMap::new()` for none, or prefer [`Engine::builder`])
168    ///
169    /// # Example
170    ///
171    /// ```
172    /// use dataflow_rs::{Engine, Workflow};
173    ///
174    /// let workflows = vec![Workflow::from_json(r#"{"id": "test", "name": "Test", "priority": 0, "tasks": [{"id": "task1", "name": "Task 1", "function": {"name": "map", "input": {"mappings": []}}}]}"#).unwrap()];
175    ///
176    /// let engine = Engine::builder().with_workflows(workflows).build().unwrap();
177    /// ```
178    /// The recommended construction path is [`Engine::builder`]. `Engine::new`
179    /// is the lower-level escape hatch — accepts handlers as a plain
180    /// `HashMap` (use `HashMap::new()` for the no-handler case).
181    pub fn new(
182        workflows: Vec<Workflow>,
183        task_functions: HashMap<String, BoxedFunctionHandler>,
184    ) -> Result<Self> {
185        Self::new_with_operators(workflows, task_functions, Arc::new(HashMap::new()))
186    }
187
188    /// As [`Engine::new`], with custom JSONLogic operators registered on the
189    /// datalogic engine (and retained across [`Engine::with_new_workflows`]).
190    /// The builder path is [`EngineBuilder::with_datalogic_operator`]; this is
191    /// its escape-hatch twin, matching `new`.
192    pub fn new_with_operators(
193        workflows: Vec<Workflow>,
194        task_functions: HashMap<String, BoxedFunctionHandler>,
195        datalogic_operators: DatalogicOperators,
196    ) -> Result<Self> {
197        // Compile workflows (sorted by priority at compile time). Each
198        // workflow/task/config owns its own `Arc<Logic>` slots — no central
199        // cache to return. Any compile failure bubbles up immediately.
200        let compiler = LogicCompiler::with_operators(&datalogic_operators);
201        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
202        let datalogic = compiler.into_engine();
203
204        // Pre-parse `FunctionConfig::Custom { input }` JSON into the
205        // registered handler's typed `Self::Input`, caching the boxed value
206        // on the task. Misshapen Custom configs fail here, not on first
207        // message — matches the "fail loud at startup" stance for compiled
208        // logic. Built-in async configs (HttpCall/Enrich/PublishKafka) are
209        // already typed by serde and need no second pass.
210        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
211
212        let task_executor = Arc::new(TaskExecutor::new(
213            Arc::new(task_functions),
214            Arc::clone(&datalogic),
215        ));
216
217        let workflow_executor =
218            Arc::new(WorkflowExecutor::new(task_executor, Arc::clone(&datalogic)));
219
220        // Build channel index for O(1) channel-based routing
221        let channel_index = build_channel_index(&sorted_workflows);
222
223        Ok(Self {
224            workflows: Arc::new(sorted_workflows),
225            channel_index: Arc::new(channel_index),
226            workflow_executor,
227            datalogic,
228            datalogic_operators,
229            engine_version: Arc::new(OwnedDataValue::String(
230                env!("CARGO_PKG_VERSION").to_string(),
231            )),
232        })
233    }
234
235    /// Start building an engine. The recommended construction path —
236    /// chains `register("name", handler)` and `with_workflow(w)` calls,
237    /// then `build()` to produce a `Result<Engine>`.
238    ///
239    /// ```no_run
240    /// use dataflow_rs::{Engine, Workflow};
241    /// # let workflow: Workflow = unimplemented!();
242    /// let engine = Engine::builder()
243    ///     .with_workflow(workflow)
244    ///     // .register("my_handler", MyHandler)  // any AsyncFunctionHandler
245    ///     .build()
246    ///     .unwrap();
247    /// ```
248    pub fn builder() -> EngineBuilder {
249        EngineBuilder::new()
250    }
251
252    /// Cached `OwnedDataValue::String` of the engine version.
253    pub fn engine_version_value(&self) -> &OwnedDataValue {
254        &self.engine_version
255    }
256
257    /// Creates a new Engine with different workflows but the same custom function handlers.
258    ///
259    /// This is the hot-reload path. The existing engine remains valid for any
260    /// in-flight `process_message` calls. The returned engine shares the same
261    /// function registry (zero-copy Arc bump) but has freshly compiled logic
262    /// for the new workflow set.
263    ///
264    /// # Arguments
265    /// * `workflows` - The new set of workflows to compile and use
266    pub fn with_new_workflows(&self, workflows: Vec<Workflow>) -> Result<Self> {
267        // Extract the shared function registry from the existing executor
268        let task_functions = self.workflow_executor.task_functions();
269
270        // Compile new workflows with a fresh datalogic engine instance —
271        // re-registering the retained custom operators, so a hot reload keeps
272        // the same operator vocabulary as the engine it replaces.
273        let compiler = LogicCompiler::with_operators(&self.datalogic_operators);
274        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
275        let datalogic = compiler.into_engine();
276
277        // Pre-parse Custom inputs against the existing handler registry —
278        // hot-reload still validates the new workflow set against the
279        // already-registered handlers.
280        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
281
282        // Rebuild the executor stack, reusing the existing function registry
283        let task_executor = Arc::new(TaskExecutor::new(task_functions, Arc::clone(&datalogic)));
284
285        // Carry the observer across the reload. Dropping it here would stop
286        // metrics silently at the first hot reload.
287        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&datalogic));
288        if let Some(observer) = self.workflow_executor.observer() {
289            executor = executor.with_observer(Arc::clone(observer));
290        }
291        // Same reasoning as the observer: dropping this would silently stop
292        // recording failure codes at the first hot reload.
293        if let Some(cfg) = self.workflow_executor.error_context() {
294            executor = executor.with_error_context(Arc::clone(cfg));
295        }
296        let workflow_executor = Arc::new(executor);
297
298        // Build channel index for O(1) channel-based routing
299        let channel_index = build_channel_index(&sorted_workflows);
300
301        Ok(Self {
302            workflows: Arc::new(sorted_workflows),
303            channel_index: Arc::new(channel_index),
304            workflow_executor,
305            datalogic,
306            datalogic_operators: Arc::clone(&self.datalogic_operators),
307            engine_version: Arc::clone(&self.engine_version),
308        })
309    }
310
311    /// Attach a per-task [`ExecutionObserver`], returning the updated engine.
312    ///
313    /// The escape hatch matching [`Engine::new`] — [`EngineBuilder::with_observer`]
314    /// is the recommended path. Rebuilds the executor stack around the existing
315    /// handler registry and datalogic engine, so nothing is recompiled; the cost
316    /// is a few `Arc` bumps.
317    ///
318    /// Carried across [`Engine::with_new_workflows`], so a hot reload does not
319    /// silently stop reporting.
320    pub fn with_observer(self, observer: Arc<dyn ExecutionObserver>) -> Self {
321        self.rebuild_executor(|executor| executor.with_observer(observer))
322    }
323
324    /// Mirror per-task failure codes into the message context, returning the
325    /// updated engine.
326    ///
327    /// The escape hatch matching [`Engine::new`];
328    /// [`EngineBuilder::with_error_context_path`] is the recommended path and the
329    /// only one that validates the path. Carried across
330    /// [`Engine::with_new_workflows`] and [`Engine::with_observer`].
331    pub(crate) fn with_error_context(self, cfg: Arc<ErrorContextConfig>) -> Self {
332        self.rebuild_executor(|executor| executor.with_error_context(cfg))
333    }
334
335    /// Rebuild the executor stack around the existing handler registry and
336    /// datalogic engine, applying `configure` to the fresh executor.
337    ///
338    /// Nothing is recompiled; the cost is a few `Arc` bumps. Every knob the old
339    /// executor held is re-applied first, because the rebuild otherwise drops
340    /// them — that is what would make `.with_error_context(..)` followed by
341    /// `.with_observer(..)` silently lose the former.
342    fn rebuild_executor(
343        self,
344        configure: impl FnOnce(WorkflowExecutor) -> WorkflowExecutor,
345    ) -> Self {
346        let task_executor = Arc::new(TaskExecutor::new(
347            self.workflow_executor.task_functions(),
348            Arc::clone(&self.datalogic),
349        ));
350        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&self.datalogic));
351        if let Some(observer) = self.workflow_executor.observer() {
352            executor = executor.with_observer(Arc::clone(observer));
353        }
354        if let Some(cfg) = self.workflow_executor.error_context() {
355            executor = executor.with_error_context(Arc::clone(cfg));
356        }
357        Self {
358            workflows: self.workflows,
359            channel_index: self.channel_index,
360            workflow_executor: Arc::new(configure(executor)),
361            datalogic: self.datalogic,
362            datalogic_operators: self.datalogic_operators,
363            engine_version: self.engine_version,
364        }
365    }
366
367    /// Processes a message through workflows that match their conditions.
368    ///
369    /// This async method:
370    /// 1. Iterates through workflows sequentially in priority order (pre-sorted at construction)
371    /// 2. Delegates workflow execution to the WorkflowExecutor
372    /// 3. Updates message metadata
373    ///
374    /// # Error contract
375    ///
376    /// Errors flow through two complementary channels:
377    /// - `message.errors()` — **always** contains every error encountered
378    ///   (validation failures, task panics, 5xx-status outcomes, workflow
379    ///   wrappers). Callers that want a uniform view inspect this list.
380    /// - `Result::Err` — signals **only** that the engine stopped before
381    ///   processing every workflow. Callers that want fail-fast match on
382    ///   this. The error pushed to `message.errors` for the same failure
383    ///   carries the workflow context (id) that the bare `Err` doesn't.
384    ///
385    /// In particular: a workflow with `continue_on_error: true` records its
386    /// errors to `message.errors` and returns `Ok(())` here. A workflow
387    /// with `continue_on_error: false` records to `message.errors` *and*
388    /// returns `Result::Err` (which short-circuits the rest of this call).
389    ///
390    /// # Arguments
391    /// * `message` - The message to process through workflows
392    ///
393    /// # Returns
394    /// * `Result<()>` — `Ok(())` if every workflow completed (each may have
395    ///   pushed errors to `message.errors`); `Err(e)` if the engine
396    ///   stopped early on a hard failure.
397    pub async fn process_message(&self, message: &mut Message) -> Result<()> {
398        // Capture a single timestamp for the entire process_message call. The
399        // workflow executor reads it back via Message metadata if it needs to
400        // emit AuditTrail entries; this caps the number of `Utc::now()` syscalls
401        // at 1 per message (down from 3+ — one stamp here, one per AuditTrail).
402        self.process_all(message, None, Utc::now()).await
403    }
404
405    /// Processes a message through workflows with step-by-step tracing,
406    /// recording into a caller-owned trace.
407    ///
408    /// Identical to [`Engine::process_message_with_trace`] except that the
409    /// trace is borrowed rather than returned, so the steps completed before a
410    /// hard failure survive the `Err`. That makes this the method to reach for
411    /// when the run you want to inspect is the run that failed — a returned
412    /// trace is dropped by the `?` at the call site, a borrowed one is not.
413    ///
414    /// Steps are **appended** to `trace`; any steps already present are
415    /// preserved, so a caller can accumulate across a chain of calls.
416    ///
417    /// The error contract is unchanged: `Ok(())` means every workflow was
418    /// processed (each may still have pushed to `message.errors`), and `Err(e)`
419    /// means the engine stopped early. See [`Engine::process_message`] for the
420    /// full contract.
421    ///
422    /// Note that the failing task's *own* step is not recorded — the engine
423    /// propagates the failure before appending it — so the retained trace ends
424    /// at the last known-good step rather than at the error. The error itself
425    /// is available from the returned `Err` and from `message.errors()`.
426    ///
427    /// # Arguments
428    /// * `message` - The message to process through workflows
429    /// * `trace` - Caller-owned trace to append steps to
430    ///
431    /// # Returns
432    /// * `Result<()>` — `Ok(())` if every workflow completed; `Err(e)` if the
433    ///   engine stopped early. In both cases `trace` holds the steps that ran.
434    pub async fn process_message_tracing(
435        &self,
436        message: &mut Message,
437        trace: &mut ExecutionTrace,
438    ) -> Result<()> {
439        // The trace carries its own capture policy, so nothing to pass here.
440        self.process_all(message, Some(trace), Utc::now()).await
441    }
442
443    /// Shared driver behind [`Self::process_message`] and
444    /// [`Self::process_message_tracing`] — stamps processing metadata and runs
445    /// every registered workflow in priority order. Mirrors [`Self::process_channel`]
446    /// for the whole-registry case.
447    ///
448    /// `run_all_borrowed` groups consecutive fully-sync workflows into a
449    /// single shared-arena scope so the context is deep-walked once per run
450    /// rather than once per workflow. Passing the registry slice directly
451    /// avoids a per-message `Vec<&Workflow>` collect.
452    async fn process_all(
453        &self,
454        message: &mut Message,
455        trace: Option<&mut ExecutionTrace>,
456        now: chrono::DateTime<Utc>,
457    ) -> Result<()> {
458        set_processing_metadata(&mut message.context, &self.engine_version, now, None);
459        self.workflow_executor
460            .run_all_borrowed(&self.workflows[..], message, trace, now)
461            .await
462    }
463
464    /// Processes a message through workflows with step-by-step tracing.
465    ///
466    /// This method is similar to `process_message` but captures an execution trace
467    /// that can be used for debugging and step-by-step visualization.
468    ///
469    /// Because the trace is returned by value, a `?` at the call site discards
470    /// it — on a hard failure this yields `Err` and no steps at all. Use
471    /// [`Engine::process_message_tracing`] to keep the steps that ran.
472    ///
473    /// # Arguments
474    /// * `message` - The message to process through workflows
475    ///
476    /// # Returns
477    /// * `Result<ExecutionTrace>` - The execution trace with message snapshots
478    pub async fn process_message_with_trace(
479        &self,
480        message: &mut Message,
481    ) -> Result<ExecutionTrace> {
482        self.process_message_with_trace_options(message, TraceOptions::default())
483            .await
484    }
485
486    /// Processes a message with tracing under an explicit capture policy.
487    ///
488    /// The default policy — what [`Engine::process_message_with_trace`] uses —
489    /// takes a full [`Message`] snapshot per executed step, which is unbounded
490    /// in message size and quadratic in task count. A host that *persists*
491    /// traces should bound them here rather than trimming the result
492    /// afterwards; by then the peak memory has already been paid.
493    ///
494    /// See [`TraceOptions`] for the knobs, and
495    /// [`Engine::process_message_tracing`] if you also need the steps to survive
496    /// a hard failure.
497    ///
498    /// # Arguments
499    /// * `message` - The message to process through workflows
500    /// * `options` - What to record for each step
501    pub async fn process_message_with_trace_options(
502        &self,
503        message: &mut Message,
504        options: TraceOptions,
505    ) -> Result<ExecutionTrace> {
506        let mut trace = ExecutionTrace::with_options(options);
507        self.process_message_tracing(message, &mut trace).await?;
508        Ok(trace)
509    }
510
511    /// Processes a message through only the Active workflows registered for a given channel.
512    ///
513    /// Workflows are processed in priority order (lowest first), same as process_message().
514    /// If the channel does not exist or has no Active workflows, this is a no-op.
515    ///
516    /// # Arguments
517    /// * `channel` - The channel name to route the message through
518    /// * `message` - The message to process
519    pub async fn process_message_for_channel(
520        &self,
521        channel: &str,
522        message: &mut Message,
523    ) -> Result<()> {
524        self.process_channel(channel, message, None, Utc::now())
525            .await
526    }
527
528    /// Channel-scoped variant of [`Engine::process_message_tracing`].
529    ///
530    /// As with [`Engine::process_message_for_channel`], an unknown channel — or
531    /// a channel with no Active workflows — is a no-op: this returns `Ok(())`
532    /// and leaves `trace` untouched. Steps are appended, matching
533    /// [`Engine::process_message_tracing`].
534    ///
535    /// # Arguments
536    /// * `channel` - The channel name to route the message through
537    /// * `message` - The message to process
538    /// * `trace` - Caller-owned trace to append steps to
539    pub async fn process_message_for_channel_tracing(
540        &self,
541        channel: &str,
542        message: &mut Message,
543        trace: &mut ExecutionTrace,
544    ) -> Result<()> {
545        self.process_channel(channel, message, Some(trace), Utc::now())
546            .await
547    }
548
549    /// Shared driver behind [`Self::process_message_for_channel`] and
550    /// [`Self::process_message_for_channel_tracing`] — stamps processing
551    /// metadata and runs only the channel's Active workflows. An unknown
552    /// channel, or one with no Active workflows, is a no-op.
553    async fn process_channel(
554        &self,
555        channel: &str,
556        message: &mut Message,
557        trace: Option<&mut ExecutionTrace>,
558        now: chrono::DateTime<Utc>,
559    ) -> Result<()> {
560        set_processing_metadata(
561            &mut message.context,
562            &self.engine_version,
563            now,
564            Some(channel),
565        );
566
567        if let Some(indices) = self.channel_index.get(channel) {
568            // Channel-selected workflows are non-contiguous in the registry,
569            // so the pointer collect stays on this path.
570            let workflows: Vec<&Workflow> =
571                indices.iter().map(|&idx| &self.workflows[idx]).collect();
572            self.workflow_executor
573                .run_all_borrowed(&workflows, message, trace, now)
574                .await?;
575        }
576
577        Ok(())
578    }
579
580    /// Processes a message through a channel with step-by-step tracing.
581    ///
582    /// Because the trace is returned by value, a `?` at the call site discards
583    /// it — on a hard failure this yields `Err` and no steps at all. Use
584    /// [`Engine::process_message_for_channel_tracing`] to keep the steps that
585    /// ran.
586    ///
587    /// # Arguments
588    /// * `channel` - The channel name to route the message through
589    /// * `message` - The message to process
590    pub async fn process_message_for_channel_with_trace(
591        &self,
592        channel: &str,
593        message: &mut Message,
594    ) -> Result<ExecutionTrace> {
595        self.process_message_for_channel_with_trace_options(
596            channel,
597            message,
598            TraceOptions::default(),
599        )
600        .await
601    }
602
603    /// Channel-scoped variant of
604    /// [`Engine::process_message_with_trace_options`].
605    ///
606    /// # Arguments
607    /// * `channel` - The channel name to route the message through
608    /// * `message` - The message to process
609    /// * `options` - What to record for each step
610    pub async fn process_message_for_channel_with_trace_options(
611        &self,
612        channel: &str,
613        message: &mut Message,
614        options: TraceOptions,
615    ) -> Result<ExecutionTrace> {
616        let mut trace = ExecutionTrace::with_options(options);
617        self.process_message_for_channel_tracing(channel, message, &mut trace)
618            .await?;
619        Ok(trace)
620    }
621
622    /// Get a reference to the workflows (pre-sorted by priority)
623    pub fn workflows(&self) -> &Arc<Vec<Workflow>> {
624        &self.workflows
625    }
626
627    /// Look up a workflow by its ID
628    pub fn workflow_by_id(&self, id: &str) -> Option<&Workflow> {
629        self.workflows.iter().find(|w| w.id == id)
630    }
631
632    /// Get a reference to the underlying datalogic v5 engine.
633    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
634        &self.datalogic
635    }
636}
637
638/// Builder for [`Engine`]. The recommended construction path — chain
639/// `register("name", handler)` and `with_workflow(workflow)` calls, then
640/// `build()` to produce a `Result<Engine>`. Empty registration is fine; an
641/// engine with no custom handlers still resolves the built-in functions.
642///
643/// `register` takes any [`AsyncFunctionHandler`] and boxes it internally; the
644/// `Box<dyn DynAsyncFunctionHandler + Send + Sync>` plumbing stays out of
645/// user code.
646///
647/// ```no_run
648/// use dataflow_rs::{Engine, Workflow};
649/// # let workflow: Workflow = unimplemented!();
650/// let engine = Engine::builder()
651///     .with_workflow(workflow)
652///     // .register("my_handler", MyHandler)
653///     .build()
654///     .unwrap();
655/// ```
656#[must_use = "EngineBuilder must be `.build()` to produce an Engine"]
657#[derive(Default)]
658pub struct EngineBuilder {
659    workflows: Vec<Workflow>,
660    handlers: HashMap<String, BoxedFunctionHandler>,
661    observer: Option<Arc<dyn ExecutionObserver>>,
662    datalogic_operators: HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>,
663    error_context_path: Option<String>,
664    error_context_limit: Option<usize>,
665}
666
667impl EngineBuilder {
668    /// Create an empty builder. Equivalent to [`EngineBuilder::default`].
669    pub fn new() -> Self {
670        Self::default()
671    }
672
673    /// Register a custom async handler under `name`. Accepts any
674    /// `AsyncFunctionHandler`; boxing happens internally via the engine's
675    /// blanket impl.
676    pub fn register<F>(mut self, name: impl Into<String>, handler: F) -> Self
677    where
678        F: AsyncFunctionHandler,
679    {
680        self.handlers.insert(name.into(), Box::new(handler));
681        self
682    }
683
684    /// Register a pre-boxed handler. Useful when handlers are constructed
685    /// dynamically (e.g. plugin registries) and the concrete type isn't
686    /// known at the call site.
687    pub fn register_boxed(
688        mut self,
689        name: impl Into<String>,
690        handler: BoxedFunctionHandler,
691    ) -> Self {
692        self.handlers.insert(name.into(), handler);
693        self
694    }
695
696    /// Add a single workflow. Subsequent calls append.
697    pub fn with_workflow(mut self, workflow: Workflow) -> Self {
698        self.workflows.push(workflow);
699        self
700    }
701
702    /// Append every workflow in `workflows`. Accepts anything iterable —
703    /// `Vec<Workflow>`, an array, an iterator. Existing workflows on the
704    /// builder are kept; subsequent registers/workflows still chain.
705    pub fn with_workflows<I>(mut self, workflows: I) -> Self
706    where
707        I: IntoIterator<Item = Workflow>,
708    {
709        self.workflows.extend(workflows);
710        self
711    }
712
713    /// Insert every handler in `handlers`, keeping any already registered.
714    ///
715    /// Same extend-not-replace semantics as [`EngineBuilder::with_workflows`].
716    /// Exists because `register` is per-name, which pushed an embedder that
717    /// builds a whole `HashMap<String, BoxedFunctionHandler>` in one place onto
718    /// [`Engine::new`] and off the builder entirely — and therefore out of reach
719    /// of [`EngineBuilder::with_observer`].
720    pub fn with_handlers(mut self, handlers: HashMap<String, BoxedFunctionHandler>) -> Self {
721        self.handlers.extend(handlers);
722        self
723    }
724
725    /// Attach a per-task [`ExecutionObserver`]. Later calls replace the previous
726    /// one.
727    ///
728    /// This is the only way to time the sync built-ins, which are dispatched
729    /// inside the executor and never reach the function registry. With no
730    /// observer attached the instrumentation — including its clock reads — stays
731    /// out of the dispatch path entirely.
732    pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
733        self.observer = Some(observer);
734        self
735    }
736
737    /// Mirror per-task failure codes into the message context at `path`, so a
738    /// downstream `condition` or `map` can branch on *why* a task failed.
739    ///
740    /// Off unless called: with no path configured nothing is written and the
741    /// mechanism costs one `Option` check on a path that only runs after a task
742    /// has already failed.
743    ///
744    /// One record is appended per error a task contributes to
745    /// [`Message::errors`](crate::engine::message::Message::errors):
746    ///
747    /// ```json
748    /// { "workflow_id": "place_order", "task_id": "charge_payment",
749    ///   "code": "TIMEOUT_ERROR", "status": 500 }
750    /// ```
751    ///
752    /// so a later task can gate on the reason:
753    ///
754    /// ```json
755    /// { "in": [ { "var": "metadata.errors.0.code" },
756    ///           ["TIMEOUT_ERROR", "IO_ERROR"] ] }
757    /// ```
758    ///
759    /// Coverage matches `errors()` exactly — a handler returning `Err`, a task
760    /// returning a 5xx outcome, the `validation` built-in's per-rule failures, and
761    /// anything a handler adds through
762    /// [`TaskContext::add_error`](crate::engine::task_context::TaskContext::add_error)
763    /// all appear. The workflow-level `WORKFLOW_ERROR` wrapper does not: it
764    /// re-reports the same underlying failure, so mirroring it would double-count.
765    ///
766    /// `status` is the task's own status — `500` when the handler returned `Err`,
767    /// otherwise the status the outcome carried (`400` for `validation`). That is
768    /// the distinction `metadata.progress` cannot make, since its failure arm
769    /// hard-codes `500`.
770    ///
771    /// The error `message` and the operator-only `detail` are deliberately **not**
772    /// recorded: the context is serialized back to callers, and `detail` is
773    /// documented as unsafe to hand to an untrusted one. Read those from
774    /// `message.errors()` host-side.
775    ///
776    /// `path` must start with `data`, `metadata` or `temp_data` — the JSONLogic
777    /// evaluation context is exactly those three slots — and may not be
778    /// `metadata.progress`. Violations fail [`EngineBuilder::build`].
779    pub fn with_error_context_path(mut self, path: impl Into<String>) -> Self {
780        self.error_context_path = Some(path.into());
781        self
782    }
783
784    /// Cap the number of records retained at the error-context path, keeping the
785    /// most recent (default 32).
786    ///
787    /// The bound is what keeps the option's memory cost independent of a looping
788    /// workflow's iteration count: `Message.context` is deep-cloned into every
789    /// trace snapshot, so an uncapped list in a loop with a failing body grows the
790    /// trace quadratically. Conditions overwhelmingly read the latest failure, so
791    /// the oldest records are the ones dropped.
792    ///
793    /// Setting a limit without a path is inert, not an error. A limit of `0` fails
794    /// [`EngineBuilder::build`].
795    pub fn with_error_context_limit(mut self, limit: usize) -> Self {
796        self.error_context_limit = Some(limit);
797        self
798    }
799
800    /// Register a custom JSONLogic operator on the engine's internal datalogic
801    /// instance, under `name`. Later calls with the same name replace the
802    /// earlier registration.
803    ///
804    /// This is the host's door for domain operators: the engine builds (and on
805    /// [`Engine::with_new_workflows`] *rebuilds*) its datalogic engine
806    /// internally, where registration is builder-only — so operators must
807    /// enter here to exist at all, and are retained on the engine so every
808    /// hot reload re-registers them.
809    ///
810    /// Semantics follow `datalogic_rs`: arguments arrive pre-evaluated, and a
811    /// built-in operator name always wins over a custom registration — pick
812    /// names no built-in uses. Because the engine always runs in templating
813    /// mode, a name that is *not* registered is not an error: the object
814    /// echoes back as literal data, exactly like a disabled operator family.
815    /// Registering a name therefore converts previously-inert values into
816    /// live operator calls, the same caveat the cargo features carry.
817    pub fn with_datalogic_operator<T>(mut self, name: impl Into<String>, operator: T) -> Self
818    where
819        T: datalogic_rs::CustomOperator + 'static,
820    {
821        self.datalogic_operators
822            .insert(name.into(), Arc::new(operator));
823        self
824    }
825
826    /// Compile the workflows, pre-parse Custom inputs, and produce the
827    /// engine. Compile errors and missing handler references surface here —
828    /// the engine never deserializes Custom config on the hot path.
829    pub fn build(self) -> Result<Engine> {
830        // Validated here rather than at the setter so an invalid path fails at
831        // engine construction alongside every other config-shape error, instead
832        // of on the first message that happens to fail a task.
833        let error_context = match self.error_context_path {
834            Some(path) => Some(Arc::new(ErrorContextConfig::new(
835                path,
836                self.error_context_limit
837                    .unwrap_or(DEFAULT_ERROR_CONTEXT_LIMIT),
838            )?)),
839            None => None,
840        };
841        let engine = Engine::new_with_operators(
842            self.workflows,
843            self.handlers,
844            Arc::new(self.datalogic_operators),
845        )?;
846        let engine = match error_context {
847            Some(cfg) => engine.with_error_context(cfg),
848            None => engine,
849        };
850        Ok(match self.observer {
851            Some(observer) => engine.with_observer(observer),
852            None => engine,
853        })
854    }
855}
856
857/// Walk every task in every workflow; for each `FunctionConfig::Custom`,
858/// look up the registered handler and ask it to parse the raw `input` JSON
859/// into its typed `Self::Input` (boxed as `dyn Any`). The cached result is
860/// stored on the task — dispatch then hands the handler a `&dyn Any` it
861/// downcasts in O(1).
862///
863/// Built-in async configs (`HttpCall`, `Enrich`, `PublishKafka`) are already
864/// parsed by serde's `untagged` representation on `FunctionConfig`; they
865/// need no second pass.
866///
867/// Returns `FunctionNotFound` when a Custom task references an unregistered
868/// handler — moves the failure from "first message" to engine construction.
869fn precompile_custom_inputs(
870    workflows: &mut [Workflow],
871    handlers: &HashMap<String, BoxedFunctionHandler>,
872    datalogic: &Arc<DatalogicEngine>,
873) -> Result<()> {
874    let template_compiler = TemplateCompiler::new(Arc::clone(datalogic));
875    for workflow in workflows {
876        for task in &mut workflow.tasks {
877            if let FunctionConfig::Custom {
878                name,
879                input,
880                compiled_input,
881            } = &mut task.function
882            {
883                let handler = handlers
884                    .get(name)
885                    .ok_or_else(|| function_not_found_error(name, handlers))?;
886                let mut parsed = handler.parse_input_box(input)?;
887                handler.compile_input_box(&mut *parsed, &template_compiler)?;
888                *compiled_input = Some(CompiledCustomInput(Arc::from(parsed)));
889            }
890        }
891    }
892    Ok(())
893}
894
895/// Build a `FunctionNotFound` error that lists both the registered custom
896/// handlers and the names of built-in functions, so a user with a typo
897/// (e.g. `htttp_call`) can immediately spot the intended name.
898///
899/// **This message is free-form and deliberately unpinned.** It is a diagnostic
900/// for humans; its wording and layout may change in any release. No test
901/// asserts on it, and none should — a caller that needs the built-in vocabulary
902/// programmatically should use [`crate::BUILTIN_FUNCTION_NAMES`] and
903/// [`crate::builtin_function_kind`], which exist for exactly that purpose and
904/// answer the sharper question of whether a name needs a registered handler.
905fn function_not_found_error(
906    name: &str,
907    handlers: &HashMap<String, BoxedFunctionHandler>,
908) -> DataflowError {
909    use crate::engine::functions::config::BUILTIN_FUNCTION_NAMES;
910    let mut registered: Vec<&str> = handlers.keys().map(String::as_str).collect();
911    registered.sort_unstable();
912    let registered_part = if registered.is_empty() {
913        String::from("none")
914    } else {
915        registered.join(", ")
916    };
917    DataflowError::FunctionNotFound(format!(
918        "{name} (registered handlers: {registered_part}; built-ins: {})",
919        BUILTIN_FUNCTION_NAMES.join(", ")
920    ))
921}
922
923/// Stamp the standard processing metadata (`processed_at`, `engine_version`,
924/// and optionally `channel`) into the message context.
925///
926/// `now` is captured once at the top of `process_message` and reused so the
927/// timestamp on `metadata.processed_at` matches the one used for every
928/// `AuditTrail` entry within the same call.
929///
930/// Walks to the `metadata` object once and sets every key in a single pass,
931/// instead of one full `"metadata.*"` path split + tree walk per key.
932/// Mirrors `set_nested_value` semantics for the degenerate shapes: a
933/// non-object context or a non-object existing `metadata` slot no-ops; a
934/// missing `metadata` slot is created.
935///
936/// `(**engine_version).clone()` deep-clones the inner `String` — the
937/// context owns its values, so one small allocation per message is
938/// inherent; the cached `Arc` only saves re-formatting the version.
939fn set_processing_metadata(
940    context: &mut OwnedDataValue,
941    engine_version: &Arc<OwnedDataValue>,
942    now: chrono::DateTime<Utc>,
943    channel: Option<&str>,
944) {
945    let OwnedDataValue::Object(top) = context else {
946        return;
947    };
948    let metadata = match top.iter().position(|(k, _)| k == "metadata") {
949        Some(i) => &mut top[i].1,
950        None => {
951            top.push(("metadata".to_string(), OwnedDataValue::Object(Vec::new())));
952            &mut top.last_mut().expect("just pushed").1
953        }
954    };
955    let OwnedDataValue::Object(meta) = metadata else {
956        return;
957    };
958
959    let mut set_key = |key: &str, value: OwnedDataValue| {
960        if let Some(slot) = meta.iter_mut().find(|(k, _)| k == key) {
961            slot.1 = value;
962        } else {
963            meta.push((key.to_string(), value));
964        }
965    };
966    set_key("processed_at", OwnedDataValue::String(now.to_rfc3339()));
967    set_key("engine_version", (**engine_version).clone());
968    if let Some(channel) = channel {
969        set_key("channel", OwnedDataValue::String(channel.to_string()));
970    }
971}