1use std::collections::BTreeMap;
13
14use crate::cli::CliError;
15use crate::cli::commands::grants;
16use crate::cli::executor::{
17 ApplyPlan, ApplyResult, DeploymentExecutor, ObjectAction, ObjectResult, connect_apply_client,
18};
19use crate::client::{Client, parse_create_cluster, quote_identifier};
20use crate::config::Settings;
21use crate::project::clusters::{self, ClusterDefinition};
22use mz_repr::adt::interval::Interval;
23use mz_sql_parser::ast::display::AstDisplay;
24use mz_sql_parser::ast::visit_mut::VisitMut;
25use mz_sql_parser::ast::{
26 ClusterOption, ClusterOptionName, CreateClusterStatement, Raw, Value, WithOptionValue,
27};
28
29pub async fn plan(
31 settings: &Settings,
32 client: &Client,
33 executor: &DeploymentExecutor<'_>,
34) -> Result<ApplyResult, CliError> {
35 let profile = settings.connection();
36 let directory = &settings.directory;
37
38 let definitions = clusters::load_clusters(
39 directory,
40 &profile.name,
41 settings.profile_suffix(),
42 settings.variables(),
43 )?;
44
45 if definitions.is_empty() {
46 return Ok(ApplyResult {
47 phase: "clusters".to_string(),
48 results: vec![],
49 });
50 }
51
52 let mut object_results = Vec::new();
53 for def in &definitions {
54 let obj_result = plan_cluster(client, executor, def).await?;
55 object_results.push(obj_result);
56 }
57
58 Ok(ApplyResult {
59 phase: "clusters".to_string(),
60 results: object_results,
61 })
62}
63
64pub async fn run(settings: &Settings, dry_run: bool) -> Result<ApplyPlan, CliError> {
66 let client = connect_apply_client(settings).await?;
67 let executor = DeploymentExecutor::new_dry_run(&client);
68 let mut plan_result = ApplyPlan::new();
69 let phase = plan(settings, &client, &executor).await?;
70 plan_result.add_phase(phase);
71
72 if !dry_run {
73 plan_result.execute(&client).await?;
74 }
75
76 Ok(plan_result)
77}
78
79async fn plan_cluster(
82 client: &Client,
83 executor: &DeploymentExecutor<'_>,
84 def: &ClusterDefinition,
85) -> Result<ObjectResult, CliError> {
86 let cluster_name = &def.name;
87
88 executor.take_statements();
90
91 let live = live_cluster(client, cluster_name).await?;
92
93 let action = match live {
94 None => {
95 executor.execute_sql(&def.create_stmt).await?;
96 ObjectAction::Created
97 }
98 Some(live) => {
99 let defaults = default_options(
100 client
101 .default_cluster_replication_factor()
102 .await
103 .map_err(CliError::Connection)?,
104 );
105 let (to_set, to_reset) = diff_cluster_options(&def.create_stmt, &live, &defaults);
106
107 if to_set.is_empty() && to_reset.is_empty() {
108 ObjectAction::UpToDate
109 } else {
110 if !to_reset.is_empty() {
119 let reset_sql = format!(
120 "ALTER CLUSTER {} RESET ({})",
121 quote_identifier(cluster_name),
122 render_option_list(&to_reset)
123 );
124 executor.execute_sql(&reset_sql).await?;
125 }
126 if !to_set.is_empty() {
127 let alter_sql = format!(
128 "ALTER CLUSTER {} SET ({})",
129 quote_identifier(cluster_name),
130 render_option_list(&to_set)
131 );
132 executor.execute_sql(&alter_sql).await?;
133 }
134 ObjectAction::Altered
135 }
136 }
137 };
138
139 grants::reconcile_named_object(
141 client,
142 executor,
143 cluster_name,
144 &def.grants,
145 &grants::GrantNamedObjectKind::Cluster,
146 )
147 .await?;
148
149 for comment in &def.comments {
151 executor.execute_sql(comment).await?;
152 }
153
154 Ok(ObjectResult {
155 object: cluster_name.clone(),
156 action,
157 statements: executor.take_statements(),
158 redacted_statements: vec![],
159 transaction_group: None,
160 post_statements: vec![],
161 })
162}
163
164async fn live_cluster(
169 client: &Client,
170 name: &str,
171) -> Result<Option<CreateClusterStatement<Raw>>, CliError> {
172 let Some(cluster) = client
173 .introspection()
174 .get_cluster(name)
175 .await
176 .map_err(CliError::Connection)?
177 else {
178 return Ok(None);
179 };
180 if !cluster.managed {
181 return Err(CliError::Message(format!(
182 "cluster '{}' is unmanaged; mz-deploy reconciles managed clusters only",
183 name
184 )));
185 }
186 client
187 .introspection()
188 .get_cluster_create_sql(name)
189 .await
190 .map_err(CliError::Connection)?
191 .map(|sql| parse_create_cluster(&sql).map_err(CliError::Message))
192 .transpose()
193}
194
195fn default_options(replication_factor: u32) -> BTreeMap<ClusterOptionName, WithOptionValue<Raw>> {
204 let sql = format!(
205 "CREATE CLUSTER defaults (\
206 EXPERIMENTAL ARRANGEMENT COMPRESSION = false, \
207 INTROSPECTION DEBUGGING = false, \
208 INTROSPECTION INTERVAL = INTERVAL '00:00:01', \
209 MANAGED = true, \
210 REPLICATION FACTOR = {replication_factor}, \
211 SCHEDULE = MANUAL)"
212 );
213 let create = parse_create_cluster(&sql).expect("cluster defaults are valid SQL");
214 create
215 .options
216 .iter()
217 .map(|option| (option.name.clone(), comparable(option)))
218 .collect()
219}
220
221fn comparable(option: &ClusterOption<Raw>) -> WithOptionValue<Raw> {
235 let mut value = option
236 .value
237 .clone()
238 .unwrap_or(WithOptionValue::Value(Value::Boolean(true)));
239 CanonicalIntervals.visit_with_option_value_mut(&mut value);
240 if option.name == ClusterOptionName::IntrospectionInterval && is_zero_interval(&value) {
241 value = WithOptionValue::Value(Value::Null);
242 }
243 value
244}
245
246struct CanonicalIntervals;
255
256impl<'ast> VisitMut<'ast, Raw> for CanonicalIntervals {
257 fn visit_value_mut(&mut self, node: &'ast mut Value) {
258 let text = match node {
259 Value::Number(text) | Value::String(text) => text.as_str(),
260 Value::Interval(interval) => interval.value.as_str(),
261 _ => return,
262 };
263 if let Ok(interval) = mz_repr::strconv::parse_interval(text) {
264 *node = Value::String(interval.to_string());
265 }
266 }
267}
268
269fn is_zero_interval(value: &WithOptionValue<Raw>) -> bool {
272 let WithOptionValue::Value(Value::String(text)) = value else {
273 return false;
274 };
275 mz_repr::strconv::parse_interval(text).is_ok_and(|interval| interval == Interval::default())
276}
277
278fn diff_cluster_options(
288 local: &CreateClusterStatement<Raw>,
289 live: &CreateClusterStatement<Raw>,
290 defaults: &BTreeMap<ClusterOptionName, WithOptionValue<Raw>>,
291) -> (Vec<ClusterOption<Raw>>, Vec<ClusterOptionName>) {
292 let local_options = index_options(local);
293 let live_options = index_options(live);
294
295 let to_set = local_options
296 .values()
297 .filter(|option| {
298 live_options.get(&option.name).map(|live| comparable(live)) != Some(comparable(option))
299 })
300 .map(|option| (*option).clone())
301 .collect();
302
303 let to_reset = live_options
304 .values()
305 .filter(|option| !local_options.contains_key(&option.name))
306 .filter(|option| defaults.get(&option.name) != Some(&comparable(option)))
307 .map(|option| option.name.clone())
308 .collect();
309
310 (to_set, to_reset)
311}
312
313fn index_options(
317 create: &CreateClusterStatement<Raw>,
318) -> BTreeMap<ClusterOptionName, &ClusterOption<Raw>> {
319 create
320 .options
321 .iter()
322 .filter(|option| !is_discarded(option))
323 .map(|option| (option.name.clone(), option))
324 .collect()
325}
326
327fn is_discarded(option: &ClusterOption<Raw>) -> bool {
335 option.name == ClusterOptionName::Disk || option.to_ast_string_simple().ends_with("= ()")
336}
337
338fn render_option_list<T: AstDisplay>(items: &[T]) -> String {
341 items
342 .iter()
343 .map(AstDisplay::to_ast_string_simple)
344 .collect::<Vec<_>>()
345 .join(", ")
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn live(size: &str, replication_factor: u32, extra: &str) -> CreateClusterStatement<Raw> {
355 let sql = format!(
356 "CREATE CLUSTER \"scaled\" (\
357 EXPERIMENTAL ARRANGEMENT COMPRESSION = false, \
358 INTROSPECTION DEBUGGING = false, \
359 INTROSPECTION INTERVAL = INTERVAL '00:00:01', \
360 MANAGED = true, \
361 REPLICATION FACTOR = {replication_factor}, \
362 SIZE = '{size}', \
363 SCHEDULE = MANUAL{extra})"
364 );
365 parse_create_cluster(&sql).unwrap()
366 }
367
368 fn live_exactly(options: &str) -> CreateClusterStatement<Raw> {
371 parse_create_cluster(&format!("CREATE CLUSTER \"scaled\" ({options})")).unwrap()
372 }
373
374 fn diff(local: &str, live: &CreateClusterStatement<Raw>) -> (Vec<String>, Vec<String>) {
376 let local = parse_create_cluster(local).unwrap();
377 let (to_set, to_reset) = diff_cluster_options(&local, live, &default_options(1));
378 (
379 to_set
380 .iter()
381 .map(AstDisplay::to_ast_string_simple)
382 .collect(),
383 to_reset
384 .iter()
385 .map(AstDisplay::to_ast_string_simple)
386 .collect(),
387 )
388 }
389
390 #[mz_ore::test]
391 fn test_diff_defaults_are_not_drift() {
392 assert_eq!(
395 diff(
396 "CREATE CLUSTER scaled (SIZE = '25cc')",
397 &live("25cc", 1, "")
398 ),
399 (vec![], Vec::<String>::new())
400 );
401 }
402
403 #[mz_ore::test]
404 fn test_diff_up_to_date() {
405 let strategy = ", AUTO SCALING STRATEGY = (ON HYDRATION (HYDRATION SIZE = '100cc'))";
406 assert_eq!(
407 diff(
408 "CREATE CLUSTER scaled (SIZE = '25cc', REPLICATION FACTOR = 2, \
409 AUTO SCALING STRATEGY = (ON HYDRATION (HYDRATION SIZE = '100cc')))",
410 &live("25cc", 2, strategy)
411 ),
412 (vec![], Vec::<String>::new())
413 );
414 }
415
416 #[mz_ore::test]
417 fn test_diff_size_only() {
418 assert_eq!(
419 diff(
420 "CREATE CLUSTER scaled (SIZE = '50cc', REPLICATION FACTOR = 2)",
421 &live("25cc", 2, "")
422 ),
423 (vec!["SIZE = '50cc'".to_string()], Vec::<String>::new())
424 );
425 }
426
427 #[mz_ore::test]
428 fn test_diff_replication_factor_reset_when_omitted() {
429 assert_eq!(
431 diff(
432 "CREATE CLUSTER scaled (SIZE = '25cc')",
433 &live("25cc", 3, "")
434 ),
435 (vec![], vec!["REPLICATION FACTOR".to_string()])
436 );
437 }
438
439 #[mz_ore::test]
440 fn test_diff_strategy_set() {
441 assert_eq!(
442 diff(
443 "CREATE CLUSTER scaled (SIZE = '25cc', REPLICATION FACTOR = 2, \
444 AUTO SCALING STRATEGY = (ON HYDRATION (HYDRATION SIZE = '100cc')))",
445 &live("25cc", 2, "")
446 ),
447 (
448 vec![
449 "AUTO SCALING STRATEGY = (ON HYDRATION (HYDRATION SIZE = '100cc'))".to_string()
450 ],
451 Vec::<String>::new()
452 )
453 );
454 }
455
456 #[mz_ore::test]
457 fn test_diff_strategy_reset() {
458 let strategy = ", AUTO SCALING STRATEGY = (ON HYDRATION (HYDRATION SIZE = '100cc'))";
459 assert_eq!(
460 diff(
461 "CREATE CLUSTER scaled (SIZE = '25cc', REPLICATION FACTOR = 2)",
462 &live("25cc", 2, strategy)
463 ),
464 (vec![], vec!["AUTO SCALING STRATEGY".to_string()])
465 );
466 }
467
468 #[mz_ore::test]
469 fn test_diff_option_the_reconciler_never_names() {
470 assert_eq!(
473 diff(
474 "CREATE CLUSTER scaled (SIZE = '25cc', REPLICATION FACTOR = 2, \
475 AVAILABILITY ZONES = ('use1-az1'))",
476 &live("25cc", 2, ", WORKLOAD CLASS = 'batch'")
477 ),
478 (
479 vec!["AVAILABILITY ZONES = ('use1-az1')".to_string()],
480 vec!["WORKLOAD CLASS".to_string()]
481 )
482 );
483 }
484
485 #[mz_ore::test]
486 fn test_diff_duration_spelling_is_not_drift() {
487 let strategy = ", AUTO SCALING STRATEGY = (ON HYDRATION \
489 (HYDRATION SIZE = '100cc', LINGER DURATION = '00:01:00'))";
490 assert_eq!(
491 diff(
492 "CREATE CLUSTER scaled (SIZE = '25cc', AUTO SCALING STRATEGY = \
493 (ON HYDRATION (HYDRATION SIZE = '100cc', LINGER DURATION = '60s')))",
494 &live("25cc", 1, strategy)
495 ),
496 (vec![], Vec::<String>::new())
497 );
498 }
499
500 #[mz_ore::test]
501 fn test_diff_sizes_are_not_read_as_durations() {
502 assert_eq!(
504 diff(
505 "CREATE CLUSTER scaled (SIZE = '50cc')",
506 &live("25cc", 1, "")
507 ),
508 (vec!["SIZE = '50cc'".to_string()], Vec::<String>::new())
509 );
510 }
511
512 #[mz_ore::test]
513 fn test_diff_empty_block_is_not_drift() {
514 assert_eq!(
517 diff(
518 "CREATE CLUSTER scaled (SIZE = '25cc', AUTO SCALING STRATEGY = ())",
519 &live("25cc", 1, "")
520 ),
521 (vec![], Vec::<String>::new())
522 );
523 }
524
525 #[mz_ore::test]
526 fn test_diff_implied_true_is_not_drift() {
527 assert_eq!(
529 diff(
530 "CREATE CLUSTER scaled (SIZE = '25cc', EXPERIMENTAL ARRANGEMENT COMPRESSION, \
531 INTROSPECTION DEBUGGING, MANAGED)",
532 &live_exactly(
533 "EXPERIMENTAL ARRANGEMENT COMPRESSION = true, \
534 INTROSPECTION DEBUGGING = true, \
535 INTROSPECTION INTERVAL = INTERVAL '00:00:01', \
536 MANAGED = true, REPLICATION FACTOR = 1, SIZE = '25cc', SCHEDULE = MANUAL"
537 )
538 ),
539 (vec![], Vec::<String>::new())
540 );
541 }
542
543 #[mz_ore::test]
544 fn test_diff_zero_interval_is_not_drift() {
545 let live = live_exactly(
548 "EXPERIMENTAL ARRANGEMENT COMPRESSION = false, \
549 INTROSPECTION INTERVAL = NULL, \
550 MANAGED = true, REPLICATION FACTOR = 1, SIZE = '25cc', SCHEDULE = MANUAL",
551 );
552 assert_eq!(
553 diff(
554 "CREATE CLUSTER scaled (SIZE = '25cc', INTROSPECTION INTERVAL = 0)",
555 &live
556 ),
557 (vec![], Vec::<String>::new())
558 );
559 assert_eq!(
560 diff(
561 "CREATE CLUSTER scaled (SIZE = '25cc', INTROSPECTION INTERVAL = '0s')",
562 &live
563 ),
564 (vec![], Vec::<String>::new())
565 );
566 }
567
568 #[mz_ore::test]
569 fn test_diff_zero_reduces_to_null_only_for_the_interval() {
570 assert_eq!(
573 diff(
574 "CREATE CLUSTER scaled (SIZE = '25cc', WORKLOAD CLASS = '0')",
575 &live_exactly(
576 "INTROSPECTION INTERVAL = INTERVAL '00:00:01', MANAGED = true, \
577 REPLICATION FACTOR = 1, SIZE = '25cc', SCHEDULE = MANUAL, \
578 WORKLOAD CLASS = NULL"
579 )
580 ),
581 (
582 vec!["WORKLOAD CLASS = '0'".to_string()],
583 Vec::<String>::new()
584 )
585 );
586 }
587
588 #[mz_ore::test]
589 fn test_diff_disk_is_not_drift() {
590 assert_eq!(
593 diff(
594 "CREATE CLUSTER scaled (SIZE = '25cc', DISK)",
595 &live("25cc", 1, "")
596 ),
597 (vec![], Vec::<String>::new())
598 );
599 }
600
601 #[mz_ore::test]
602 fn test_default_replication_factor_is_read_from_the_server() {
603 let local = parse_create_cluster("CREATE CLUSTER scaled (SIZE = '25cc')").unwrap();
605 let (to_set, to_reset) =
606 diff_cluster_options(&local, &live("25cc", 2, ""), &default_options(2));
607 assert!(to_set.is_empty() && to_reset.is_empty());
608 }
609
610 #[mz_ore::test]
611 fn test_default_introspection_interval_matches_the_server() {
612 assert_eq!(
615 mz_controller_types::DEFAULT_REPLICA_LOGGING_INTERVAL,
616 std::time::Duration::from_secs(1)
617 );
618 }
619}