Skip to main content

dataflow_rs/engine/
workflow.rs

1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::FunctionConfig;
3use crate::engine::task::Task;
4use chrono::{DateTime, Utc};
5use datalogic_rs::Logic;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11
12/// Half-open bucket range `[bucket_start, bucket_end)` over `0..100`, giving this
13/// workflow a slice of the traffic on its channel.
14///
15/// Compared against [`crate::Message::routing_bucket`]. The engine does **not**
16/// derive the bucket: how a caller maps to one — a sticky hash of some request
17/// identity, a per-message random draw, round-robin — is entirely the caller's
18/// policy and deliberately stays outside this crate.
19#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
20pub struct Rollout {
21    /// Inclusive lower bound.
22    pub bucket_start: u8,
23    /// Exclusive upper bound. `100` means "up to and including bucket 99".
24    pub bucket_end: u8,
25}
26
27impl Rollout {
28    /// Whether this range serves `bucket` (`0..=99`).
29    ///
30    /// `[0, 100)` accepts everything. An empty or inverted range
31    /// (`bucket_end <= bucket_start`) accepts nothing.
32    #[inline]
33    pub fn accepts(&self, bucket: u8) -> bool {
34        bucket >= self.bucket_start && bucket < self.bucket_end
35    }
36}
37
38/// Workflow lifecycle status
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum WorkflowStatus {
42    #[default]
43    Active,
44    Paused,
45    Archived,
46}
47
48/// Workflow represents a collection of tasks that execute sequentially (also known as a Rule in rules-engine terminology).
49///
50/// Conditions are evaluated against the full message context, including `data`, `metadata`, and `temp_data` fields.
51#[derive(Clone, Debug, Deserialize)]
52pub struct Workflow {
53    pub id: String,
54    /// Engine-internal: `Arc<str>` mirror of `id`, populated by
55    /// `LogicCompiler::compile_workflows`. Cloning is a refcount bump; per-message
56    /// `AuditTrail` entries reuse it instead of allocating from `&id` each time.
57    /// Not part of the stable API.
58    #[doc(hidden)]
59    #[serde(skip)]
60    pub id_arc: Arc<str>,
61    pub name: String,
62    #[serde(default)]
63    pub priority: u32,
64    pub description: Option<String>,
65    #[serde(default = "crate::engine::utils::default_condition")]
66    pub condition: Value,
67    /// Engine-internal: pre-compiled JSONLogic for `condition`, populated by
68    /// `LogicCompiler`. `None` is treated as "no condition / always run" by
69    /// the executor. Not part of the stable API.
70    #[doc(hidden)]
71    #[serde(skip)]
72    pub compiled_condition: Option<Arc<Logic>>,
73    /// Engine-internal: `true` when every task is a synchronous built-in
74    /// (`is_sync_builtin`), so the whole workflow can run inside a shared
75    /// `with_arena` scope with no `.await`. Populated by `LogicCompiler`; the
76    /// `false` default means an uncompiled workflow conservatively takes the
77    /// async path. Not part of the stable API.
78    #[doc(hidden)]
79    #[serde(skip, default)]
80    pub fully_sync: bool,
81    pub tasks: Vec<Task>,
82    #[serde(default)]
83    pub continue_on_error: bool,
84    /// Channel for routing (default: "default")
85    #[serde(default = "default_channel")]
86    pub channel: String,
87    /// Version number for rule versioning (default: 1)
88    #[serde(default = "default_version")]
89    pub version: u32,
90    /// Workflow status — Active, Paused, or Archived (default: Active)
91    #[serde(default)]
92    pub status: WorkflowStatus,
93    /// Traffic split for this workflow. `None` (the default) means the workflow
94    /// is not part of a split and runs for every message.
95    ///
96    /// A workflow with a rollout is skipped when the message's
97    /// [`crate::Message::routing_bucket`] falls outside the range. A message with
98    /// **no** bucket is admitted — see [`Rollout`].
99    #[serde(default)]
100    pub rollout: Option<Rollout>,
101    /// Tags for categorization and filtering
102    #[serde(default)]
103    pub tags: Vec<String>,
104    /// Creation timestamp
105    #[serde(default)]
106    pub created_at: Option<DateTime<Utc>>,
107    /// Last update timestamp
108    #[serde(default)]
109    pub updated_at: Option<DateTime<Utc>>,
110}
111
112fn default_channel() -> String {
113    "default".to_string()
114}
115
116fn default_version() -> u32 {
117    1
118}
119
120impl Default for Workflow {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl Workflow {
127    pub fn new() -> Self {
128        Workflow {
129            id: String::new(),
130            id_arc: Arc::from(""),
131            name: String::new(),
132            priority: 0,
133            description: None,
134            condition: Value::Bool(true),
135            compiled_condition: None,
136            fully_sync: false,
137            tasks: Vec::new(),
138            continue_on_error: false,
139            channel: default_channel(),
140            version: 1,
141            status: WorkflowStatus::Active,
142            rollout: None,
143            tags: Vec::new(),
144            created_at: None,
145            updated_at: None,
146        }
147    }
148
149    /// Create a workflow (rule) with a condition and tasks.
150    ///
151    /// This is a convenience constructor for the IFTTT-style rules engine pattern:
152    /// **IF** `condition` **THEN** execute `tasks`.
153    ///
154    /// # Arguments
155    /// * `id` - Unique identifier for the rule
156    /// * `name` - Human-readable name
157    /// * `condition` - JSONLogic condition evaluated against the full context (data, metadata, temp_data)
158    /// * `tasks` - Actions to execute when the condition is met
159    pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
160        Workflow {
161            id: id.to_string(),
162            id_arc: Arc::from(id),
163            name: name.to_string(),
164            priority: 0,
165            description: None,
166            condition,
167            compiled_condition: None,
168            fully_sync: false,
169            tasks,
170            continue_on_error: false,
171            channel: default_channel(),
172            version: 1,
173            status: WorkflowStatus::Active,
174            rollout: None,
175            tags: Vec::new(),
176            created_at: None,
177            updated_at: None,
178        }
179    }
180
181    /// Load workflow from JSON string
182    pub fn from_json(json_str: &str) -> Result<Self> {
183        serde_json::from_str(json_str).map_err(DataflowError::from_serde)
184    }
185
186    /// Load workflow from JSON file
187    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
188        let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
189
190        Self::from_json(&json_str)
191    }
192
193    /// Validate the workflow structure
194    pub fn validate(&self) -> Result<()> {
195        // Check required fields
196        if self.id.is_empty() {
197            return Err(DataflowError::Workflow(
198                "Workflow id cannot be empty".to_string(),
199            ));
200        }
201
202        if self.name.is_empty() {
203            return Err(DataflowError::Workflow(
204                "Workflow name cannot be empty".to_string(),
205            ));
206        }
207
208        // Check tasks
209        if self.tasks.is_empty() {
210            return Err(DataflowError::Workflow(
211                "Workflow must have at least one task".to_string(),
212            ));
213        }
214
215        // Validate that task IDs are unique
216        let mut task_ids = std::collections::HashSet::new();
217        for task in &self.tasks {
218            if !task_ids.insert(&task.id) {
219                return Err(DataflowError::Workflow(format!(
220                    "Duplicate task ID '{}' in workflow",
221                    task.id
222                )));
223            }
224        }
225
226        Ok(())
227    }
228}
229
230/// One task's connector reference, located within a workflow.
231///
232/// `Copy`: every field is a shared borrow. `config` is carried so callers can
233/// apply cross-field rules — "a task on this kind of connector also needs
234/// `input.database`" — without re-parsing the task.
235///
236/// Not `Serialize`: [`FunctionConfig`] is deserialize-only, so callers that emit
237/// JSON diagnostics build their own shape from these fields.
238#[derive(Debug, Clone, Copy)]
239pub struct ConnectorRef<'a> {
240    /// `id` of the owning workflow.
241    pub workflow_id: &'a str,
242    /// `id` of the referencing task.
243    pub task_id: &'a str,
244    /// Canonical function name, as [`FunctionConfig::function_name`].
245    pub function: &'a str,
246    /// The connector name, exactly as authored.
247    pub connector: &'a str,
248    /// The whole function config, for cross-field rules.
249    pub config: &'a FunctionConfig,
250}
251
252impl Workflow {
253    /// Every connector reference in this workflow, in task order.
254    ///
255    /// Tasks whose function names no connector are skipped. One item is yielded
256    /// per *task*, not per distinct connector: two tasks on the same connector
257    /// yield two items. Callers wanting a distinct set collect one themselves.
258    ///
259    /// Does not require a compiled workflow — this reads only deserialized
260    /// fields, so it works on the output of [`Workflow::from_json`] before the
261    /// engine has compiled it.
262    ///
263    /// Which configs carry a connector is this crate's fact; deriving it here
264    /// rather than reimplementing the set downstream is the point.
265    pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
266        // `move` is load-bearing: it copies the `&Workflow` into the closure so
267        // the returned iterator does not borrow a local.
268        self.tasks.iter().filter_map(move |task| {
269            task.function.connector().map(|connector| ConnectorRef {
270                workflow_id: &self.id,
271                task_id: &task.id,
272                function: task.function.function_name(),
273                connector,
274                config: &task.function,
275            })
276        })
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn wf(tasks_json: &str) -> Workflow {
285        Workflow::from_json(&format!(
286            r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
287                  "tasks": [{tasks_json}] }}"#
288        ))
289        .expect("workflow should parse")
290    }
291
292    const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
293        "name": "http_call", "input": { "connector": "user_service" } } }"#;
294    const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
295        "name": "publish_kafka",
296        "input": { "connector": "events", "topic": "t" } } }"#;
297    const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
298        "name": "map", "input": { "mappings": [] } } }"#;
299    const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
300        "name": "log", "input": { "message": "hi" } } }"#;
301
302    #[test]
303    fn connector_refs_yields_only_connector_tasks_in_task_order() {
304        let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
305        let refs: Vec<_> = workflow.connector_refs().collect();
306
307        assert_eq!(refs.len(), 2);
308        assert_eq!(refs[0].task_id, "call");
309        assert_eq!(refs[0].function, "http_call");
310        assert_eq!(refs[0].connector, "user_service");
311        assert_eq!(refs[1].task_id, "pub");
312        assert_eq!(refs[1].function, "publish_kafka");
313        assert_eq!(refs[1].connector, "events");
314    }
315
316    #[test]
317    fn connector_refs_carries_the_owning_workflow_id() {
318        let workflow = wf(HTTP);
319        assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
320
321        // Including the empty-id case from `Workflow::new()`.
322        let empty = Workflow::new();
323        assert_eq!(empty.id, "");
324        assert_eq!(empty.connector_refs().count(), 0);
325    }
326
327    #[test]
328    fn connector_refs_is_empty_for_no_tasks() {
329        // `validate` rejects an empty task list, but `connector_refs` must not
330        // assume `validate` ran — `Workflow::new()` has empty tasks.
331        assert_eq!(Workflow::new().connector_refs().count(), 0);
332    }
333
334    #[test]
335    fn connector_refs_does_not_deduplicate() {
336        let a = r#"{ "id": "a", "name": "a", "function": {
337            "name": "http_call", "input": { "connector": "same" } } }"#;
338        let b = r#"{ "id": "b", "name": "b", "function": {
339            "name": "enrich",
340            "input": { "connector": "same", "merge_path": "data.out" } } }"#;
341        let workflow = wf(&format!("{a},{b}"));
342
343        let refs: Vec<_> = workflow.connector_refs().collect();
344        assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
345        assert!(refs.iter().all(|r| r.connector == "same"));
346    }
347
348    #[test]
349    fn connector_refs_works_on_an_uncompiled_workflow() {
350        // Straight from `from_json`, before any engine construction: `id_arc` and
351        // `compiled_condition` are still unset.
352        let workflow = wf(HTTP);
353        assert!(workflow.compiled_condition.is_none());
354        assert_eq!(workflow.connector_refs().count(), 1);
355    }
356
357    #[test]
358    fn connector_ref_is_copy() {
359        let workflow = wf(HTTP);
360        let r = workflow.connector_refs().next().unwrap();
361        let copied = r;
362        // Reading both without cloning only compiles if `ConnectorRef` is `Copy`.
363        assert_eq!(r.connector, copied.connector);
364        assert_eq!(r.task_id, copied.task_id);
365    }
366
367    #[test]
368    fn connector_ref_config_supports_a_cross_field_rule() {
369        // Proves `config` is load-bearing rather than decorative: read another
370        // key out of the same task's input.
371        let custom = r#"{ "id": "db", "name": "db", "function": {
372            "name": "pg_query",
373            "input": { "connector": "pg_main", "database": "orders" } } }"#;
374        let workflow = wf(custom);
375
376        let r = workflow.connector_refs().next().expect("custom connector");
377        assert_eq!(r.connector, "pg_main");
378        match r.config {
379            FunctionConfig::Custom { input, .. } => {
380                assert_eq!(
381                    input.get("database").and_then(|v| v.as_str()),
382                    Some("orders")
383                );
384            }
385            other => panic!("expected Custom, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn rollout_accepts_is_a_half_open_range() {
391        let all = Rollout {
392            bucket_start: 0,
393            bucket_end: 100,
394        };
395        assert!(all.accepts(0));
396        assert!(all.accepts(99));
397
398        let lower = Rollout {
399            bucket_start: 0,
400            bucket_end: 50,
401        };
402        assert!(lower.accepts(0));
403        assert!(lower.accepts(49));
404        assert!(!lower.accepts(50), "bucket_end is exclusive");
405        assert!(!lower.accepts(99));
406
407        // `start` inclusive, `end` exclusive — boundary exactness.
408        let upper = Rollout {
409            bucket_start: 50,
410            bucket_end: 100,
411        };
412        assert!(upper.accepts(50), "bucket_start is inclusive");
413        assert!(upper.accepts(99));
414        assert!(!upper.accepts(49));
415
416        // The two halves partition 0..=99 exactly.
417        for b in 0u8..=99 {
418            assert_ne!(
419                lower.accepts(b),
420                upper.accepts(b),
421                "bucket {b} must be served by exactly one half"
422            );
423        }
424    }
425
426    #[test]
427    fn rollout_empty_and_inverted_ranges_accept_nothing() {
428        let empty = Rollout {
429            bucket_start: 50,
430            bucket_end: 50,
431        };
432        let inverted = Rollout {
433            bucket_start: 60,
434            bucket_end: 20,
435        };
436        for b in 0u8..=99 {
437            assert!(!empty.accepts(b), "empty range accepted {b}");
438            assert!(!inverted.accepts(b), "inverted range accepted {b}");
439        }
440    }
441
442    #[test]
443    fn rollout_end_of_100_is_representable_without_overflow() {
444        // `bucket_end = 100` fits a u8 and `accepts` does no arithmetic on it.
445        let r = Rollout {
446            bucket_start: 99,
447            bucket_end: 100,
448        };
449        assert!(r.accepts(99));
450        assert!(!r.accepts(98));
451    }
452
453    #[test]
454    fn rollout_defaults_to_none_on_every_construction_path() {
455        assert_eq!(Workflow::new().rollout, None);
456        assert_eq!(Workflow::default().rollout, None);
457        assert_eq!(
458            Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
459            None
460        );
461        assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
462    }
463
464    #[test]
465    fn rollout_deserializes_from_json() {
466        let workflow = Workflow::from_json(
467            r#"{ "id": "w", "name": "w", "condition": true,
468                 "rollout": { "bucket_start": 0, "bucket_end": 50 },
469                 "tasks": [] }"#,
470        )
471        .unwrap();
472        assert_eq!(
473            workflow.rollout,
474            Some(Rollout {
475                bucket_start: 0,
476                bucket_end: 50
477            })
478        );
479    }
480}