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