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 authoring;
58pub mod compiler;
59pub mod error;
60pub mod executor;
61pub mod functions;
62pub mod message;
63pub mod observer;
64/// Retrying a failed operation. Not available on `wasm32` — tokio's time
65/// driver, which the backoff needs, does not run there.
66#[cfg(not(target_arch = "wasm32"))]
67pub mod retry;
68pub mod rollout;
69pub mod steps;
70pub mod task;
71pub mod task_context;
72pub mod task_executor;
73pub mod task_outcome;
74pub mod trace;
75pub mod utils;
76pub mod workflow;
77pub mod workflow_executor;
78
79// Re-export key types for easier access
80pub use authoring::{IssueCode, WorkflowIssue};
81use error::{DEFAULT_ERROR_CONTEXT_LIMIT, ErrorContextConfig};
82pub use error::{DataflowError, ErrorInfo, Result, ServiceErrorBuilder};
83pub use functions::{
84    AsyncFunctionHandler, BoxedFunctionHandler, CompiledCustomInput, DynAsyncFunctionHandler,
85    FunctionConfig, Template, TemplateCompiler,
86};
87pub use message::Message;
88pub use observer::{
89    ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
90    WorkflowStarted,
91};
92#[cfg(not(target_arch = "wasm32"))]
93pub use retry::{RetryPolicy, retry_with_attempts, retry_with_policy};
94pub use rollout::{Rollout, RolloutError};
95pub use steps::{
96    AuthoredStep, AuthoredSteps, MAX_GROUP_DEPTH, StepKind, is_group, walk_authored_steps,
97};
98pub use task::{Task, TaskGroup};
99pub use task_context::TaskContext;
100pub use task_outcome::{HALT_STATUS_CODE, TaskOutcome};
101pub use trace::{AuditTrailScope, ExecutionStep, ExecutionTrace, StepResult, TraceOptions};
102pub use workflow::{ConnectorRef, Workflow, WorkflowStatus};
103
104// `EngineBuilder` is defined further down in this file but exposed here so
105// downstream paths can import it via `dataflow_rs::engine::EngineBuilder`.
106
107use chrono::Utc;
108use datalogic_rs::Engine as DatalogicEngine;
109use datavalue::OwnedDataValue;
110use std::collections::HashMap;
111use std::sync::Arc;
112
113use crate::engine::functions::config::{
114    DispatchableFunction, can_dispatch_in, dispatchable_functions_in,
115};
116
117use compiler::LogicCompiler;
118use task_executor::TaskExecutor;
119use workflow_executor::WorkflowExecutor;
120
121/// High-performance async workflow engine for message processing.
122///
123/// ## Architecture
124///
125/// The engine is designed for async-first operation with Tokio:
126/// - **Separation of Concerns**: Distinct executors for workflows and tasks
127/// - **Shared datalogic engine**: Single `datalogic_rs::Engine` wrapped in `Arc` for thread-safe sharing
128/// - **Arc<Logic>**: Pre-compiled logic shared across all async tasks
129/// - **Async Functions**: Native async support for I/O-bound operations
130///
131/// ## Performance Characteristics
132///
133/// - **Zero Runtime Compilation**: All logic compiled during initialization
134/// - **Zero-Copy Sharing**: Arc-wrapped compiled logic shared without cloning
135/// - **Optimal for Mixed Workloads**: Async I/O with blocking CPU evaluation
136/// - **Thread-Safe by Design**: All components safe to share across Tokio tasks
137pub struct Engine {
138    /// Registry of available workflows, pre-sorted by priority (immutable after initialization).
139    /// Each workflow / task / function-config holds its own `Arc<Logic>` slots
140    /// — there is no central logic cache anymore.
141    workflows: Arc<Vec<Workflow>>,
142    /// Channel index: maps channel name -> indices into workflows vec (only Active workflows)
143    channel_index: Arc<HashMap<String, Vec<usize>>>,
144    /// Workflow executor for orchestrating workflow execution
145    workflow_executor: Arc<WorkflowExecutor>,
146    /// Shared datalogic v5 engine for JSONLogic evaluation (Send + Sync)
147    datalogic: Arc<DatalogicEngine>,
148    /// Custom JSONLogic operators registered via
149    /// [`EngineBuilder::with_datalogic_operator`]. Retained here — not just
150    /// applied once — because [`Engine::with_new_workflows`] builds a fresh
151    /// datalogic engine and must re-register them; holding only the built
152    /// engine would silently drop every custom operator at the first hot
153    /// reload.
154    datalogic_operators: DatalogicOperators,
155    /// Pre-built `Arc<OwnedDataValue::String>` of the engine version.
156    /// Built once at construction. Note the per-message stamp still clones
157    /// the inner `String` — the context owns its values, so the cached
158    /// form only saves re-formatting, not the (small) allocation.
159    engine_version: Arc<OwnedDataValue>,
160}
161
162/// The custom-operator registrations an engine carries across rebuilds.
163pub type DatalogicOperators = Arc<HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>>;
164
165/// Build a channel index from pre-sorted workflows.
166/// Maps channel name -> indices into workflows vec, only for Active workflows.
167fn build_channel_index(workflows: &[Workflow]) -> HashMap<String, Vec<usize>> {
168    let mut index: HashMap<String, Vec<usize>> = HashMap::new();
169    for (i, workflow) in workflows.iter().enumerate() {
170        if workflow.status == WorkflowStatus::Active {
171            index.entry(workflow.channel.clone()).or_default().push(i);
172        }
173    }
174    index
175}
176
177impl Engine {
178    /// Creates a new Engine instance.
179    ///
180    /// Compiles every workflow / task / function-config JSONLogic expression
181    /// up-front. Returns `Err(DataflowError)` if any required expression
182    /// fails to compile — fail-loud at construction time instead of silently
183    /// dropping broken workflows at runtime.
184    ///
185    /// # Arguments
186    /// * `workflows` - The workflows to use for processing messages
187    /// * `task_functions` - Custom async function handlers (use
188    ///   `HashMap::new()` for none, or prefer [`Engine::builder`])
189    ///
190    /// # Example
191    ///
192    /// ```
193    /// use dataflow_rs::{Engine, Workflow};
194    ///
195    /// 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()];
196    ///
197    /// let engine = Engine::builder().with_workflows(workflows).build().unwrap();
198    /// ```
199    /// The recommended construction path is [`Engine::builder`]. `Engine::new`
200    /// is the lower-level escape hatch — accepts handlers as a plain
201    /// `HashMap` (use `HashMap::new()` for the no-handler case).
202    pub fn new(
203        workflows: Vec<Workflow>,
204        task_functions: HashMap<String, BoxedFunctionHandler>,
205    ) -> Result<Self> {
206        Self::new_with_operators(workflows, task_functions, Arc::new(HashMap::new()))
207    }
208
209    /// As [`Engine::new`], with custom JSONLogic operators registered on the
210    /// datalogic engine (and retained across [`Engine::with_new_workflows`]).
211    /// The builder path is [`EngineBuilder::with_datalogic_operator`]; this is
212    /// its escape-hatch twin, matching `new`.
213    pub fn new_with_operators(
214        workflows: Vec<Workflow>,
215        task_functions: HashMap<String, BoxedFunctionHandler>,
216        datalogic_operators: DatalogicOperators,
217    ) -> Result<Self> {
218        // Compile workflows (sorted by priority at compile time). Each
219        // workflow/task/config owns its own `Arc<Logic>` slots — no central
220        // cache to return. Any compile failure bubbles up immediately.
221        let compiler = LogicCompiler::with_operators(&datalogic_operators);
222        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
223        let datalogic = compiler.into_engine();
224
225        // Pre-parse `FunctionConfig::Custom { input }` JSON into the
226        // registered handler's typed `Self::Input`, caching the boxed value
227        // on the task. Misshapen Custom configs fail here, not on first
228        // message — matches the "fail loud at startup" stance for compiled
229        // logic. Built-in async configs (HttpCall/Enrich/PublishKafka) are
230        // already typed by serde and need no second pass.
231        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
232
233        let task_executor = Arc::new(TaskExecutor::new(
234            Arc::new(task_functions),
235            Arc::clone(&datalogic),
236        ));
237
238        let workflow_executor =
239            Arc::new(WorkflowExecutor::new(task_executor, Arc::clone(&datalogic)));
240
241        // Build channel index for O(1) channel-based routing
242        let channel_index = build_channel_index(&sorted_workflows);
243
244        Ok(Self {
245            workflows: Arc::new(sorted_workflows),
246            channel_index: Arc::new(channel_index),
247            workflow_executor,
248            datalogic,
249            datalogic_operators,
250            engine_version: Arc::new(OwnedDataValue::String(
251                env!("CARGO_PKG_VERSION").to_string(),
252            )),
253        })
254    }
255
256    /// Start building an engine. The recommended construction path —
257    /// chains `register("name", handler)` and `with_workflow(w)` calls,
258    /// then `build()` to produce a `Result<Engine>`.
259    ///
260    /// ```no_run
261    /// use dataflow_rs::{Engine, Workflow};
262    /// # let workflow: Workflow = unimplemented!();
263    /// let engine = Engine::builder()
264    ///     .with_workflow(workflow)
265    ///     // .register("my_handler", MyHandler)  // any AsyncFunctionHandler
266    ///     .build()
267    ///     .unwrap();
268    /// ```
269    pub fn builder() -> EngineBuilder {
270        EngineBuilder::new()
271    }
272
273    /// Cached `OwnedDataValue::String` of the engine version.
274    pub fn engine_version_value(&self) -> &OwnedDataValue {
275        &self.engine_version
276    }
277
278    /// Creates a new Engine with different workflows but the same custom function handlers.
279    ///
280    /// This is the hot-reload path. The existing engine remains valid for any
281    /// in-flight `process_message` calls. The returned engine shares the same
282    /// function registry (zero-copy Arc bump) but has freshly compiled logic
283    /// for the new workflow set.
284    ///
285    /// # Arguments
286    /// * `workflows` - The new set of workflows to compile and use
287    pub fn with_new_workflows(&self, workflows: Vec<Workflow>) -> Result<Self> {
288        // Extract the shared function registry from the existing executor
289        let task_functions = self.workflow_executor.task_functions();
290
291        // Compile new workflows with a fresh datalogic engine instance —
292        // re-registering the retained custom operators, so a hot reload keeps
293        // the same operator vocabulary as the engine it replaces.
294        let compiler = LogicCompiler::with_operators(&self.datalogic_operators);
295        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
296        let datalogic = compiler.into_engine();
297
298        // Pre-parse Custom inputs against the existing handler registry —
299        // hot-reload still validates the new workflow set against the
300        // already-registered handlers.
301        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
302
303        // Rebuild the executor stack, reusing the existing function registry
304        let task_executor = Arc::new(TaskExecutor::new(task_functions, Arc::clone(&datalogic)));
305
306        // Carry the observer across the reload. Dropping it here would stop
307        // metrics silently at the first hot reload.
308        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&datalogic));
309        if let Some(observer) = self.workflow_executor.observer() {
310            executor = executor.with_observer(Arc::clone(observer));
311        }
312        // Same reasoning as the observer: dropping this would silently stop
313        // recording failure codes at the first hot reload.
314        if let Some(cfg) = self.workflow_executor.error_context() {
315            executor = executor.with_error_context(Arc::clone(cfg));
316        }
317        let workflow_executor = Arc::new(executor);
318
319        // Build channel index for O(1) channel-based routing
320        let channel_index = build_channel_index(&sorted_workflows);
321
322        Ok(Self {
323            workflows: Arc::new(sorted_workflows),
324            channel_index: Arc::new(channel_index),
325            workflow_executor,
326            datalogic,
327            datalogic_operators: Arc::clone(&self.datalogic_operators),
328            engine_version: Arc::clone(&self.engine_version),
329        })
330    }
331
332    /// Attach a per-task [`ExecutionObserver`], returning the updated engine.
333    ///
334    /// The escape hatch matching [`Engine::new`] — [`EngineBuilder::with_observer`]
335    /// is the recommended path. Rebuilds the executor stack around the existing
336    /// handler registry and datalogic engine, so nothing is recompiled; the cost
337    /// is a few `Arc` bumps.
338    ///
339    /// Carried across [`Engine::with_new_workflows`], so a hot reload does not
340    /// silently stop reporting.
341    pub fn with_observer(self, observer: Arc<dyn ExecutionObserver>) -> Self {
342        self.rebuild_executor(|executor| executor.with_observer(observer))
343    }
344
345    /// Mirror per-task failure codes into the message context, returning the
346    /// updated engine.
347    ///
348    /// The escape hatch matching [`Engine::new`];
349    /// [`EngineBuilder::with_error_context_path`] is the recommended path and the
350    /// only one that validates the path. Carried across
351    /// [`Engine::with_new_workflows`] and [`Engine::with_observer`].
352    pub(crate) fn with_error_context(self, cfg: Arc<ErrorContextConfig>) -> Self {
353        self.rebuild_executor(|executor| executor.with_error_context(cfg))
354    }
355
356    /// Rebuild the executor stack around the existing handler registry and
357    /// datalogic engine, applying `configure` to the fresh executor.
358    ///
359    /// Nothing is recompiled; the cost is a few `Arc` bumps. Every knob the old
360    /// executor held is re-applied first, because the rebuild otherwise drops
361    /// them — that is what would make `.with_error_context(..)` followed by
362    /// `.with_observer(..)` silently lose the former.
363    fn rebuild_executor(
364        self,
365        configure: impl FnOnce(WorkflowExecutor) -> WorkflowExecutor,
366    ) -> Self {
367        let task_executor = Arc::new(TaskExecutor::new(
368            self.workflow_executor.task_functions(),
369            Arc::clone(&self.datalogic),
370        ));
371        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&self.datalogic));
372        if let Some(observer) = self.workflow_executor.observer() {
373            executor = executor.with_observer(Arc::clone(observer));
374        }
375        if let Some(cfg) = self.workflow_executor.error_context() {
376            executor = executor.with_error_context(Arc::clone(cfg));
377        }
378        Self {
379            workflows: self.workflows,
380            channel_index: self.channel_index,
381            workflow_executor: Arc::new(configure(executor)),
382            datalogic: self.datalogic,
383            datalogic_operators: self.datalogic_operators,
384            engine_version: self.engine_version,
385        }
386    }
387
388    /// Processes a message through workflows that match their conditions.
389    ///
390    /// This async method:
391    /// 1. Iterates through workflows sequentially in priority order (pre-sorted at construction)
392    /// 2. Delegates workflow execution to the WorkflowExecutor
393    /// 3. Updates message metadata
394    ///
395    /// # Error contract
396    ///
397    /// Errors flow through two complementary channels:
398    /// - `message.errors()` — **always** contains every error encountered
399    ///   (validation failures, task panics, 5xx-status outcomes, workflow
400    ///   wrappers). Callers that want a uniform view inspect this list.
401    /// - `Result::Err` — signals **only** that the engine stopped before
402    ///   processing every workflow. Callers that want fail-fast match on
403    ///   this. The error pushed to `message.errors` for the same failure
404    ///   carries the workflow context (id) that the bare `Err` doesn't.
405    ///
406    /// In particular: a workflow with `continue_on_error: true` records its
407    /// errors to `message.errors` and returns `Ok(())` here. A workflow
408    /// with `continue_on_error: false` records to `message.errors` *and*
409    /// returns `Result::Err` (which short-circuits the rest of this call).
410    ///
411    /// # Arguments
412    /// * `message` - The message to process through workflows
413    ///
414    /// # Returns
415    /// * `Result<()>` — `Ok(())` if every workflow completed (each may have
416    ///   pushed errors to `message.errors`); `Err(e)` if the engine
417    ///   stopped early on a hard failure.
418    pub async fn process_message(&self, message: &mut Message) -> Result<()> {
419        // Capture a single timestamp for the entire process_message call. The
420        // workflow executor reads it back via Message metadata if it needs to
421        // emit AuditTrail entries; this caps the number of `Utc::now()` syscalls
422        // at 1 per message (down from 3+ — one stamp here, one per AuditTrail).
423        self.process_all(message, None, Utc::now()).await
424    }
425
426    /// Processes a message through workflows with step-by-step tracing,
427    /// recording into a caller-owned trace.
428    ///
429    /// Identical to [`Engine::process_message_with_trace`] except that the
430    /// trace is borrowed rather than returned, so the steps completed before a
431    /// hard failure survive the `Err`. That makes this the method to reach for
432    /// when the run you want to inspect is the run that failed — a returned
433    /// trace is dropped by the `?` at the call site, a borrowed one is not.
434    ///
435    /// Steps are **appended** to `trace`; any steps already present are
436    /// preserved, so a caller can accumulate across a chain of calls.
437    ///
438    /// The error contract is unchanged: `Ok(())` means every workflow was
439    /// processed (each may still have pushed to `message.errors`), and `Err(e)`
440    /// means the engine stopped early. See [`Engine::process_message`] for the
441    /// full contract.
442    ///
443    /// Note that the failing task's *own* step is not recorded — the engine
444    /// propagates the failure before appending it — so the retained trace ends
445    /// at the last known-good step rather than at the error. The error itself
446    /// is available from the returned `Err` and from `message.errors()`.
447    ///
448    /// # Arguments
449    /// * `message` - The message to process through workflows
450    /// * `trace` - Caller-owned trace to append steps to
451    ///
452    /// # Returns
453    /// * `Result<()>` — `Ok(())` if every workflow completed; `Err(e)` if the
454    ///   engine stopped early. In both cases `trace` holds the steps that ran.
455    pub async fn process_message_tracing(
456        &self,
457        message: &mut Message,
458        trace: &mut ExecutionTrace,
459    ) -> Result<()> {
460        // The trace carries its own capture policy, so nothing to pass here.
461        self.process_all(message, Some(trace), Utc::now()).await
462    }
463
464    /// Shared driver behind [`Self::process_message`] and
465    /// [`Self::process_message_tracing`] — stamps processing metadata and runs
466    /// every registered workflow in priority order. Mirrors [`Self::process_channel`]
467    /// for the whole-registry case.
468    ///
469    /// `run_all_borrowed` groups consecutive fully-sync workflows into a
470    /// single shared-arena scope so the context is deep-walked once per run
471    /// rather than once per workflow. Passing the registry slice directly
472    /// avoids a per-message `Vec<&Workflow>` collect.
473    async fn process_all(
474        &self,
475        message: &mut Message,
476        trace: Option<&mut ExecutionTrace>,
477        now: chrono::DateTime<Utc>,
478    ) -> Result<()> {
479        set_processing_metadata(&mut message.context, &self.engine_version, now, None);
480        self.workflow_executor
481            .run_all_borrowed(&self.workflows[..], message, trace, now)
482            .await
483    }
484
485    /// Processes a message through workflows with step-by-step tracing.
486    ///
487    /// This method is similar to `process_message` but captures an execution trace
488    /// that can be used for debugging and step-by-step visualization.
489    ///
490    /// Because the trace is returned by value, a `?` at the call site discards
491    /// it — on a hard failure this yields `Err` and no steps at all. Use
492    /// [`Engine::process_message_tracing`] to keep the steps that ran.
493    ///
494    /// # Arguments
495    /// * `message` - The message to process through workflows
496    ///
497    /// # Returns
498    /// * `Result<ExecutionTrace>` - The execution trace with message snapshots
499    pub async fn process_message_with_trace(
500        &self,
501        message: &mut Message,
502    ) -> Result<ExecutionTrace> {
503        self.process_message_with_trace_options(message, TraceOptions::default())
504            .await
505    }
506
507    /// Processes a message with tracing under an explicit capture policy.
508    ///
509    /// The default policy — what [`Engine::process_message_with_trace`] uses —
510    /// takes a full [`Message`] snapshot per executed step, which is unbounded
511    /// in message size and quadratic in task count. A host that *persists*
512    /// traces should bound them here rather than trimming the result
513    /// afterwards; by then the peak memory has already been paid.
514    ///
515    /// See [`TraceOptions`] for the knobs, and
516    /// [`Engine::process_message_tracing`] if you also need the steps to survive
517    /// a hard failure.
518    ///
519    /// # Arguments
520    /// * `message` - The message to process through workflows
521    /// * `options` - What to record for each step
522    pub async fn process_message_with_trace_options(
523        &self,
524        message: &mut Message,
525        options: TraceOptions,
526    ) -> Result<ExecutionTrace> {
527        let mut trace = ExecutionTrace::with_options(options);
528        self.process_message_tracing(message, &mut trace).await?;
529        Ok(trace)
530    }
531
532    /// Processes a message through only the Active workflows registered for a given channel.
533    ///
534    /// Workflows are processed in priority order (lowest first), same as process_message().
535    /// If the channel does not exist or has no Active workflows, this is a no-op.
536    ///
537    /// # Arguments
538    /// * `channel` - The channel name to route the message through
539    /// * `message` - The message to process
540    pub async fn process_message_for_channel(
541        &self,
542        channel: &str,
543        message: &mut Message,
544    ) -> Result<()> {
545        self.process_channel(channel, message, None, Utc::now())
546            .await
547    }
548
549    /// Channel-scoped variant of [`Engine::process_message_tracing`].
550    ///
551    /// As with [`Engine::process_message_for_channel`], an unknown channel — or
552    /// a channel with no Active workflows — is a no-op: this returns `Ok(())`
553    /// and leaves `trace` untouched. Steps are appended, matching
554    /// [`Engine::process_message_tracing`].
555    ///
556    /// # Arguments
557    /// * `channel` - The channel name to route the message through
558    /// * `message` - The message to process
559    /// * `trace` - Caller-owned trace to append steps to
560    pub async fn process_message_for_channel_tracing(
561        &self,
562        channel: &str,
563        message: &mut Message,
564        trace: &mut ExecutionTrace,
565    ) -> Result<()> {
566        self.process_channel(channel, message, Some(trace), Utc::now())
567            .await
568    }
569
570    /// Shared driver behind [`Self::process_message_for_channel`] and
571    /// [`Self::process_message_for_channel_tracing`] — stamps processing
572    /// metadata and runs only the channel's Active workflows. An unknown
573    /// channel, or one with no Active workflows, is a no-op.
574    async fn process_channel(
575        &self,
576        channel: &str,
577        message: &mut Message,
578        trace: Option<&mut ExecutionTrace>,
579        now: chrono::DateTime<Utc>,
580    ) -> Result<()> {
581        set_processing_metadata(
582            &mut message.context,
583            &self.engine_version,
584            now,
585            Some(channel),
586        );
587
588        if let Some(indices) = self.channel_index.get(channel) {
589            // Channel-selected workflows are non-contiguous in the registry,
590            // so the pointer collect stays on this path.
591            let workflows: Vec<&Workflow> =
592                indices.iter().map(|&idx| &self.workflows[idx]).collect();
593            self.workflow_executor
594                .run_all_borrowed(&workflows, message, trace, now)
595                .await?;
596        }
597
598        Ok(())
599    }
600
601    /// Processes a message through a channel with step-by-step tracing.
602    ///
603    /// Because the trace is returned by value, a `?` at the call site discards
604    /// it — on a hard failure this yields `Err` and no steps at all. Use
605    /// [`Engine::process_message_for_channel_tracing`] to keep the steps that
606    /// ran.
607    ///
608    /// # Arguments
609    /// * `channel` - The channel name to route the message through
610    /// * `message` - The message to process
611    pub async fn process_message_for_channel_with_trace(
612        &self,
613        channel: &str,
614        message: &mut Message,
615    ) -> Result<ExecutionTrace> {
616        self.process_message_for_channel_with_trace_options(
617            channel,
618            message,
619            TraceOptions::default(),
620        )
621        .await
622    }
623
624    /// Channel-scoped variant of
625    /// [`Engine::process_message_with_trace_options`].
626    ///
627    /// # Arguments
628    /// * `channel` - The channel name to route the message through
629    /// * `message` - The message to process
630    /// * `options` - What to record for each step
631    pub async fn process_message_for_channel_with_trace_options(
632        &self,
633        channel: &str,
634        message: &mut Message,
635        options: TraceOptions,
636    ) -> Result<ExecutionTrace> {
637        let mut trace = ExecutionTrace::with_options(options);
638        self.process_message_for_channel_tracing(channel, message, &mut trace)
639            .await?;
640        Ok(trace)
641    }
642
643    /// Get a reference to the workflows (pre-sorted by priority)
644    pub fn workflows(&self) -> &Arc<Vec<Workflow>> {
645        &self.workflows
646    }
647
648    /// Look up a workflow by its ID
649    pub fn workflow_by_id(&self, id: &str) -> Option<&Workflow> {
650        self.workflows.iter().find(|w| w.id == id)
651    }
652
653    /// Get a reference to the underlying datalogic v5 engine.
654    /// Every function this engine will dispatch: self-contained built-ins,
655    /// plus [`BuiltinKind::RequiresHandler`] built-ins and custom names with a
656    /// registered handler.
657    ///
658    /// This is the authoring-side vocabulary — what a host needs to screen a
659    /// workflow definition, build a completion catalogue, or offer a
660    /// did-you-mean on an unknown name, without keeping its own copy of the
661    /// list.
662    ///
663    /// Aliases are grouped: `validate` is yielded once carrying
664    /// `["validation"]`, not twice. [`Engine::can_dispatch`] does accept an
665    /// alias, so the two are deliberately different sets.
666    ///
667    /// **Ordering is not meaningful** and may change without notice; treat the
668    /// result as a set, and collect and sort if you need stable output.
669    ///
670    /// ```
671    /// use dataflow_rs::{BuiltinKind, Engine};
672    ///
673    /// let engine = Engine::builder().build().unwrap();
674    /// let mut names: Vec<&str> = engine.dispatchable_functions().map(|f| f.name).collect();
675    /// names.sort_unstable();
676    ///
677    /// // Self-contained built-ins need no registration…
678    /// assert!(names.contains(&"map"));
679    /// // …but `enrich` ships as a config schema only, so with no handler
680    /// // registered this engine cannot run it.
681    /// assert!(!names.contains(&"enrich"));
682    ///
683    /// let validate = engine
684    ///     .dispatchable_functions()
685    ///     .find(|f| f.name == "validate")
686    ///     .unwrap();
687    /// assert_eq!(validate.kind, Some(BuiltinKind::SelfContained));
688    /// assert_eq!(validate.aliases, &["validation"]);
689    /// ```
690    pub fn dispatchable_functions(&self) -> impl Iterator<Item = DispatchableFunction<'_>> {
691        dispatchable_functions_in(self.workflow_executor.registry())
692    }
693
694    /// Whether this engine can actually run a task named `name`.
695    ///
696    /// `true` for a [`BuiltinKind::SelfContained`] built-in, which this crate
697    /// executes itself, and for any name with a registered handler — including
698    /// an alias such as `validation`.
699    ///
700    /// `false` means the opposite is guaranteed: a task naming it fails with
701    /// [`DataflowError::FunctionNotFound`] on the first message that reaches
702    /// it. That is the whole point of the method — `Engine::build` is
703    /// deliberately permissive about `http_call` / `enrich` / `publish_kafka`,
704    /// which deserialize into typed built-in variants and so pass construction
705    /// even with no handler behind them.
706    ///
707    /// ```
708    /// use dataflow_rs::Engine;
709    ///
710    /// let engine = Engine::builder().build().unwrap();
711    ///
712    /// assert!(engine.can_dispatch("map"));
713    /// assert!(engine.can_dispatch("validation")); // alias of `validate`
714    ///
715    /// // Builds fine, would fail every message — this is the check that catches it.
716    /// assert!(!engine.can_dispatch("enrich"));
717    /// assert!(!engine.can_dispatch("never_registered"));
718    /// ```
719    pub fn can_dispatch(&self, name: &str) -> bool {
720        can_dispatch_in(self.workflow_executor.registry(), name)
721    }
722
723    /// Check a workflow against this engine's registered handlers, without
724    /// building anything.
725    ///
726    /// Answers the half of the question [`Workflow::validate_authored`] cannot:
727    /// that method proves the definition *parses and validates*, but
728    /// [`Engine::build`] also resolves every task to a handler and parses
729    /// custom inputs. A definition can therefore be structurally perfect and
730    /// still abort a build — which, in a host that builds one engine over many
731    /// stored definitions, takes down every workflow in the process.
732    ///
733    /// Reports rather than aborts, so a host screens one definition at a time.
734    /// Issues are anchored on [`WorkflowIssue::task_id`] — step ids are unique
735    /// across tasks and groups — with a path relative to that task
736    /// (`function.input`). Join it with the coordinate
737    /// [`walk_authored_steps`](crate::walk_authored_steps) reports for that id
738    /// to point at the authored document.
739    ///
740    /// `Workflow::tasks` is already flattened, so tasks inside groups are
741    /// covered with no extra traversal.
742    ///
743    /// ```
744    /// use dataflow_rs::{Engine, IssueCode, Workflow};
745    ///
746    /// let workflow = Workflow::from_json(r#"{
747    ///     "id": "w", "name": "w", "priority": 0,
748    ///     "tasks": [{"id": "lookup", "name": "lookup",
749    ///                "function": {"name": "enrich",
750    ///                             "input": {"connector": "c", "merge_path": "data.out"}}}]
751    /// }"#).unwrap();
752    ///
753    /// // Builds cleanly — that permissiveness is deliberate.
754    /// let engine = Engine::builder().build().unwrap();
755    ///
756    /// let issues = engine.check_workflow(&workflow);
757    /// assert_eq!(issues[0].code, IssueCode::MissingHandler);
758    /// assert_eq!(issues[0].task_id.as_deref(), Some("lookup"));
759    /// ```
760    pub fn check_workflow(&self, workflow: &Workflow) -> Vec<WorkflowIssue> {
761        let compiler = TemplateCompiler::new(Arc::clone(&self.datalogic));
762        authoring::check_against_registry(workflow, self.workflow_executor.registry(), &compiler)
763    }
764
765    /// Every operator name this build evaluates: datalogic's core vocabulary,
766    /// the extension families compiled in, and operators registered via
767    /// [`EngineBuilder::with_datalogic_operator`].
768    ///
769    /// Because the engine runs datalogic in templating mode, an unknown
770    /// operator is not an error — the object echoes back as literal data. That
771    /// makes this the only way to answer the authoring-side question a lint
772    /// needs: **is this single-key object a live operator call, or inert
773    /// data?**
774    ///
775    /// Turning a family on is therefore not a no-op. With `ext-string`
776    /// disabled, `{"length": …}` is a value; with it enabled, the same JSON is
777    /// a call. The enumeration moves with the feature.
778    ///
779    /// **Ordering is not meaningful** and may change without notice; treat the
780    /// result as a set, matching
781    /// [`BUILTIN_FUNCTION_NAMES`](crate::BUILTIN_FUNCTION_NAMES) and
782    /// [`Engine::dispatchable_functions`].
783    ///
784    /// ```
785    /// use dataflow_rs::Engine;
786    /// use std::collections::HashSet;
787    ///
788    /// let engine = Engine::builder().build().unwrap();
789    /// let vocabulary: HashSet<&str> = engine.operator_names().collect();
790    ///
791    /// // Core datalogic, always present.
792    /// assert!(vocabulary.contains("var"));
793    /// assert!(vocabulary.contains("if"));
794    ///
795    /// // A name outside the vocabulary is inert data, not a call — which is
796    /// // exactly what a lint wants to warn about.
797    /// assert!(!vocabulary.contains("lenght"));
798    /// ```
799    pub fn operator_names(&self) -> impl Iterator<Item = &str> + '_ {
800        // The built-in half comes from datalogic's own `OPCODE_NAMES` table
801        // (5.3.0's `builtin_operator_names`), the same table its compiler
802        // resolves keys against — so this cannot drift from dispatch the way a
803        // host-side copy of the list could. It moves with the compiled feature
804        // set on its side, including families this crate exposes no cargo
805        // feature for but that another crate in the graph turned on.
806        //
807        // The `map` is a lifetime coercion, not a transformation: datalogic
808        // yields `&'static str`, and `chain` needs both halves to agree on the
809        // item type with the `&'a str` borrowed from the custom registry.
810        let builtins = || {
811            self.datalogic
812                .builtin_operator_names()
813                .map(|name| -> &str { name })
814        };
815        // A custom registration under a built-in name is still that one name;
816        // filtering here is what dedups the two sources.
817        let customs = self
818            .datalogic_operators
819            .keys()
820            .map(String::as_str)
821            .filter(move |name| !builtins().any(|b| b == *name));
822        builtins().chain(customs)
823    }
824
825    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
826        &self.datalogic
827    }
828}
829
830/// Builder for [`Engine`]. The recommended construction path — chain
831/// `register("name", handler)` and `with_workflow(workflow)` calls, then
832/// `build()` to produce a `Result<Engine>`. Empty registration is fine; an
833/// engine with no custom handlers still resolves the built-in functions.
834///
835/// `register` takes any [`AsyncFunctionHandler`] and boxes it internally; the
836/// `Box<dyn DynAsyncFunctionHandler + Send + Sync>` plumbing stays out of
837/// user code.
838///
839/// ```no_run
840/// use dataflow_rs::{Engine, Workflow};
841/// # let workflow: Workflow = unimplemented!();
842/// let engine = Engine::builder()
843///     .with_workflow(workflow)
844///     // .register("my_handler", MyHandler)
845///     .build()
846///     .unwrap();
847/// ```
848#[must_use = "EngineBuilder must be `.build()` to produce an Engine"]
849#[derive(Default)]
850pub struct EngineBuilder {
851    workflows: Vec<Workflow>,
852    handlers: HashMap<String, BoxedFunctionHandler>,
853    observer: Option<Arc<dyn ExecutionObserver>>,
854    datalogic_operators: HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>,
855    error_context_path: Option<String>,
856    error_context_limit: Option<usize>,
857}
858
859impl EngineBuilder {
860    /// Create an empty builder. Equivalent to [`EngineBuilder::default`].
861    pub fn new() -> Self {
862        Self::default()
863    }
864
865    /// Register a custom async handler under `name`. Accepts any
866    /// `AsyncFunctionHandler`; boxing happens internally via the engine's
867    /// blanket impl.
868    pub fn register<F>(mut self, name: impl Into<String>, handler: F) -> Self
869    where
870        F: AsyncFunctionHandler,
871    {
872        self.handlers.insert(name.into(), Box::new(handler));
873        self
874    }
875
876    /// Register a pre-boxed handler. Useful when handlers are constructed
877    /// dynamically (e.g. plugin registries) and the concrete type isn't
878    /// known at the call site.
879    pub fn register_boxed(
880        mut self,
881        name: impl Into<String>,
882        handler: BoxedFunctionHandler,
883    ) -> Self {
884        self.handlers.insert(name.into(), handler);
885        self
886    }
887
888    /// Every function this builder will dispatch once built.
889    ///
890    /// The pre-build twin of [`Engine::dispatchable_functions`], with identical
891    /// semantics — the two agree by construction, since `build()` moves this
892    /// registry into the engine unchanged. Takes `&self`, so screening a batch
893    /// of definitions does not consume the builder.
894    ///
895    /// ```
896    /// use dataflow_rs::Engine;
897    ///
898    /// let builder = Engine::builder();
899    /// let names: Vec<&str> = builder.dispatchable_functions().map(|f| f.name).collect();
900    ///
901    /// assert!(names.contains(&"parse_json"));
902    /// assert!(!names.contains(&"publish_kafka")); // config schema, no handler
903    /// ```
904    pub fn dispatchable_functions(&self) -> impl Iterator<Item = DispatchableFunction<'_>> {
905        dispatchable_functions_in(&self.handlers)
906    }
907
908    /// Whether the engine this builder produces will run a task named `name`.
909    ///
910    /// The pre-build twin of [`Engine::can_dispatch`]. Screening a workflow is
911    /// then a filter over its tasks — note that `Workflow::tasks` is already
912    /// flattened, so this covers members of task groups too:
913    ///
914    /// ```
915    /// use dataflow_rs::{Engine, Workflow};
916    ///
917    /// let workflow = Workflow::from_json(r#"{
918    ///     "id": "w", "name": "w", "priority": 0,
919    ///     "tasks": [
920    ///         {"id": "a", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
921    ///         {"id": "b", "name": "b",
922    ///          "function": {"name": "enrich",
923    ///                       "input": {"connector": "c", "merge_path": "data.out"}}}
924    ///     ]
925    /// }"#).unwrap();
926    ///
927    /// let builder = Engine::builder();
928    /// let unrunnable: Vec<&str> = workflow
929    ///     .tasks
930    ///     .iter()
931    ///     .map(|t| t.function.function_name())
932    ///     .filter(|name| !builder.can_dispatch(name))
933    ///     .collect();
934    ///
935    /// assert_eq!(unrunnable, vec!["enrich"]);
936    /// ```
937    pub fn can_dispatch(&self, name: &str) -> bool {
938        can_dispatch_in(&self.handlers, name)
939    }
940
941    /// Check a workflow against this builder's registered handlers and
942    /// operators, without consuming the builder or building an engine.
943    ///
944    /// The pre-build twin of [`Engine::check_workflow`], with identical
945    /// semantics. Takes `&self`, so a host can screen a batch of definitions
946    /// against the registrations it is about to build with.
947    ///
948    /// Templates are compiled against a datalogic engine configured exactly as
949    /// [`Self::build`] will configure it — same custom operators, same
950    /// templating mode — so a template that passes here compiles there.
951    ///
952    /// ```
953    /// use dataflow_rs::{Engine, IssueCode, Workflow};
954    ///
955    /// let workflow = Workflow::from_json(r#"{
956    ///     "id": "w", "name": "w", "priority": 0,
957    ///     "tasks": [{"id": "t", "name": "t",
958    ///                "function": {"name": "typo_handler", "input": {}}}]
959    /// }"#).unwrap();
960    ///
961    /// let issues = Engine::builder().check_workflow(&workflow);
962    /// assert_eq!(issues[0].code, IssueCode::UnknownFunction);
963    /// assert_eq!(issues[0].task_id.as_deref(), Some("t"));
964    /// ```
965    pub fn check_workflow(&self, workflow: &Workflow) -> Vec<WorkflowIssue> {
966        // Build the datalogic engine the same way `build()` does, so template
967        // compilation here is the same operation it will be there — rather than
968        // an approximation a caller has to keep in step by hand.
969        let compiler = LogicCompiler::with_operators(&self.datalogic_operators);
970        let template_compiler = TemplateCompiler::new(compiler.into_engine());
971        authoring::check_against_registry(workflow, &self.handlers, &template_compiler)
972    }
973
974    /// Add a single workflow. Subsequent calls append.
975    pub fn with_workflow(mut self, workflow: Workflow) -> Self {
976        self.workflows.push(workflow);
977        self
978    }
979
980    /// Append every workflow in `workflows`. Accepts anything iterable —
981    /// `Vec<Workflow>`, an array, an iterator. Existing workflows on the
982    /// builder are kept; subsequent registers/workflows still chain.
983    pub fn with_workflows<I>(mut self, workflows: I) -> Self
984    where
985        I: IntoIterator<Item = Workflow>,
986    {
987        self.workflows.extend(workflows);
988        self
989    }
990
991    /// Insert every handler in `handlers`, keeping any already registered.
992    ///
993    /// Same extend-not-replace semantics as [`EngineBuilder::with_workflows`].
994    /// Exists because `register` is per-name, which pushed an embedder that
995    /// builds a whole `HashMap<String, BoxedFunctionHandler>` in one place onto
996    /// [`Engine::new`] and off the builder entirely — and therefore out of reach
997    /// of [`EngineBuilder::with_observer`].
998    pub fn with_handlers(mut self, handlers: HashMap<String, BoxedFunctionHandler>) -> Self {
999        self.handlers.extend(handlers);
1000        self
1001    }
1002
1003    /// Attach a per-task [`ExecutionObserver`]. Later calls replace the previous
1004    /// one.
1005    ///
1006    /// This is the only way to time the sync built-ins, which are dispatched
1007    /// inside the executor and never reach the function registry. With no
1008    /// observer attached the instrumentation — including its clock reads — stays
1009    /// out of the dispatch path entirely.
1010    pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
1011        self.observer = Some(observer);
1012        self
1013    }
1014
1015    /// Mirror per-task failure codes into the message context at `path`, so a
1016    /// downstream `condition` or `map` can branch on *why* a task failed.
1017    ///
1018    /// Off unless called: with no path configured nothing is written and the
1019    /// mechanism costs one `Option` check on a path that only runs after a task
1020    /// has already failed.
1021    ///
1022    /// One record is appended per error a task contributes to
1023    /// [`Message::errors`](crate::engine::message::Message::errors):
1024    ///
1025    /// ```json
1026    /// { "workflow_id": "place_order", "task_id": "charge_payment",
1027    ///   "code": "TIMEOUT_ERROR", "status": 500 }
1028    /// ```
1029    ///
1030    /// so a later task can gate on the reason:
1031    ///
1032    /// ```json
1033    /// { "in": [ { "var": "metadata.errors.0.code" },
1034    ///           ["TIMEOUT_ERROR", "IO_ERROR"] ] }
1035    /// ```
1036    ///
1037    /// Coverage matches `errors()` exactly — a handler returning `Err`, a task
1038    /// returning a 5xx outcome, the `validation` built-in's per-rule failures, and
1039    /// anything a handler adds through
1040    /// [`TaskContext::add_error`](crate::engine::task_context::TaskContext::add_error)
1041    /// all appear. The workflow-level `WORKFLOW_ERROR` wrapper does not: it
1042    /// re-reports the same underlying failure, so mirroring it would double-count.
1043    ///
1044    /// `status` is the task's own status — `500` when the handler returned `Err`,
1045    /// otherwise the status the outcome carried (`400` for `validation`). That is
1046    /// the distinction `metadata.progress` cannot make, since its failure arm
1047    /// hard-codes `500`.
1048    ///
1049    /// The error `message` and the operator-only `detail` are deliberately **not**
1050    /// recorded: the context is serialized back to callers, and `detail` is
1051    /// documented as unsafe to hand to an untrusted one. Read those from
1052    /// `message.errors()` host-side.
1053    ///
1054    /// `path` must start with `data`, `metadata` or `temp_data` — the JSONLogic
1055    /// evaluation context is exactly those three slots — and may not be
1056    /// `metadata.progress`. Violations fail [`EngineBuilder::build`].
1057    pub fn with_error_context_path(mut self, path: impl Into<String>) -> Self {
1058        self.error_context_path = Some(path.into());
1059        self
1060    }
1061
1062    /// Cap the number of records retained at the error-context path, keeping the
1063    /// most recent (default 32).
1064    ///
1065    /// The bound is what keeps the option's memory cost independent of a looping
1066    /// workflow's iteration count: `Message.context` is deep-cloned into every
1067    /// trace snapshot, so an uncapped list in a loop with a failing body grows the
1068    /// trace quadratically. Conditions overwhelmingly read the latest failure, so
1069    /// the oldest records are the ones dropped.
1070    ///
1071    /// Setting a limit without a path is inert, not an error. A limit of `0` fails
1072    /// [`EngineBuilder::build`].
1073    pub fn with_error_context_limit(mut self, limit: usize) -> Self {
1074        self.error_context_limit = Some(limit);
1075        self
1076    }
1077
1078    /// Register a custom JSONLogic operator on the engine's internal datalogic
1079    /// instance, under `name`. Later calls with the same name replace the
1080    /// earlier registration.
1081    ///
1082    /// This is the host's door for domain operators: the engine builds (and on
1083    /// [`Engine::with_new_workflows`] *rebuilds*) its datalogic engine
1084    /// internally, where registration is builder-only — so operators must
1085    /// enter here to exist at all, and are retained on the engine so every
1086    /// hot reload re-registers them.
1087    ///
1088    /// Semantics follow `datalogic_rs`: arguments arrive pre-evaluated, and a
1089    /// built-in operator name always wins over a custom registration — pick
1090    /// names no built-in uses. Because the engine always runs in templating
1091    /// mode, a name that is *not* registered is not an error: the object
1092    /// echoes back as literal data, exactly like a disabled operator family.
1093    /// Registering a name therefore converts previously-inert values into
1094    /// live operator calls, the same caveat the cargo features carry.
1095    pub fn with_datalogic_operator<T>(mut self, name: impl Into<String>, operator: T) -> Self
1096    where
1097        T: datalogic_rs::CustomOperator + 'static,
1098    {
1099        self.datalogic_operators
1100            .insert(name.into(), Arc::new(operator));
1101        self
1102    }
1103
1104    /// Compile the workflows, pre-parse Custom inputs, and produce the
1105    /// engine. Compile errors and missing handler references surface here —
1106    /// the engine never deserializes Custom config on the hot path.
1107    pub fn build(self) -> Result<Engine> {
1108        // Validated here rather than at the setter so an invalid path fails at
1109        // engine construction alongside every other config-shape error, instead
1110        // of on the first message that happens to fail a task.
1111        let error_context = match self.error_context_path {
1112            Some(path) => Some(Arc::new(ErrorContextConfig::new(
1113                path,
1114                self.error_context_limit
1115                    .unwrap_or(DEFAULT_ERROR_CONTEXT_LIMIT),
1116            )?)),
1117            None => None,
1118        };
1119        let engine = Engine::new_with_operators(
1120            self.workflows,
1121            self.handlers,
1122            Arc::new(self.datalogic_operators),
1123        )?;
1124        let engine = match error_context {
1125            Some(cfg) => engine.with_error_context(cfg),
1126            None => engine,
1127        };
1128        Ok(match self.observer {
1129            Some(observer) => engine.with_observer(observer),
1130            None => engine,
1131        })
1132    }
1133}
1134
1135/// Walk every task in every workflow; for each `FunctionConfig::Custom`,
1136/// look up the registered handler and ask it to parse the raw `input` JSON
1137/// into its typed `Self::Input` (boxed as `dyn Any`). The cached result is
1138/// stored on the task — dispatch then hands the handler a `&dyn Any` it
1139/// downcasts in O(1).
1140///
1141/// Built-in async configs (`HttpCall`, `Enrich`, `PublishKafka`) are already
1142/// parsed by serde's `untagged` representation on `FunctionConfig`; they
1143/// need no second pass.
1144///
1145/// Returns `FunctionNotFound` when a Custom task references an unregistered
1146/// handler — moves the failure from "first message" to engine construction.
1147fn precompile_custom_inputs(
1148    workflows: &mut [Workflow],
1149    handlers: &HashMap<String, BoxedFunctionHandler>,
1150    datalogic: &Arc<DatalogicEngine>,
1151) -> Result<()> {
1152    let template_compiler = TemplateCompiler::new(Arc::clone(datalogic));
1153    for workflow in workflows {
1154        for task in &mut workflow.tasks {
1155            if let FunctionConfig::Custom {
1156                name,
1157                input,
1158                compiled_input,
1159            } = &mut task.function
1160            {
1161                let handler = handlers
1162                    .get(name)
1163                    .ok_or_else(|| function_not_found_error(name, handlers))?;
1164                let mut parsed = handler.parse_input_box(input)?;
1165                handler.compile_input_box(&mut *parsed, &template_compiler)?;
1166                *compiled_input = Some(CompiledCustomInput(Arc::from(parsed)));
1167            }
1168        }
1169    }
1170    Ok(())
1171}
1172
1173/// Build a `FunctionNotFound` error that lists both the registered custom
1174/// handlers and the names of built-in functions, so a user with a typo
1175/// (e.g. `htttp_call`) can immediately spot the intended name.
1176///
1177/// **This message is free-form and deliberately unpinned.** It is a diagnostic
1178/// for humans; its wording and layout may change in any release. No test
1179/// asserts on it, and none should — a caller that needs the built-in vocabulary
1180/// programmatically should use [`crate::BUILTIN_FUNCTION_NAMES`] and
1181/// [`crate::builtin_function_kind`], which exist for exactly that purpose and
1182/// answer the sharper question of whether a name needs a registered handler.
1183fn function_not_found_error(
1184    name: &str,
1185    handlers: &HashMap<String, BoxedFunctionHandler>,
1186) -> DataflowError {
1187    use crate::engine::functions::config::BUILTIN_FUNCTION_NAMES;
1188    let mut registered: Vec<&str> = handlers.keys().map(String::as_str).collect();
1189    registered.sort_unstable();
1190    let registered_part = if registered.is_empty() {
1191        String::from("none")
1192    } else {
1193        registered.join(", ")
1194    };
1195    DataflowError::FunctionNotFound(format!(
1196        "{name} (registered handlers: {registered_part}; built-ins: {})",
1197        BUILTIN_FUNCTION_NAMES.join(", ")
1198    ))
1199}
1200
1201/// Stamp the standard processing metadata (`processed_at`, `engine_version`,
1202/// and optionally `channel`) into the message context.
1203///
1204/// `now` is captured once at the top of `process_message` and reused so the
1205/// timestamp on `metadata.processed_at` matches the one used for every
1206/// `AuditTrail` entry within the same call.
1207///
1208/// Walks to the `metadata` object once and sets every key in a single pass,
1209/// instead of one full `"metadata.*"` path split + tree walk per key.
1210/// Mirrors `set_nested_value` semantics for the degenerate shapes: a
1211/// non-object context or a non-object existing `metadata` slot no-ops; a
1212/// missing `metadata` slot is created.
1213///
1214/// `(**engine_version).clone()` deep-clones the inner `String` — the
1215/// context owns its values, so one small allocation per message is
1216/// inherent; the cached `Arc` only saves re-formatting the version.
1217fn set_processing_metadata(
1218    context: &mut OwnedDataValue,
1219    engine_version: &Arc<OwnedDataValue>,
1220    now: chrono::DateTime<Utc>,
1221    channel: Option<&str>,
1222) {
1223    let OwnedDataValue::Object(top) = context else {
1224        return;
1225    };
1226    let metadata = match top.iter().position(|(k, _)| k == "metadata") {
1227        Some(i) => &mut top[i].1,
1228        None => {
1229            top.push(("metadata".to_string(), OwnedDataValue::Object(Vec::new())));
1230            &mut top.last_mut().expect("just pushed").1
1231        }
1232    };
1233    let OwnedDataValue::Object(meta) = metadata else {
1234        return;
1235    };
1236
1237    let mut set_key = |key: &str, value: OwnedDataValue| {
1238        if let Some(slot) = meta.iter_mut().find(|(k, _)| k == key) {
1239            slot.1 = value;
1240        } else {
1241            meta.push((key.to_string(), value));
1242        }
1243    };
1244    set_key("processed_at", OwnedDataValue::String(now.to_rfc3339()));
1245    set_key("engine_version", (**engine_version).clone());
1246    if let Some(channel) = channel {
1247        set_key("channel", OwnedDataValue::String(channel.to_string()));
1248    }
1249}