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