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