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