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
12pub use crate::engine::rollout::{Rollout, RolloutError};
13
14#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
41pub struct LoopConfig {
42 #[serde(default)]
53 pub counter: Option<String>,
54
55 #[serde(default)]
57 pub init: i64,
58
59 #[serde(default = "default_increment")]
62 pub increment: i64,
63
64 pub max: i64,
69
70 #[doc(hidden)]
74 #[serde(skip)]
75 pub counter_parts: Arc<[Arc<str>]>,
76}
77
78fn default_increment() -> i64 {
79 1
80}
81
82impl LoopConfig {
83 fn validate(&self, workflow_id: &str) -> Result<()> {
87 if self.increment < 1 {
88 return Err(DataflowError::Workflow(format!(
89 "Workflow {workflow_id}: loop increment must be >= 1, got {} \
90 (a non-advancing counter would never reach max)",
91 self.increment
92 )));
93 }
94 if self.max <= self.init {
95 return Err(DataflowError::Workflow(format!(
96 "Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
97 the bound is half-open, so this could never run a sweep",
98 self.max, self.init
99 )));
100 }
101 if let Some(counter) = &self.counter {
102 if counter.is_empty() || counter.split('.').any(str::is_empty) {
103 return Err(DataflowError::Workflow(format!(
104 "Workflow {workflow_id}: loop counter must be a non-empty \
105 temp_data field path, got {counter:?}"
106 )));
107 }
108 }
109 Ok(())
110 }
111
112 #[doc(hidden)]
117 pub fn precompute_counter_path(&mut self) {
118 self.counter_parts = match &self.counter {
119 Some(counter) => crate::engine::utils::compute_path_parts("temp_data", counter),
120 None => Arc::from([] as [Arc<str>; 0]),
121 };
122 }
123}
124
125#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum WorkflowStatus {
129 #[default]
130 Active,
131 Paused,
132 Archived,
133}
134
135#[derive(Clone, Debug, Deserialize)]
148#[non_exhaustive]
149pub struct Workflow {
150 pub id: String,
151 #[doc(hidden)]
156 #[serde(skip)]
157 pub id_arc: Arc<str>,
158 pub name: String,
159 #[serde(default)]
160 pub priority: u32,
161 pub description: Option<String>,
162 #[serde(default = "crate::engine::utils::default_condition")]
163 pub condition: Value,
164 #[doc(hidden)]
168 #[serde(skip)]
169 pub compiled_condition: Option<Arc<Logic>>,
170 #[doc(hidden)]
176 #[serde(skip, default)]
177 pub fully_sync: bool,
178 #[serde(deserialize_with = "crate::engine::steps::flatten")]
187 pub tasks: Vec<Task>,
188 #[serde(default)]
189 pub continue_on_error: bool,
190 #[serde(default = "default_channel")]
192 pub channel: String,
193 #[serde(default = "default_version")]
195 pub version: u32,
196 #[serde(default)]
198 pub status: WorkflowStatus,
199 #[serde(default)]
206 pub rollout: Option<Rollout>,
207 #[serde(default, rename = "loop")]
213 pub loop_config: Option<LoopConfig>,
214 #[serde(default)]
216 pub tags: Vec<String>,
217 #[serde(default)]
219 pub created_at: Option<DateTime<Utc>>,
220 #[serde(default)]
222 pub updated_at: Option<DateTime<Utc>>,
223}
224
225fn default_channel() -> String {
226 "default".to_string()
227}
228
229fn default_version() -> u32 {
230 1
231}
232
233impl Default for Workflow {
234 fn default() -> Self {
235 Self::new()
236 }
237}
238
239impl Workflow {
240 pub fn new() -> Self {
241 Workflow {
242 id: String::new(),
243 id_arc: Arc::from(""),
244 name: String::new(),
245 priority: 0,
246 description: None,
247 condition: Value::Bool(true),
248 compiled_condition: None,
249 fully_sync: false,
250 tasks: Vec::new(),
251 continue_on_error: false,
252 channel: default_channel(),
253 version: 1,
254 status: WorkflowStatus::Active,
255 rollout: None,
256 loop_config: None,
257 tags: Vec::new(),
258 created_at: None,
259 updated_at: None,
260 }
261 }
262
263 pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
274 Workflow {
275 id: id.to_string(),
276 id_arc: Arc::from(id),
277 name: name.to_string(),
278 priority: 0,
279 description: None,
280 condition,
281 compiled_condition: None,
282 fully_sync: false,
283 tasks,
284 continue_on_error: false,
285 channel: default_channel(),
286 version: 1,
287 status: WorkflowStatus::Active,
288 rollout: None,
289 loop_config: None,
290 tags: Vec::new(),
291 created_at: None,
292 updated_at: None,
293 }
294 }
295
296 pub fn from_json(json_str: &str) -> Result<Self> {
298 serde_json::from_str(json_str).map_err(DataflowError::from_serde)
299 }
300
301 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
303 let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
304
305 Self::from_json(&json_str)
306 }
307
308 pub fn validate(&self) -> Result<()> {
310 if self.id.is_empty() {
312 return Err(DataflowError::Workflow(
313 "Workflow id cannot be empty".to_string(),
314 ));
315 }
316
317 if self.name.is_empty() {
318 return Err(DataflowError::Workflow(
319 "Workflow name cannot be empty".to_string(),
320 ));
321 }
322
323 if self.tasks.is_empty() {
325 return Err(DataflowError::Workflow(
326 "Workflow must have at least one task".to_string(),
327 ));
328 }
329
330 let mut step_ids = std::collections::HashSet::new();
334 for task in &self.tasks {
335 for group in &task.group_starts {
336 if !step_ids.insert(group.id.as_str()) {
337 return Err(DataflowError::Workflow(format!(
338 "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
339 group.id
340 )));
341 }
342 }
343 if !step_ids.insert(task.id.as_str()) {
344 return Err(DataflowError::Workflow(format!(
345 "Duplicate task ID '{}' in workflow",
346 task.id
347 )));
348 }
349 }
350
351 if let Some(loop_config) = &self.loop_config {
355 loop_config.validate(&self.id)?;
356 }
357
358 Ok(())
359 }
360}
361
362#[derive(Debug, Clone, Copy)]
371pub struct ConnectorRef<'a> {
372 pub workflow_id: &'a str,
374 pub task_id: &'a str,
376 pub function: &'a str,
378 pub connector: &'a str,
380 pub config: &'a FunctionConfig,
382}
383
384impl Workflow {
385 pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
398 self.tasks.iter().filter_map(move |task| {
401 task.function.connector().map(|connector| ConnectorRef {
402 workflow_id: &self.id,
403 task_id: &task.id,
404 function: task.function.function_name(),
405 connector,
406 config: &task.function,
407 })
408 })
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn wf(tasks_json: &str) -> Workflow {
417 Workflow::from_json(&format!(
418 r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
419 "tasks": [{tasks_json}] }}"#
420 ))
421 .expect("workflow should parse")
422 }
423
424 const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
425 "name": "http_call", "input": { "connector": "user_service" } } }"#;
426 const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
427 "name": "publish_kafka",
428 "input": { "connector": "events", "topic": "t" } } }"#;
429 const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
430 "name": "map", "input": { "mappings": [] } } }"#;
431 const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
432 "name": "log", "input": { "message": "hi" } } }"#;
433
434 #[test]
435 fn connector_refs_yields_only_connector_tasks_in_task_order() {
436 let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
437 let refs: Vec<_> = workflow.connector_refs().collect();
438
439 assert_eq!(refs.len(), 2);
440 assert_eq!(refs[0].task_id, "call");
441 assert_eq!(refs[0].function, "http_call");
442 assert_eq!(refs[0].connector, "user_service");
443 assert_eq!(refs[1].task_id, "pub");
444 assert_eq!(refs[1].function, "publish_kafka");
445 assert_eq!(refs[1].connector, "events");
446 }
447
448 #[test]
449 fn connector_refs_carries_the_owning_workflow_id() {
450 let workflow = wf(HTTP);
451 assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
452
453 let empty = Workflow::new();
455 assert_eq!(empty.id, "");
456 assert_eq!(empty.connector_refs().count(), 0);
457 }
458
459 #[test]
460 fn connector_refs_is_empty_for_no_tasks() {
461 assert_eq!(Workflow::new().connector_refs().count(), 0);
464 }
465
466 #[test]
467 fn connector_refs_does_not_deduplicate() {
468 let a = r#"{ "id": "a", "name": "a", "function": {
469 "name": "http_call", "input": { "connector": "same" } } }"#;
470 let b = r#"{ "id": "b", "name": "b", "function": {
471 "name": "enrich",
472 "input": { "connector": "same", "merge_path": "data.out" } } }"#;
473 let workflow = wf(&format!("{a},{b}"));
474
475 let refs: Vec<_> = workflow.connector_refs().collect();
476 assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
477 assert!(refs.iter().all(|r| r.connector == "same"));
478 }
479
480 #[test]
481 fn connector_refs_works_on_an_uncompiled_workflow() {
482 let workflow = wf(HTTP);
485 assert!(workflow.compiled_condition.is_none());
486 assert_eq!(workflow.connector_refs().count(), 1);
487 }
488
489 #[test]
490 fn connector_ref_is_copy() {
491 let workflow = wf(HTTP);
492 let r = workflow.connector_refs().next().unwrap();
493 let copied = r;
494 assert_eq!(r.connector, copied.connector);
496 assert_eq!(r.task_id, copied.task_id);
497 }
498
499 #[test]
500 fn connector_ref_config_supports_a_cross_field_rule() {
501 let custom = r#"{ "id": "db", "name": "db", "function": {
504 "name": "pg_query",
505 "input": { "connector": "pg_main", "database": "orders" } } }"#;
506 let workflow = wf(custom);
507
508 let r = workflow.connector_refs().next().expect("custom connector");
509 assert_eq!(r.connector, "pg_main");
510 match r.config {
511 FunctionConfig::Custom { input, .. } => {
512 assert_eq!(
513 input.get("database").and_then(|v| v.as_str()),
514 Some("orders")
515 );
516 }
517 other => panic!("expected Custom, got {other:?}"),
518 }
519 }
520
521 #[test]
522 fn rollout_defaults_to_none_on_every_construction_path() {
523 assert_eq!(Workflow::new().rollout, None);
524 assert_eq!(Workflow::default().rollout, None);
525 assert_eq!(
526 Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
527 None
528 );
529 assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
530 }
531
532 fn loop_wf(loop_json: &str) -> Result<Workflow> {
538 let workflow = Workflow::from_json(&format!(
539 r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
540 ))?;
541 workflow.validate()?;
542 Ok(workflow)
543 }
544
545 #[test]
546 fn loop_config_defaults_init_zero_increment_one() {
547 let cfg = loop_wf(r#"{"max": 5}"#)
548 .expect("valid loop")
549 .loop_config
550 .expect("loop config present");
551 assert_eq!(cfg.init, 0);
552 assert_eq!(cfg.increment, 1);
553 assert_eq!(cfg.max, 5);
554 assert_eq!(cfg.counter, None);
555 }
556
557 #[test]
558 fn loop_config_is_absent_on_every_construction_path() {
559 assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
560 assert!(Workflow::new().loop_config.is_none());
561 assert!(Workflow::default().loop_config.is_none());
562 assert!(
563 Workflow::rule("r", "r", Value::Bool(true), Vec::new())
564 .loop_config
565 .is_none()
566 );
567 }
568
569 #[test]
570 fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
571 assert!(loop_wf(r#"{"max": 0}"#).is_err());
574 assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
575 assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
576 }
577
578 #[test]
579 fn loop_config_rejects_a_non_advancing_increment() {
580 assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
581 assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
582 }
583
584 #[test]
585 fn loop_config_rejects_an_empty_counter_path() {
586 assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
587 assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
588 assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
589 }
590
591 #[test]
592 fn loop_config_requires_max() {
593 assert!(
596 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
597 );
598 }
599
600 #[test]
601 fn loop_config_deserializes_every_combination_of_optional_fields() {
602 for (json, counter, init, increment) in [
606 (r#"{"max": 9}"#, None, 0, 1),
607 (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
608 (r#"{"max": 9, "init": 4}"#, None, 4, 1),
609 (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
610 (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
611 (
612 r#"{"max": 9, "counter": "i", "increment": 3}"#,
613 Some("i"),
614 0,
615 3,
616 ),
617 (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
618 (
619 r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
620 Some("i"),
621 4,
622 3,
623 ),
624 ] {
625 let cfg = loop_wf(json)
626 .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
627 .loop_config
628 .expect("loop config present");
629 assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
630 assert_eq!(cfg.init, init, "init for {json}");
631 assert_eq!(cfg.increment, increment, "increment for {json}");
632 assert_eq!(cfg.max, 9, "max for {json}");
633 }
634 }
635
636 #[test]
637 fn loop_config_validation_matrix_over_init_increment_and_max() {
638 for (init, increment, max, valid) in [
642 (0_i64, 1_i64, 1_i64, true),
644 (0, 1, 100, true),
645 (0, 7, 3, true), (5, 1, 6, true),
647 (-5, 1, 0, true),
649 (-5, 2, -4, true),
650 (-1, 1, 1, true),
651 (0, 1, 0, false),
653 (5, 1, 5, false),
654 (5, 1, 4, false),
655 (0, 1, -1, false),
656 (-5, 1, -5, false),
657 (0, 0, 10, false),
659 (0, -1, 10, false),
660 (0, -100, 10, false),
661 ] {
662 let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
663 assert_eq!(
664 loop_wf(&json).is_ok(),
665 valid,
666 "init={init} increment={increment} max={max} should be {}",
667 if valid { "accepted" } else { "rejected" }
668 );
669 }
670 }
671
672 #[test]
673 fn loop_config_counter_path_matrix() {
674 for (counter, valid) in [
677 ("i", true),
678 ("index", true),
679 ("cursor.index", true),
680 ("a.b.c.d", true),
681 ("#7", true), ("", false),
683 (".", false),
684 ("a.", false),
685 (".a", false),
686 ("a..b", false),
687 ] {
688 let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
689 assert_eq!(
690 loop_wf(&json).is_ok(),
691 valid,
692 "counter {counter:?} should be {}",
693 if valid { "accepted" } else { "rejected" }
694 );
695 }
696 }
697
698 #[test]
699 fn precompute_counter_path_matrix() {
700 for (counter, expected) in [
701 ("i", vec!["temp_data", "i"]),
702 ("cursor.index", vec!["temp_data", "cursor", "index"]),
703 ("a.b.c", vec!["temp_data", "a", "b", "c"]),
704 ("#7", vec!["temp_data", "#7"]),
707 ] {
708 let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
709 .expect("valid loop")
710 .loop_config
711 .expect("loop config present");
712 cfg.precompute_counter_path();
713 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
714 assert_eq!(parts, expected, "for counter {counter:?}");
715 }
716 }
717
718 #[test]
719 fn precompute_counter_path_is_idempotent() {
720 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
723 .expect("valid loop")
724 .loop_config
725 .expect("loop config present");
726 cfg.precompute_counter_path();
727 let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
728 cfg.precompute_counter_path();
729 assert_eq!(cfg.counter_parts.to_vec(), first);
730 }
731
732 #[test]
733 fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
734 for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
735 assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
736 }
737 }
738
739 #[test]
740 fn an_explicit_null_loop_means_no_loop() {
741 let workflow = loop_wf("null").expect("explicit null should be accepted");
745 assert!(workflow.loop_config.is_none());
746 }
747
748 #[test]
749 fn a_workflow_with_a_loop_still_validates_its_other_rules() {
750 let duplicate_tasks = Workflow::from_json(
753 r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
754 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
755 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
756 )
757 .expect("should parse");
758 assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
759
760 let no_tasks =
761 Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
762 .expect("should parse");
763 assert!(no_tasks.validate().is_err(), "empty task list");
764 }
765
766 #[test]
767 fn loop_config_coexists_with_every_other_workflow_field() {
768 let workflow = Workflow::from_json(&format!(
771 r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
772 "condition": {{"==": [1, 1]}},
773 "loop": {{"counter": "i", "max": 5}},
774 "continue_on_error": true, "channel": "c", "version": 3,
775 "status": "paused",
776 "rollout": {{"bucket_start": 0, "bucket_end": 50}},
777 "tags": ["x"], "tasks": [{MAP}] }}"#
778 ))
779 .expect("should parse");
780 workflow.validate().expect("should validate");
781
782 assert_eq!(workflow.priority, 7);
783 assert_eq!(workflow.channel, "c");
784 assert_eq!(workflow.version, 3);
785 assert_eq!(workflow.status, WorkflowStatus::Paused);
786 assert!(workflow.continue_on_error);
787 assert_eq!(
788 workflow.rollout,
789 Some(Rollout {
790 bucket_start: 0,
791 bucket_end: 50
792 })
793 );
794 assert_eq!(workflow.tags, ["x"]);
795 assert_eq!(
796 workflow
797 .loop_config
798 .expect("loop present")
799 .counter
800 .as_deref(),
801 Some("i")
802 );
803 }
804
805 #[test]
806 fn loop_config_accepts_a_valid_counter() {
807 let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
808 .expect("valid loop")
809 .loop_config
810 .expect("loop config present");
811 assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
812 }
813
814 #[test]
815 fn precompute_counter_path_prefixes_temp_data() {
816 let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
817 .expect("valid loop")
818 .loop_config
819 .expect("loop config present");
820 assert!(
821 cfg.counter_parts.is_empty(),
822 "uncompiled workflows start with no pre-split path"
823 );
824
825 cfg.precompute_counter_path();
826
827 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
828 assert_eq!(parts, ["temp_data", "cursor", "index"]);
829 }
830
831 #[test]
832 fn precompute_counter_path_is_empty_without_a_counter_name() {
833 let mut cfg = loop_wf(r#"{"max": 5}"#)
834 .expect("valid loop")
835 .loop_config
836 .expect("loop config present");
837 cfg.precompute_counter_path();
838 assert!(cfg.counter_parts.is_empty());
839 }
840
841 #[test]
842 fn rollout_deserializes_from_json() {
843 let workflow = Workflow::from_json(
844 r#"{ "id": "w", "name": "w", "condition": true,
845 "rollout": { "bucket_start": 0, "bucket_end": 50 },
846 "tasks": [] }"#,
847 )
848 .unwrap();
849 assert_eq!(
850 workflow.rollout,
851 Some(Rollout {
852 bucket_start: 0,
853 bucket_end: 50
854 })
855 );
856 }
857}