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#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
20pub struct Rollout {
21 pub bucket_start: u8,
23 pub bucket_end: u8,
25}
26
27impl Rollout {
28 #[inline]
33 pub fn accepts(&self, bucket: u8) -> bool {
34 bucket >= self.bucket_start && bucket < self.bucket_end
35 }
36}
37
38#[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#[derive(Clone, Debug, Deserialize)]
52pub struct Workflow {
53 pub id: String,
54 #[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 #[doc(hidden)]
71 #[serde(skip)]
72 pub compiled_condition: Option<Arc<Logic>>,
73 #[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 #[serde(default = "default_channel")]
86 pub channel: String,
87 #[serde(default = "default_version")]
89 pub version: u32,
90 #[serde(default)]
92 pub status: WorkflowStatus,
93 #[serde(default)]
100 pub rollout: Option<Rollout>,
101 #[serde(default)]
103 pub tags: Vec<String>,
104 #[serde(default)]
106 pub created_at: Option<DateTime<Utc>>,
107 #[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 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 pub fn from_json(json_str: &str) -> Result<Self> {
183 serde_json::from_str(json_str).map_err(DataflowError::from_serde)
184 }
185
186 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 pub fn validate(&self) -> Result<()> {
195 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 if self.tasks.is_empty() {
210 return Err(DataflowError::Workflow(
211 "Workflow must have at least one task".to_string(),
212 ));
213 }
214
215 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#[derive(Debug, Clone, Copy)]
239pub struct ConnectorRef<'a> {
240 pub workflow_id: &'a str,
242 pub task_id: &'a str,
244 pub function: &'a str,
246 pub connector: &'a str,
248 pub config: &'a FunctionConfig,
250}
251
252impl Workflow {
253 pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
266 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 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 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 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 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 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 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 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 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}