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