Skip to main content

feldera_types/
pipeline_diff.rs

1use crate::config::PipelineConfigProgramInfo;
2use crate::program_schema::ProgramSchema;
3use feldera_ir::{MirNode, MirNodeId};
4use serde::{Deserialize, Serialize};
5use std::{
6    collections::{BTreeMap, HashMap},
7    fmt::Display,
8};
9use utoipa::ToSchema;
10
11/// Summary of changes in the program between checkpointed and new versions.
12#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
13pub struct ProgramDiff {
14    added_tables: Vec<String>,
15    removed_tables: Vec<String>,
16    modified_tables: Vec<String>,
17
18    added_views: Vec<String>,
19    removed_views: Vec<String>,
20    modified_views: Vec<String>,
21}
22
23impl Display for ProgramDiff {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        fn quoted_list(list: &[String]) -> String {
26            list.iter()
27                .map(|s| format!("'{}'", s))
28                .collect::<Vec<_>>()
29                .join(", ")
30        }
31
32        if !self.added_tables.is_empty() {
33            writeln!(f, "Added tables: {}", quoted_list(&self.added_tables))?;
34        }
35        if !self.removed_tables.is_empty() {
36            writeln!(f, "Removed tables: {}", quoted_list(&self.removed_tables))?;
37        }
38        if !self.modified_tables.is_empty() {
39            writeln!(f, "Modified tables: {}", quoted_list(&self.modified_tables))?;
40        }
41        if !self.added_views.is_empty() {
42            writeln!(f, "Added views: {}", quoted_list(&self.added_views))?;
43        }
44        if !self.removed_views.is_empty() {
45            writeln!(f, "Removed views: {}", quoted_list(&self.removed_views))?;
46        }
47        if !self.modified_views.is_empty() {
48            writeln!(f, "Modified views: {}", quoted_list(&self.modified_views))?;
49        }
50        Ok(())
51    }
52}
53
54impl ProgramDiff {
55    pub fn new() -> Self {
56        Self {
57            added_tables: Vec::new(),
58            removed_tables: Vec::new(),
59            modified_tables: Vec::new(),
60            added_views: Vec::new(),
61            removed_views: Vec::new(),
62            modified_views: Vec::new(),
63        }
64    }
65
66    pub fn with_added_tables(mut self, mut tables: Vec<String>) -> Self {
67        tables.sort();
68        self.added_tables = tables;
69        self
70    }
71
72    pub fn with_removed_tables(mut self, mut tables: Vec<String>) -> Self {
73        tables.sort();
74        self.removed_tables = tables;
75        self
76    }
77
78    pub fn with_modified_tables(mut self, mut tables: Vec<String>) -> Self {
79        tables.sort();
80        self.modified_tables = tables;
81        self
82    }
83
84    pub fn with_added_views(mut self, mut views: Vec<String>) -> Self {
85        views.sort();
86        self.added_views = views;
87        self
88    }
89
90    pub fn with_removed_views(mut self, mut views: Vec<String>) -> Self {
91        views.sort();
92        self.removed_views = views;
93        self
94    }
95
96    pub fn with_modified_views(mut self, mut views: Vec<String>) -> Self {
97        views.sort();
98        self.modified_views = views;
99        self
100    }
101
102    pub fn added_tables(&self) -> &Vec<String> {
103        &self.added_tables
104    }
105
106    pub fn removed_tables(&self) -> &Vec<String> {
107        &self.removed_tables
108    }
109
110    pub fn modified_tables(&self) -> &Vec<String> {
111        &self.modified_tables
112    }
113
114    pub fn added_views(&self) -> &Vec<String> {
115        &self.added_views
116    }
117
118    pub fn removed_views(&self) -> &Vec<String> {
119        &self.removed_views
120    }
121
122    pub fn modified_views(&self) -> &Vec<String> {
123        &self.modified_views
124    }
125
126    pub fn is_empty(&self) -> bool {
127        self.added_tables.is_empty()
128            && self.removed_tables.is_empty()
129            && self.modified_tables.is_empty()
130            && self.added_views.is_empty()
131            && self.removed_views.is_empty()
132            && self.modified_views.is_empty()
133    }
134
135    pub fn is_affected_relation(&self, relation_name: &str) -> bool {
136        let relation_name = relation_name.to_string();
137        self.added_tables.contains(&relation_name)
138            || self.removed_tables.contains(&relation_name)
139            || self.modified_tables.contains(&relation_name)
140            || self.added_views.contains(&relation_name)
141            || self.removed_views.contains(&relation_name)
142            || self.modified_views.contains(&relation_name)
143    }
144}
145
146/// Summary of changes in the pipeline between checkpointed and new versions.
147#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq, Eq)]
148pub struct PipelineDiff {
149    /// IR changes or the reason why we couldn't compute them.
150    program_diff: Option<ProgramDiff>,
151    program_diff_error: Option<String>,
152
153    added_input_connectors: Vec<String>,
154    modified_input_connectors: Vec<String>,
155    removed_input_connectors: Vec<String>,
156
157    added_output_connectors: Vec<String>,
158    modified_output_connectors: Vec<String>,
159    removed_output_connectors: Vec<String>,
160}
161
162impl Display for PipelineDiff {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        if let Some(err) = &self.program_diff_error {
165            writeln!(f, "Could not compute program diff: {err}")?;
166        }
167
168        if let Some(diff) = &self.program_diff
169            && !diff.is_empty()
170        {
171            writeln!(f, "Program changes:")?;
172            for change in diff.to_string().lines() {
173                writeln!(f, "  {change}")?;
174            }
175        }
176
177        if !self.added_input_connectors.is_empty() {
178            writeln!(
179                f,
180                "Added input connectors: {}",
181                self.added_input_connectors.join(", ")
182            )?;
183        }
184
185        if !self.removed_input_connectors.is_empty() {
186            writeln!(
187                f,
188                "Removed input connectors: {}",
189                self.removed_input_connectors.join(", ")
190            )?;
191        }
192
193        if !self.modified_input_connectors.is_empty() {
194            writeln!(
195                f,
196                "Modified input connectors: {}",
197                self.modified_input_connectors.join(", ")
198            )?;
199        }
200
201        if !self.added_output_connectors.is_empty() {
202            writeln!(
203                f,
204                "Added output connectors: {}",
205                self.added_output_connectors.join(", ")
206            )?;
207        }
208
209        if !self.removed_output_connectors.is_empty() {
210            writeln!(
211                f,
212                "Removed output connectors: {}",
213                self.removed_output_connectors.join(", ")
214            )?;
215        }
216
217        if !self.modified_output_connectors.is_empty() {
218            writeln!(
219                f,
220                "Modified output connectors: {}",
221                self.modified_output_connectors.join(", ")
222            )?;
223        }
224
225        Ok(())
226    }
227}
228
229impl PipelineDiff {
230    pub fn new(program_diff_or_err: Result<ProgramDiff, String>) -> Self {
231        match program_diff_or_err {
232            Ok(program_diff) => Self::new_with_program_diff(program_diff),
233            Err(program_diff_error) => Self::new_with_program_diff_error(program_diff_error),
234        }
235    }
236
237    pub fn new_with_program_diff(program_diff: ProgramDiff) -> Self {
238        Self {
239            program_diff: Some(program_diff),
240            program_diff_error: None,
241            added_input_connectors: Vec::new(),
242            modified_input_connectors: Vec::new(),
243            removed_input_connectors: Vec::new(),
244            added_output_connectors: Vec::new(),
245            modified_output_connectors: Vec::new(),
246            removed_output_connectors: Vec::new(),
247        }
248    }
249
250    pub fn new_with_program_diff_error(program_diff_error: String) -> Self {
251        Self {
252            program_diff: None,
253            program_diff_error: Some(program_diff_error),
254            added_input_connectors: Vec::new(),
255            modified_input_connectors: Vec::new(),
256            removed_input_connectors: Vec::new(),
257            added_output_connectors: Vec::new(),
258            modified_output_connectors: Vec::new(),
259            removed_output_connectors: Vec::new(),
260        }
261    }
262
263    pub fn with_added_input_connectors(mut self, mut connectors: Vec<String>) -> Self {
264        connectors.sort();
265        self.added_input_connectors = connectors;
266        self
267    }
268
269    pub fn with_modified_input_connectors(mut self, mut connectors: Vec<String>) -> Self {
270        connectors.sort();
271        self.modified_input_connectors = connectors;
272        self
273    }
274
275    pub fn with_removed_input_connectors(mut self, mut connectors: Vec<String>) -> Self {
276        connectors.sort();
277        self.removed_input_connectors = connectors;
278        self
279    }
280
281    pub fn with_added_output_connectors(mut self, mut connectors: Vec<String>) -> Self {
282        connectors.sort();
283        self.added_output_connectors = connectors;
284        self
285    }
286
287    pub fn with_modified_output_connectors(mut self, mut connectors: Vec<String>) -> Self {
288        connectors.sort();
289        self.modified_output_connectors = connectors;
290        self
291    }
292
293    pub fn with_removed_output_connectors(mut self, mut connectors: Vec<String>) -> Self {
294        connectors.sort();
295        self.removed_output_connectors = connectors;
296        self
297    }
298
299    pub fn is_empty(&self) -> bool {
300        self.program_diff
301            .as_ref()
302            .map(|diff| diff.is_empty())
303            .unwrap_or(false)
304            && self.added_input_connectors.is_empty()
305            && self.removed_input_connectors.is_empty()
306            && self.modified_input_connectors.is_empty()
307            && self.added_output_connectors.is_empty()
308            && self.removed_output_connectors.is_empty()
309            && self.modified_output_connectors.is_empty()
310    }
311
312    pub fn clear_program_diff(&mut self) {
313        self.program_diff = Some(ProgramDiff::default());
314    }
315
316    pub fn is_affected_connector(&self, connector_name: &str) -> bool {
317        let connector_name = connector_name.to_string();
318        self.added_input_connectors.contains(&connector_name)
319            || self.removed_input_connectors.contains(&connector_name)
320            || self.modified_input_connectors.contains(&connector_name)
321            || self.added_output_connectors.contains(&connector_name)
322            || self.removed_output_connectors.contains(&connector_name)
323            || self.modified_output_connectors.contains(&connector_name)
324    }
325
326    pub fn is_affected_relation(&self, relation_name: &str) -> bool {
327        self.program_diff
328            .as_ref()
329            .map(|diff| diff.is_affected_relation(relation_name))
330            .unwrap_or(false)
331    }
332
333    pub fn program_diff(&self) -> Option<&ProgramDiff> {
334        self.program_diff.as_ref()
335    }
336
337    pub fn program_diff_error(&self) -> Option<&String> {
338        self.program_diff_error.as_ref()
339    }
340
341    pub fn added_input_connectors(&self) -> &Vec<String> {
342        &self.added_input_connectors
343    }
344
345    pub fn modified_input_connectors(&self) -> &Vec<String> {
346        &self.modified_input_connectors
347    }
348
349    pub fn removed_input_connectors(&self) -> &Vec<String> {
350        &self.removed_input_connectors
351    }
352
353    pub fn added_output_connectors(&self) -> &Vec<String> {
354        &self.added_output_connectors
355    }
356
357    pub fn modified_output_connectors(&self) -> &Vec<String> {
358        &self.modified_output_connectors
359    }
360
361    pub fn removed_output_connectors(&self) -> &Vec<String> {
362        &self.removed_output_connectors
363    }
364}
365
366pub fn program_diff(
367    old_mir: &HashMap<MirNodeId, MirNode>,
368    new_mir: &HashMap<MirNodeId, MirNode>,
369) -> ProgramDiff {
370    let old_tables: HashMap<String, String> = old_mir
371        .values()
372        .filter(|node| node.persistent_id.is_some())
373        .filter_map(|node| {
374            node.table
375                .as_ref()
376                .map(|name| (name.clone(), node.persistent_id.clone().unwrap()))
377        })
378        .collect();
379
380    let old_views: HashMap<String, String> = old_mir
381        .values()
382        .filter(|node| node.persistent_id.is_some())
383        .filter_map(|node| {
384            node.view
385                .as_ref()
386                .map(|name| (name.clone(), node.persistent_id.clone().unwrap()))
387        })
388        .collect();
389
390    let new_tables: HashMap<String, String> = new_mir
391        .values()
392        .filter(|node| node.persistent_id.is_some())
393        .filter_map(|node| {
394            node.table
395                .as_ref()
396                .map(|name| (name.clone(), node.persistent_id.clone().unwrap()))
397        })
398        .collect();
399
400    let new_views: HashMap<String, String> = new_mir
401        .values()
402        .filter(|node| node.persistent_id.is_some())
403        .filter_map(|node| {
404            node.view
405                .as_ref()
406                .map(|name| (name.clone(), node.persistent_id.clone().unwrap()))
407        })
408        .collect();
409
410    let added_tables = new_tables
411        .keys()
412        .filter(|k| !old_tables.contains_key(*k))
413        .cloned()
414        .collect();
415    let removed_tables = old_tables
416        .keys()
417        .filter(|k| !new_tables.contains_key(*k))
418        .cloned()
419        .collect();
420    let modified_tables = new_tables
421        .iter()
422        .filter_map(|(name, id)| {
423            if let Some(old_id) = old_tables.get(name) {
424                if old_id != id {
425                    Some(name.clone())
426                } else {
427                    None
428                }
429            } else {
430                None
431            }
432        })
433        .collect();
434
435    let added_views = new_views
436        .keys()
437        .filter(|k| !old_views.contains_key(*k))
438        .cloned()
439        .collect();
440    let removed_views = old_views
441        .keys()
442        .filter(|k| !new_views.contains_key(*k))
443        .cloned()
444        .collect();
445    let modified_views = new_views
446        .iter()
447        .filter_map(|(name, id)| {
448            if let Some(old_id) = old_views.get(name) {
449                if old_id != id {
450                    Some(name.clone())
451                } else {
452                    None
453                }
454            } else {
455                None
456            }
457        })
458        .collect();
459
460    ProgramDiff::new()
461        .with_added_tables(added_tables)
462        .with_removed_tables(removed_tables)
463        .with_modified_tables(modified_tables)
464        .with_added_views(added_views)
465        .with_removed_views(removed_views)
466        .with_modified_views(modified_views)
467}
468
469/// Reasons bootstrapping cannot be performed.
470struct BootstrapBlockers {
471    /// The new version of the program has relations with lateness.
472    new_relations_with_lateness: Vec<String>,
473
474    /// The old version of the program has relations with lateness.
475    old_relations_with_lateness: Vec<String>,
476}
477
478impl BootstrapBlockers {
479    fn is_empty(&self) -> bool {
480        self.new_relations_with_lateness.is_empty() && self.old_relations_with_lateness.is_empty()
481    }
482}
483
484impl Display for BootstrapBlockers {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        if !self.new_relations_with_lateness.is_empty() {
487            writeln!(
488                f,
489                "- The new version of the program has relations with lateness: {}",
490                self.new_relations_with_lateness.join(", ")
491            )?;
492        }
493        if !self.old_relations_with_lateness.is_empty() {
494            writeln!(
495                f,
496                "- The checkpointed version of the program has relations with lateness: {}",
497                self.old_relations_with_lateness.join(", ")
498            )?;
499        }
500        Ok(())
501    }
502}
503
504/// Error returned by [`compute_pipeline_diff`].
505#[derive(Debug, Clone, thiserror::Error)]
506pub enum PipelineDiffError {
507    /// The change requires bootstrapping, but bootstrapping is not allowed
508    /// because the old or the new program has relations with lateness.
509    #[error("{error}")]
510    BootstrapNotAllowed { error: String },
511}
512
513fn compute_program_diff(
514    old_config: &PipelineConfigProgramInfo,
515    new_config: &PipelineConfigProgramInfo,
516) -> Result<(ProgramDiff, BootstrapBlockers), String> {
517    let Some(old_dataflow) = &old_config.program_ir else {
518        return Err("Unable to compute the diff between the checkpointed and new pipeline configurations: the checkpointed configuration does not contain program information. It was likely created by an old version of Feldera.".to_owned());
519    };
520
521    let Some(new_dataflow) = &new_config.program_ir else {
522        return Err("Unable to compute the diff between the checkpointed and new pipeline configurations: the new configuration does not contain program information. It was likely created by an old version of Feldera.".to_owned());
523    };
524
525    // TODO: consider parsing only the necessary subset of schema fields to avoid compatibility issues
526    // with older runtime versions.
527    let new_program_schema: ProgramSchema =
528        serde_json::from_value(new_dataflow.program_schema.clone())
529            .map_err(|e| format!("Error parsing new program schema: {}", e))?;
530    let old_program_schema: ProgramSchema =
531        serde_json::from_value(old_dataflow.program_schema.clone())
532            .map_err(|e| format!("Error parsing old program schema: {}", e))?;
533
534    let new_relations_with_lateness = new_program_schema
535        .relations_with_lateness()
536        .into_iter()
537        .map(|s| s.name().to_string())
538        .collect::<Vec<_>>();
539    let old_relations_with_lateness = old_program_schema
540        .relations_with_lateness()
541        .into_iter()
542        .map(|s| s.name().to_string())
543        .collect::<Vec<_>>();
544
545    let blockers = BootstrapBlockers {
546        new_relations_with_lateness,
547        old_relations_with_lateness,
548    };
549
550    Ok((program_diff(&old_dataflow.mir, &new_dataflow.mir), blockers))
551}
552
553/// Compute the diff between two pipeline configurations.
554///
555/// Compares the compiler-generated program IR and connector configuration of
556/// `old_config` and `new_config`, reporting added, removed, and modified
557/// tables, views, and connectors. It drives bootstrapping when a pipeline
558/// resumes from a checkpoint, and backs the `/diff` API endpoint that previews
559/// changes without restarting the pipeline.
560pub fn compute_pipeline_diff(
561    old_config: &PipelineConfigProgramInfo,
562    new_config: &PipelineConfigProgramInfo,
563) -> Result<PipelineDiff, PipelineDiffError> {
564    let diff = compute_program_diff(old_config, new_config);
565
566    if let Ok((diff, blockers)) = &diff
567        && !blockers.is_empty()
568        && !diff.is_empty()
569    {
570        return Err(PipelineDiffError::BootstrapNotAllowed {
571            error: blockers.to_string(),
572        });
573    };
574
575    let mir_diff = diff.map(|(diff, _)| diff.clone());
576
577    let mut old_configured_inputs = old_config
578        .inputs
579        .iter()
580        .filter(|(_, cfg)| !cfg.connector_config.transport.is_transient())
581        .map(|(name, cfg)| (name.clone(), cfg.clone()))
582        .collect::<BTreeMap<_, _>>();
583
584    old_configured_inputs.remove("now");
585
586    let old_configured_outputs = old_config
587        .outputs
588        .iter()
589        .filter(|(_, cfg)| !cfg.connector_config.transport.is_transient())
590        .map(|(name, cfg)| (name.clone(), cfg.clone()))
591        .collect::<BTreeMap<_, _>>();
592
593    let added_input_connectors = new_config
594        .inputs
595        .keys()
596        .filter(|k| !old_configured_inputs.contains_key(*k))
597        .map(|k| k.to_string())
598        .collect::<Vec<_>>();
599
600    let removed_input_connectors = old_configured_inputs
601        .iter()
602        .filter(|(k, config)| {
603            !new_config.inputs.contains_key(*k)
604                && !mir_diff
605                    .as_ref()
606                    .map(|mir_diff| {
607                        mir_diff
608                            .removed_tables()
609                            .contains(&config.stream.to_string())
610                    })
611                    .unwrap_or(true)
612        })
613        .map(|(k, _)| k.to_string())
614        .collect::<Vec<_>>();
615
616    let modified_input_connectors = new_config
617        .inputs
618        .iter()
619        .filter(|(k, v)| {
620            old_configured_inputs.contains_key(*k)
621                && !old_configured_inputs
622                    .get(*k)
623                    .unwrap()
624                    .connector_config
625                    .equal_for_input_checkpoint_replay(&v.connector_config)
626        })
627        .map(|(k, _)| k.to_string())
628        .collect::<Vec<_>>();
629
630    let added_output_connectors = new_config
631        .outputs
632        .keys()
633        .filter(|k| !old_configured_outputs.contains_key(*k))
634        .map(|k| k.to_string())
635        .collect::<Vec<_>>();
636
637    let removed_output_connectors = old_configured_outputs
638        .iter()
639        .filter(|(k, config)| {
640            !new_config.outputs.contains_key(*k)
641                && !mir_diff
642                    .as_ref()
643                    .map(|mir_diff| {
644                        mir_diff
645                            .removed_views()
646                            .contains(&config.stream.to_string())
647                    })
648                    .unwrap_or(true)
649        })
650        .map(|(k, _)| k.to_string())
651        .collect::<Vec<_>>();
652
653    let modified_output_connectors = new_config
654        .outputs
655        .iter()
656        .filter(|(k, v)| {
657            old_configured_outputs.contains_key(*k)
658                && !old_configured_outputs
659                    .get(*k)
660                    .unwrap()
661                    .connector_config
662                    .equal_modulo_paused(&v.connector_config)
663        })
664        .map(|(k, _)| k.to_string())
665        .collect::<Vec<_>>();
666
667    Ok(PipelineDiff::new(mir_diff)
668        .with_added_input_connectors(added_input_connectors)
669        .with_modified_input_connectors(modified_input_connectors)
670        .with_removed_input_connectors(removed_input_connectors)
671        .with_added_output_connectors(added_output_connectors)
672        .with_modified_output_connectors(modified_output_connectors)
673        .with_removed_output_connectors(removed_output_connectors))
674}
675
676#[cfg(test)]
677mod tests {
678    use super::compute_pipeline_diff;
679    use crate::config::PipelineConfigProgramInfo;
680    use serde_json::{Map, Value, json};
681
682    fn input_endpoint(mut fields: Map<String, Value>) -> Value {
683        let mut endpoint = Map::from_iter([
684            ("stream".to_string(), json!("t1")),
685            ("transport".to_string(), json!({"name": "empty_input"})),
686        ]);
687        endpoint.append(&mut fields);
688        Value::Object(endpoint)
689    }
690
691    fn pipeline_config(input: Value) -> PipelineConfigProgramInfo {
692        serde_json::from_value(json!({
693            "inputs": {
694                "t1.connector": input
695            }
696        }))
697        .unwrap()
698    }
699
700    #[test]
701    fn input_flow_control_changes_do_not_modify_connector() {
702        let old_config = pipeline_config(input_endpoint(Map::new()));
703        let new_config = pipeline_config(input_endpoint(Map::from_iter([
704            ("max_queued_records".to_string(), json!(5000)),
705            ("max_queued_bytes".to_string(), json!(6000)),
706            ("max_batch_size".to_string(), json!(7000)),
707            ("max_worker_batch_size".to_string(), json!(8000)),
708            ("paused".to_string(), json!(true)),
709        ])));
710
711        let diff = compute_pipeline_diff(&old_config, &new_config).unwrap();
712
713        assert!(diff.modified_input_connectors().is_empty());
714    }
715
716    #[test]
717    fn input_transport_changes_still_modify_connector() {
718        let old_config = pipeline_config(input_endpoint(Map::new()));
719        let new_config = pipeline_config(input_endpoint(Map::from_iter([(
720            "transport".to_string(),
721            json!({
722                "name": "datagen",
723                "config": {
724                    "plan": [{"limit": 1}]
725                }
726            }),
727        )])));
728
729        let diff = compute_pipeline_diff(&old_config, &new_config).unwrap();
730
731        assert_eq!(
732            diff.modified_input_connectors(),
733            &vec!["t1.connector".to_string()]
734        );
735    }
736}