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#[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#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq, Eq)]
148pub struct PipelineDiff {
149 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
469struct BootstrapBlockers {
471 new_relations_with_lateness: Vec<String>,
473
474 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#[derive(Debug, Clone, thiserror::Error)]
506pub enum PipelineDiffError {
507 #[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 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
553pub 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}