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 task;
63pub mod task_context;
64pub mod task_executor;
65pub mod task_outcome;
66pub mod trace;
67pub mod utils;
68pub mod workflow;
69pub mod workflow_executor;
70
71// Re-export key types for easier access
72pub use error::{DataflowError, ErrorInfo, Result};
73pub use functions::{
74    AsyncFunctionHandler, BoxedFunctionHandler, CompiledCustomInput, DynAsyncFunctionHandler,
75    FunctionConfig,
76};
77pub use message::Message;
78pub use task::Task;
79pub use task_context::TaskContext;
80pub use task_outcome::TaskOutcome;
81pub use trace::{ExecutionStep, ExecutionTrace, StepResult};
82pub use workflow::{Workflow, WorkflowStatus};
83
84// `EngineBuilder` is defined further down in this file but exposed here so
85// downstream paths can import it via `dataflow_rs::engine::EngineBuilder`.
86
87use chrono::Utc;
88use datalogic_rs::Engine as DatalogicEngine;
89use datavalue::OwnedDataValue;
90use std::collections::HashMap;
91use std::sync::Arc;
92
93use compiler::LogicCompiler;
94use task_executor::TaskExecutor;
95use workflow_executor::WorkflowExecutor;
96
97/// High-performance async workflow engine for message processing.
98///
99/// ## Architecture
100///
101/// The engine is designed for async-first operation with Tokio:
102/// - **Separation of Concerns**: Distinct executors for workflows and tasks
103/// - **Shared datalogic engine**: Single `datalogic_rs::Engine` wrapped in `Arc` for thread-safe sharing
104/// - **Arc<Logic>**: Pre-compiled logic shared across all async tasks
105/// - **Async Functions**: Native async support for I/O-bound operations
106///
107/// ## Performance Characteristics
108///
109/// - **Zero Runtime Compilation**: All logic compiled during initialization
110/// - **Zero-Copy Sharing**: Arc-wrapped compiled logic shared without cloning
111/// - **Optimal for Mixed Workloads**: Async I/O with blocking CPU evaluation
112/// - **Thread-Safe by Design**: All components safe to share across Tokio tasks
113pub struct Engine {
114    /// Registry of available workflows, pre-sorted by priority (immutable after initialization).
115    /// Each workflow / task / function-config holds its own `Arc<Logic>` slots
116    /// — there is no central logic cache anymore.
117    workflows: Arc<Vec<Workflow>>,
118    /// Channel index: maps channel name -> indices into workflows vec (only Active workflows)
119    channel_index: Arc<HashMap<String, Vec<usize>>>,
120    /// Workflow executor for orchestrating workflow execution
121    workflow_executor: Arc<WorkflowExecutor>,
122    /// Shared datalogic v5 engine for JSONLogic evaluation (Send + Sync)
123    datalogic: Arc<DatalogicEngine>,
124    /// Pre-built `Arc<OwnedDataValue::String>` of the engine version.
125    /// Built once at construction. Note the per-message stamp still clones
126    /// the inner `String` — the context owns its values, so the cached
127    /// form only saves re-formatting, not the (small) allocation.
128    engine_version: Arc<OwnedDataValue>,
129}
130
131/// Build a channel index from pre-sorted workflows.
132/// Maps channel name -> indices into workflows vec, only for Active workflows.
133fn build_channel_index(workflows: &[Workflow]) -> HashMap<String, Vec<usize>> {
134    let mut index: HashMap<String, Vec<usize>> = HashMap::new();
135    for (i, workflow) in workflows.iter().enumerate() {
136        if workflow.status == WorkflowStatus::Active {
137            index.entry(workflow.channel.clone()).or_default().push(i);
138        }
139    }
140    index
141}
142
143impl Engine {
144    /// Creates a new Engine instance.
145    ///
146    /// Compiles every workflow / task / function-config JSONLogic expression
147    /// up-front. Returns `Err(DataflowError)` if any required expression
148    /// fails to compile — fail-loud at construction time instead of silently
149    /// dropping broken workflows at runtime.
150    ///
151    /// # Arguments
152    /// * `workflows` - The workflows to use for processing messages
153    /// * `custom_functions` - Custom async function handlers (use
154    ///   `HashMap::new()` for none, or prefer [`Engine::builder`])
155    ///
156    /// # Example
157    ///
158    /// ```
159    /// use dataflow_rs::{Engine, Workflow};
160    ///
161    /// 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()];
162    ///
163    /// let engine = Engine::builder().with_workflows(workflows).build().unwrap();
164    /// ```
165    /// The recommended construction path is [`Engine::builder`]. `Engine::new`
166    /// is the lower-level escape hatch — accepts handlers as a plain
167    /// `HashMap` (use `HashMap::new()` for the no-handler case).
168    pub fn new(
169        workflows: Vec<Workflow>,
170        custom_functions: HashMap<String, BoxedFunctionHandler>,
171    ) -> Result<Self> {
172        // Compile workflows (sorted by priority at compile time). Each
173        // workflow/task/config owns its own `Arc<Logic>` slots — no central
174        // cache to return. Any compile failure bubbles up immediately.
175        let compiler = LogicCompiler::new();
176        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
177        let datalogic = compiler.into_engine();
178
179        let task_functions = custom_functions;
180
181        // Pre-parse `FunctionConfig::Custom { input }` JSON into the
182        // registered handler's typed `Self::Input`, caching the boxed value
183        // on the task. Misshapen Custom configs fail here, not on first
184        // message — matches the "fail loud at startup" stance for compiled
185        // logic. Built-in async configs (HttpCall/Enrich/PublishKafka) are
186        // already typed by serde and need no second pass.
187        precompile_custom_inputs(&mut sorted_workflows, &task_functions)?;
188
189        let task_executor = Arc::new(TaskExecutor::new(
190            Arc::new(task_functions),
191            Arc::clone(&datalogic),
192        ));
193
194        let workflow_executor =
195            Arc::new(WorkflowExecutor::new(task_executor, Arc::clone(&datalogic)));
196
197        // Build channel index for O(1) channel-based routing
198        let channel_index = build_channel_index(&sorted_workflows);
199
200        Ok(Self {
201            workflows: Arc::new(sorted_workflows),
202            channel_index: Arc::new(channel_index),
203            workflow_executor,
204            datalogic,
205            engine_version: Arc::new(OwnedDataValue::String(
206                env!("CARGO_PKG_VERSION").to_string(),
207            )),
208        })
209    }
210
211    /// Start building an engine. The recommended construction path —
212    /// chains `register("name", handler)` and `with_workflow(w)` calls,
213    /// then `build()` to produce a `Result<Engine>`.
214    ///
215    /// ```no_run
216    /// use dataflow_rs::{Engine, Workflow};
217    /// # let workflow: Workflow = unimplemented!();
218    /// let engine = Engine::builder()
219    ///     .with_workflow(workflow)
220    ///     // .register("my_handler", MyHandler)  // any AsyncFunctionHandler
221    ///     .build()
222    ///     .unwrap();
223    /// ```
224    pub fn builder() -> EngineBuilder {
225        EngineBuilder::new()
226    }
227
228    /// Cached `OwnedDataValue::String` of the engine version.
229    pub fn engine_version_value(&self) -> &OwnedDataValue {
230        &self.engine_version
231    }
232
233    /// Creates a new Engine with different workflows but the same custom function handlers.
234    ///
235    /// This is the hot-reload path. The existing engine remains valid for any
236    /// in-flight `process_message` calls. The returned engine shares the same
237    /// function registry (zero-copy Arc bump) but has freshly compiled logic
238    /// for the new workflow set.
239    ///
240    /// # Arguments
241    /// * `workflows` - The new set of workflows to compile and use
242    pub fn with_new_workflows(&self, workflows: Vec<Workflow>) -> Result<Self> {
243        // Extract the shared function registry from the existing executor
244        let task_functions = self.workflow_executor.task_functions();
245
246        // Compile new workflows with a fresh datalogic engine instance.
247        let compiler = LogicCompiler::new();
248        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
249        let datalogic = compiler.into_engine();
250
251        // Pre-parse Custom inputs against the existing handler registry —
252        // hot-reload still validates the new workflow set against the
253        // already-registered handlers.
254        precompile_custom_inputs(&mut sorted_workflows, &task_functions)?;
255
256        // Rebuild the executor stack, reusing the existing function registry
257        let task_executor = Arc::new(TaskExecutor::new(task_functions, Arc::clone(&datalogic)));
258
259        let workflow_executor =
260            Arc::new(WorkflowExecutor::new(task_executor, Arc::clone(&datalogic)));
261
262        // Build channel index for O(1) channel-based routing
263        let channel_index = build_channel_index(&sorted_workflows);
264
265        Ok(Self {
266            workflows: Arc::new(sorted_workflows),
267            channel_index: Arc::new(channel_index),
268            workflow_executor,
269            datalogic,
270            engine_version: Arc::clone(&self.engine_version),
271        })
272    }
273
274    /// Processes a message through workflows that match their conditions.
275    ///
276    /// This async method:
277    /// 1. Iterates through workflows sequentially in priority order (pre-sorted at construction)
278    /// 2. Delegates workflow execution to the WorkflowExecutor
279    /// 3. Updates message metadata
280    ///
281    /// # Error contract
282    ///
283    /// Errors flow through two complementary channels:
284    /// - `message.errors()` — **always** contains every error encountered
285    ///   (validation failures, task panics, 5xx-status outcomes, workflow
286    ///   wrappers). Callers that want a uniform view inspect this list.
287    /// - `Result::Err` — signals **only** that the engine stopped before
288    ///   processing every workflow. Callers that want fail-fast match on
289    ///   this. The error pushed to `message.errors` for the same failure
290    ///   carries the workflow context (id) that the bare `Err` doesn't.
291    ///
292    /// In particular: a workflow with `continue_on_error: true` records its
293    /// errors to `message.errors` and returns `Ok(())` here. A workflow
294    /// with `continue_on_error: false` records to `message.errors` *and*
295    /// returns `Result::Err` (which short-circuits the rest of this call).
296    ///
297    /// # Arguments
298    /// * `message` - The message to process through workflows
299    ///
300    /// # Returns
301    /// * `Result<()>` — `Ok(())` if every workflow completed (each may have
302    ///   pushed errors to `message.errors`); `Err(e)` if the engine
303    ///   stopped early on a hard failure.
304    pub async fn process_message(&self, message: &mut Message) -> Result<()> {
305        // Capture a single timestamp for the entire process_message call. The
306        // workflow executor reads it back via Message metadata if it needs to
307        // emit AuditTrail entries; this caps the number of `Utc::now()` syscalls
308        // at 1 per message (down from 3+ — one stamp here, one per AuditTrail).
309        let now = Utc::now();
310        set_processing_metadata(&mut message.context, &self.engine_version, now, None);
311
312        // Process workflows in priority order (pre-sorted at construction).
313        // `run_all_borrowed` groups consecutive fully-sync workflows into a
314        // single shared-arena scope so the context is deep-walked once per
315        // run rather than once per workflow. Passing the registry slice
316        // directly avoids the former per-message `Vec<&Workflow>` collect.
317        self.workflow_executor
318            .run_all_borrowed(&self.workflows[..], message, None, now)
319            .await
320    }
321
322    /// Processes a message through workflows with step-by-step tracing.
323    ///
324    /// This method is similar to `process_message` but captures an execution trace
325    /// that can be used for debugging and step-by-step visualization.
326    ///
327    /// # Arguments
328    /// * `message` - The message to process through workflows
329    ///
330    /// # Returns
331    /// * `Result<ExecutionTrace>` - The execution trace with message snapshots
332    pub async fn process_message_with_trace(
333        &self,
334        message: &mut Message,
335    ) -> Result<ExecutionTrace> {
336        use trace::ExecutionTrace;
337
338        let now = Utc::now();
339        set_processing_metadata(&mut message.context, &self.engine_version, now, None);
340
341        let mut trace = ExecutionTrace::new();
342
343        // Process workflows in priority order (pre-sorted at construction).
344        self.workflow_executor
345            .run_all_borrowed(&self.workflows[..], message, Some(&mut trace), now)
346            .await?;
347
348        Ok(trace)
349    }
350
351    /// Processes a message through only the Active workflows registered for a given channel.
352    ///
353    /// Workflows are processed in priority order (lowest first), same as process_message().
354    /// If the channel does not exist or has no Active workflows, this is a no-op.
355    ///
356    /// # Arguments
357    /// * `channel` - The channel name to route the message through
358    /// * `message` - The message to process
359    pub async fn process_message_for_channel(
360        &self,
361        channel: &str,
362        message: &mut Message,
363    ) -> Result<()> {
364        let now = Utc::now();
365        set_processing_metadata(
366            &mut message.context,
367            &self.engine_version,
368            now,
369            Some(channel),
370        );
371
372        if let Some(indices) = self.channel_index.get(channel) {
373            // Channel-selected workflows are non-contiguous in the registry,
374            // so the pointer collect stays on this path.
375            let workflows: Vec<&Workflow> =
376                indices.iter().map(|&idx| &self.workflows[idx]).collect();
377            self.workflow_executor
378                .run_all_borrowed(&workflows, message, None, now)
379                .await?;
380        }
381
382        Ok(())
383    }
384
385    /// Processes a message through a channel with step-by-step tracing.
386    ///
387    /// # Arguments
388    /// * `channel` - The channel name to route the message through
389    /// * `message` - The message to process
390    pub async fn process_message_for_channel_with_trace(
391        &self,
392        channel: &str,
393        message: &mut Message,
394    ) -> Result<ExecutionTrace> {
395        use trace::ExecutionTrace;
396
397        let now = Utc::now();
398        set_processing_metadata(
399            &mut message.context,
400            &self.engine_version,
401            now,
402            Some(channel),
403        );
404
405        let mut trace = ExecutionTrace::new();
406
407        if let Some(indices) = self.channel_index.get(channel) {
408            let workflows: Vec<&Workflow> =
409                indices.iter().map(|&idx| &self.workflows[idx]).collect();
410            self.workflow_executor
411                .run_all_borrowed(&workflows, message, Some(&mut trace), now)
412                .await?;
413        }
414
415        Ok(trace)
416    }
417
418    /// Get a reference to the workflows (pre-sorted by priority)
419    pub fn workflows(&self) -> &Arc<Vec<Workflow>> {
420        &self.workflows
421    }
422
423    /// Look up a workflow by its ID
424    pub fn workflow_by_id(&self, id: &str) -> Option<&Workflow> {
425        self.workflows.iter().find(|w| w.id == id)
426    }
427
428    /// Get a reference to the underlying datalogic v5 engine.
429    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
430        &self.datalogic
431    }
432}
433
434/// Builder for [`Engine`]. The recommended construction path — chain
435/// `register("name", handler)` and `with_workflow(workflow)` calls, then
436/// `build()` to produce a `Result<Engine>`. Empty registration is fine; an
437/// engine with no custom handlers still resolves the built-in functions.
438///
439/// `register` takes any [`AsyncFunctionHandler`] and boxes it internally; the
440/// `Box<dyn DynAsyncFunctionHandler + Send + Sync>` plumbing stays out of
441/// user code.
442///
443/// ```no_run
444/// use dataflow_rs::{Engine, Workflow};
445/// # let workflow: Workflow = unimplemented!();
446/// let engine = Engine::builder()
447///     .with_workflow(workflow)
448///     // .register("my_handler", MyHandler)
449///     .build()
450///     .unwrap();
451/// ```
452#[must_use = "EngineBuilder must be `.build()` to produce an Engine"]
453#[derive(Default)]
454pub struct EngineBuilder {
455    workflows: Vec<Workflow>,
456    handlers: HashMap<String, BoxedFunctionHandler>,
457}
458
459impl EngineBuilder {
460    /// Create an empty builder. Equivalent to [`EngineBuilder::default`].
461    pub fn new() -> Self {
462        Self::default()
463    }
464
465    /// Register a custom async handler under `name`. Accepts any
466    /// `AsyncFunctionHandler`; boxing happens internally via the engine's
467    /// blanket impl.
468    pub fn register<F>(mut self, name: impl Into<String>, handler: F) -> Self
469    where
470        F: AsyncFunctionHandler,
471    {
472        self.handlers.insert(name.into(), Box::new(handler));
473        self
474    }
475
476    /// Register a pre-boxed handler. Useful when handlers are constructed
477    /// dynamically (e.g. plugin registries) and the concrete type isn't
478    /// known at the call site.
479    pub fn register_boxed(
480        mut self,
481        name: impl Into<String>,
482        handler: BoxedFunctionHandler,
483    ) -> Self {
484        self.handlers.insert(name.into(), handler);
485        self
486    }
487
488    /// Add a single workflow. Subsequent calls append.
489    pub fn with_workflow(mut self, workflow: Workflow) -> Self {
490        self.workflows.push(workflow);
491        self
492    }
493
494    /// Append every workflow in `workflows`. Accepts anything iterable —
495    /// `Vec<Workflow>`, an array, an iterator. Existing workflows on the
496    /// builder are kept; subsequent registers/workflows still chain.
497    pub fn with_workflows<I>(mut self, workflows: I) -> Self
498    where
499        I: IntoIterator<Item = Workflow>,
500    {
501        self.workflows.extend(workflows);
502        self
503    }
504
505    /// Compile the workflows, pre-parse Custom inputs, and produce the
506    /// engine. Compile errors and missing handler references surface here —
507    /// the engine never deserializes Custom config on the hot path.
508    pub fn build(self) -> Result<Engine> {
509        Engine::new(self.workflows, self.handlers)
510    }
511}
512
513/// Walk every task in every workflow; for each `FunctionConfig::Custom`,
514/// look up the registered handler and ask it to parse the raw `input` JSON
515/// into its typed `Self::Input` (boxed as `dyn Any`). The cached result is
516/// stored on the task — dispatch then hands the handler a `&dyn Any` it
517/// downcasts in O(1).
518///
519/// Built-in async configs (`HttpCall`, `Enrich`, `PublishKafka`) are already
520/// parsed by serde's `untagged` representation on `FunctionConfig`; they
521/// need no second pass.
522///
523/// Returns `FunctionNotFound` when a Custom task references an unregistered
524/// handler — moves the failure from "first message" to engine construction.
525fn precompile_custom_inputs(
526    workflows: &mut [Workflow],
527    handlers: &HashMap<String, BoxedFunctionHandler>,
528) -> Result<()> {
529    for workflow in workflows {
530        for task in &mut workflow.tasks {
531            if let FunctionConfig::Custom {
532                name,
533                input,
534                compiled_input,
535            } = &mut task.function
536            {
537                let handler = handlers
538                    .get(name)
539                    .ok_or_else(|| function_not_found_error(name, handlers))?;
540                let parsed = handler.parse_input_box(input)?;
541                *compiled_input = Some(CompiledCustomInput(Arc::from(parsed)));
542            }
543        }
544    }
545    Ok(())
546}
547
548/// Build a `FunctionNotFound` error that lists both the registered custom
549/// handlers and the names of built-in functions, so a user with a typo
550/// (e.g. `htttp_call`) can immediately spot the intended name.
551fn function_not_found_error(
552    name: &str,
553    handlers: &HashMap<String, BoxedFunctionHandler>,
554) -> DataflowError {
555    use crate::engine::functions::config::BUILTIN_FUNCTION_NAMES;
556    let mut registered: Vec<&str> = handlers.keys().map(String::as_str).collect();
557    registered.sort_unstable();
558    let registered_part = if registered.is_empty() {
559        String::from("none")
560    } else {
561        registered.join(", ")
562    };
563    DataflowError::FunctionNotFound(format!(
564        "{name} (registered handlers: {registered_part}; built-ins: {})",
565        BUILTIN_FUNCTION_NAMES.join(", ")
566    ))
567}
568
569/// Stamp the standard processing metadata (`processed_at`, `engine_version`,
570/// and optionally `channel`) into the message context.
571///
572/// `now` is captured once at the top of `process_message` and reused so the
573/// timestamp on `metadata.processed_at` matches the one used for every
574/// `AuditTrail` entry within the same call.
575///
576/// Walks to the `metadata` object once and sets every key in a single pass,
577/// instead of one full `"metadata.*"` path split + tree walk per key.
578/// Mirrors `set_nested_value` semantics for the degenerate shapes: a
579/// non-object context or a non-object existing `metadata` slot no-ops; a
580/// missing `metadata` slot is created.
581///
582/// `(**engine_version).clone()` deep-clones the inner `String` — the
583/// context owns its values, so one small allocation per message is
584/// inherent; the cached `Arc` only saves re-formatting the version.
585fn set_processing_metadata(
586    context: &mut OwnedDataValue,
587    engine_version: &Arc<OwnedDataValue>,
588    now: chrono::DateTime<Utc>,
589    channel: Option<&str>,
590) {
591    let OwnedDataValue::Object(top) = context else {
592        return;
593    };
594    let metadata = match top.iter().position(|(k, _)| k == "metadata") {
595        Some(i) => &mut top[i].1,
596        None => {
597            top.push(("metadata".to_string(), OwnedDataValue::Object(Vec::new())));
598            &mut top.last_mut().expect("just pushed").1
599        }
600    };
601    let OwnedDataValue::Object(meta) = metadata else {
602        return;
603    };
604
605    let mut set_key = |key: &str, value: OwnedDataValue| {
606        if let Some(slot) = meta.iter_mut().find(|(k, _)| k == key) {
607            slot.1 = value;
608        } else {
609            meta.push((key.to_string(), value));
610        }
611    };
612    set_key("processed_at", OwnedDataValue::String(now.to_rfc3339()));
613    set_key("engine_version", (**engine_version).clone());
614    if let Some(channel) = channel {
615        set_key("channel", OwnedDataValue::String(channel.to_string()));
616    }
617}