Skip to main content

dataflow_rs/engine/
compiler.rs

1//! # Workflow Compilation Module
2//!
3//! Pre-compiles all JSONLogic expressions used by workflows and tasks at engine
4//! initialization. Each compiled `Arc<Logic>` is stored directly on the
5//! workflow/task/config struct that owns it — no central `logic_cache`, no
6//! index lookup, no bounds check on the hot path. The `Engine` is wrapped in
7//! `Arc` and is `Send + Sync` so the entire stack is safe to share across
8//! Tokio worker threads.
9
10use crate::engine::error::{DataflowError, Result};
11use crate::engine::functions::integration::{EnrichConfig, HttpCallConfig, PublishKafkaConfig};
12use crate::engine::functions::{FilterConfig, LogConfig, MapConfig, ValidationConfig};
13use crate::engine::{FunctionConfig, Workflow};
14use datalogic_rs::{Engine, Logic};
15use log::debug;
16use serde_json::Value;
17use std::sync::Arc;
18
19/// Compiles JSONLogic expressions and stamps them onto workflow/task/config
20/// structs as `Option<Arc<Logic>>` slots.
21pub struct LogicCompiler {
22    /// Shared datalogic Engine used both for compilation and (later) evaluation.
23    engine: Arc<Engine>,
24}
25
26impl Default for LogicCompiler {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl LogicCompiler {
33    /// Create a new LogicCompiler with a fresh datalogic `Engine` configured for
34    /// templating mode (preserves object structure in JSONLogic operations).
35    pub fn new() -> Self {
36        Self {
37            engine: Arc::new(Engine::builder().with_templating(true).build()),
38        }
39    }
40
41    /// Get the Engine instance
42    pub fn engine(&self) -> Arc<Engine> {
43        Arc::clone(&self.engine)
44    }
45
46    /// Consume the compiler and return the shared engine.
47    pub fn into_engine(self) -> Arc<Engine> {
48        self.engine
49    }
50
51    /// Compile all workflows and their tasks, returning them sorted by priority.
52    /// Returns `Err` on the first validation or compilation failure — engine
53    /// construction is fail-loud so misconfigured workflows can't silently
54    /// disappear at runtime.
55    pub fn compile_workflows(&self, workflows: Vec<Workflow>) -> Result<Vec<Workflow>> {
56        let mut compiled_workflows = Vec::with_capacity(workflows.len());
57
58        for mut workflow in workflows {
59            workflow.validate()?;
60
61            // Populate the cached Arc<str> ids so audit emission can refcount-bump
62            // rather than reallocate per AuditTrail entry.
63            workflow.id_arc = Arc::from(workflow.id.as_str());
64            for task in &mut workflow.tasks {
65                task.id_arc = Arc::from(task.id.as_str());
66            }
67
68            // Compile the workflow condition (defaults to `true`, which folds
69            // to `None` so the hot path skips the eval — see `compile_condition`).
70            let label = format!("workflow {} condition", workflow.id);
71            workflow.compiled_condition = self.compile_condition(&workflow.condition, &label)?;
72            debug!("Workflow {} condition compiled", workflow.id);
73
74            // Compile task conditions and function-specific logic.
75            self.compile_workflow_tasks(&mut workflow)?;
76
77            // Stamp whether every task is a synchronous built-in. A fully-sync
78            // workflow can be folded into a shared cross-workflow `with_arena`
79            // scope (no `.await`), so the message context is deep-walked into
80            // the arena once per *run* of consecutive fully-sync workflows
81            // instead of once per workflow. Any async/custom task forces the
82            // per-workflow `.await` path.
83            workflow.fully_sync = workflow.tasks.iter().all(|t| t.function.is_sync_builtin());
84
85            compiled_workflows.push(workflow);
86        }
87
88        // Sort by priority once at construction time
89        compiled_workflows.sort_by_key(|w| w.priority);
90        Ok(compiled_workflows)
91    }
92
93    /// Compile task conditions and function logic for a workflow
94    fn compile_workflow_tasks(&self, workflow: &mut Workflow) -> Result<()> {
95        for task in &mut workflow.tasks {
96            let label = format!("task {} condition (workflow {})", task.id, workflow.id);
97            task.compiled_condition = self.compile_condition(&task.condition, &label)?;
98
99            // Compile function-specific logic (map transformations, validation rules, …)
100            self.compile_function_logic(&mut task.function, &task.id, &workflow.id)?;
101        }
102        Ok(())
103    }
104
105    /// Compile function-specific logic based on function type
106    fn compile_function_logic(
107        &self,
108        function: &mut FunctionConfig,
109        task_id: &str,
110        workflow_id: &str,
111    ) -> Result<()> {
112        match function {
113            FunctionConfig::Map { input, .. } => {
114                self.compile_map_logic(input, task_id, workflow_id)
115            }
116            FunctionConfig::Validation { input, .. } => {
117                self.compile_validation_logic(input, task_id, workflow_id)
118            }
119            FunctionConfig::Filter { input, .. } => {
120                self.compile_filter_logic(input, task_id, workflow_id)
121            }
122            FunctionConfig::Log { input, .. } => {
123                self.compile_log_logic(input, task_id, workflow_id)
124            }
125            FunctionConfig::HttpCall { input, .. } => {
126                self.compile_http_call_logic(input, task_id, workflow_id)
127            }
128            FunctionConfig::Enrich { input, .. } => {
129                self.compile_enrich_logic(input, task_id, workflow_id)
130            }
131            FunctionConfig::PublishKafka { input, .. } => {
132                self.compile_publish_kafka_logic(input, task_id, workflow_id)
133            }
134            // No JSONLogic to compile, but the `data.{target}` write path is
135            // precomputed here (path string + pre-split parts) so the hot
136            // path never re-formats or re-splits it.
137            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
138                input.precompute_target_path();
139                Ok(())
140            }
141            FunctionConfig::PublishJson { input, .. }
142            | FunctionConfig::PublishXml { input, .. } => {
143                input.precompute_target_path();
144                Ok(())
145            }
146            // Custom and other functions don't need pre-compilation
147            _ => Ok(()),
148        }
149    }
150
151    /// Compile a JSONLogic expression and return the `Arc<Logic>`. Errors are
152    /// surfaced as `DataflowError::LogicEvaluation` with the supplied
153    /// context label for debugging.
154    fn compile(&self, logic: &Value, ctx_label: &str) -> Result<Arc<Logic>> {
155        self.engine
156            .compile_arc(logic)
157            .map_err(|e| DataflowError::LogicEvaluation(format!("{}: {}", ctx_label, e)))
158    }
159
160    /// Compile a workflow/task *condition*, returning `None` when the source is
161    /// the literal `true`. A `None` condition is treated as "always run" by
162    /// `evaluate_condition` / `evaluate_condition_in_arena`, so the hot path
163    /// skips the `engine.evaluate` call — and, in the sync stretch, the
164    /// per-task arena context slice build — entirely for the overwhelmingly
165    /// common default `condition: true`. datalogic already folds a literal
166    /// `true` to a near-free literal-fast-path eval; this avoids even setting
167    /// up the call. Non-literal conditions (including `false` and any real
168    /// expression) compile as normal.
169    fn compile_condition(&self, condition: &Value, ctx_label: &str) -> Result<Option<Arc<Logic>>> {
170        if matches!(condition, Value::Bool(true)) {
171            return Ok(None);
172        }
173        Ok(Some(self.compile(condition, ctx_label)?))
174    }
175
176    /// Compile map transformation logic
177    fn compile_map_logic(
178        &self,
179        config: &mut MapConfig,
180        task_id: &str,
181        workflow_id: &str,
182    ) -> Result<()> {
183        for mapping in &mut config.mappings {
184            // Pre-split the dot path so the hot path doesn't re-split per
185            // write. The `#` prefix is preserved here — it's the explicit
186            // "treat this as an object key, not an array index" hint that
187            // `set_nested_value` consumes when deciding container shape; the
188            // strip happens at lookup time inside `*_parts` helpers.
189            let parts: Vec<Arc<str>> = mapping.path.split('.').map(Arc::from).collect();
190            mapping.path_parts = Arc::from(parts.into_boxed_slice());
191            mapping.path_arc = Arc::from(mapping.path.as_str());
192
193            let label = format!(
194                "map logic for task {} in workflow {} (path {})",
195                task_id, workflow_id, mapping.path
196            );
197            mapping.compiled_logic = Some(self.compile(&mapping.logic, &label)?);
198        }
199        Ok(())
200    }
201
202    /// Compile validation rule logic
203    fn compile_validation_logic(
204        &self,
205        config: &mut ValidationConfig,
206        task_id: &str,
207        workflow_id: &str,
208    ) -> Result<()> {
209        for (idx, rule) in config.rules.iter_mut().enumerate() {
210            let label = format!(
211                "validation rule {} for task {} in workflow {}",
212                idx, task_id, workflow_id
213            );
214            rule.compiled_logic = Some(self.compile(&rule.logic, &label)?);
215        }
216        Ok(())
217    }
218
219    /// Compile log message and field expressions
220    fn compile_log_logic(
221        &self,
222        config: &mut LogConfig,
223        task_id: &str,
224        workflow_id: &str,
225    ) -> Result<()> {
226        let msg_label = format!(
227            "log message for task {} in workflow {}",
228            task_id, workflow_id
229        );
230        config.compiled_message = Some(self.compile(&config.message, &msg_label)?);
231
232        // Compile each field expression. Collect into a fresh Vec, then
233        // assign — keeps the immutable borrow of `config.fields` from
234        // overlapping with the mutable borrow of `config.compiled_fields`.
235        let mut compiled_fields = Vec::with_capacity(config.fields.len());
236        for (key, logic) in &config.fields {
237            let label = format!(
238                "log field '{}' for task {} in workflow {}",
239                key, task_id, workflow_id
240            );
241            compiled_fields.push((key.clone(), Some(self.compile(logic, &label)?)));
242        }
243        config.compiled_fields = compiled_fields;
244        Ok(())
245    }
246
247    /// Compile filter condition logic
248    fn compile_filter_logic(
249        &self,
250        config: &mut FilterConfig,
251        task_id: &str,
252        workflow_id: &str,
253    ) -> Result<()> {
254        let label = format!(
255            "filter condition for task {} in workflow {}",
256            task_id, workflow_id
257        );
258        config.compiled_condition = Some(self.compile(&config.condition, &label)?);
259        Ok(())
260    }
261
262    /// Compile http_call JSONLogic expressions (path_logic, body_logic)
263    fn compile_http_call_logic(
264        &self,
265        config: &mut HttpCallConfig,
266        task_id: &str,
267        workflow_id: &str,
268    ) -> Result<()> {
269        if let Some(logic) = &config.path_logic {
270            let label = format!(
271                "http_call path_logic for task {} in workflow {}",
272                task_id, workflow_id
273            );
274            config.compiled_path_logic = Some(self.compile(logic, &label)?);
275        }
276        if let Some(logic) = &config.body_logic {
277            let label = format!(
278                "http_call body_logic for task {} in workflow {}",
279                task_id, workflow_id
280            );
281            config.compiled_body_logic = Some(self.compile(logic, &label)?);
282        }
283        Ok(())
284    }
285
286    /// Compile enrich JSONLogic expressions (path_logic)
287    fn compile_enrich_logic(
288        &self,
289        config: &mut EnrichConfig,
290        task_id: &str,
291        workflow_id: &str,
292    ) -> Result<()> {
293        if let Some(logic) = &config.path_logic {
294            let label = format!(
295                "enrich path_logic for task {} in workflow {}",
296                task_id, workflow_id
297            );
298            config.compiled_path_logic = Some(self.compile(logic, &label)?);
299        }
300        Ok(())
301    }
302
303    /// Compile publish_kafka JSONLogic expressions (key_logic, value_logic)
304    fn compile_publish_kafka_logic(
305        &self,
306        config: &mut PublishKafkaConfig,
307        task_id: &str,
308        workflow_id: &str,
309    ) -> Result<()> {
310        if let Some(logic) = &config.key_logic {
311            let label = format!(
312                "publish_kafka key_logic for task {} in workflow {}",
313                task_id, workflow_id
314            );
315            config.compiled_key_logic = Some(self.compile(logic, &label)?);
316        }
317        if let Some(logic) = &config.value_logic {
318            let label = format!(
319                "publish_kafka value_logic for task {} in workflow {}",
320                task_id, workflow_id
321            );
322            config.compiled_value_logic = Some(self.compile(logic, &label)?);
323        }
324        Ok(())
325    }
326}