Skip to main content

mz_deploy/cli/commands/
clusters.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Clusters apply command - converge live cluster state to match definitions.
11
12use 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
29/// Plan cluster changes without executing or printing.
30pub 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
64/// Run the `clusters apply` command: plan, render, optionally execute.
65pub 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
79/// Plan a single cluster definition: create if missing, alter if drifted,
80/// then plan grants, revocations, and comments.
81async fn plan_cluster(
82    client: &Client,
83    executor: &DeploymentExecutor<'_>,
84    def: &ClusterDefinition,
85) -> Result<ObjectResult, CliError> {
86    let cluster_name = &def.name;
87
88    // Drain any prior statements
89    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                // RESET must run before SET. When an edit both raises SIZE and
111                // drops AUTO SCALING STRATEGY, running the SET first would
112                // validate the new size against the still-live policy, and the
113                // server rejects a hydration size equal to the cluster size with
114                // `HYDRATION SIZE must differ from the cluster SIZE`. Clearing
115                // the policy first lets the size change land.
116                //
117                // SET and RESET cannot be combined in one statement.
118                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    // Reconcile grants
140    grants::reconcile_named_object(
141        client,
142        executor,
143        cluster_name,
144        &def.grants,
145        &grants::GrantNamedObjectKind::Cluster,
146    )
147    .await?;
148
149    // Execute COMMENT statements
150    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
164/// The live cluster's configuration, as the canonical `CREATE CLUSTER` statement
165/// the server renders from the catalog. `None` when the cluster does not exist.
166///
167/// Errors on an unmanaged cluster, which has no `SHOW CREATE CLUSTER` form.
168async 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
195/// The options `SHOW CREATE CLUSTER` renders for every managed cluster, paired
196/// with the value the server assigns when the definition omits them.
197///
198/// A live option equal to its default is indistinguishable from an unset one, so
199/// a definition that omits it has not drifted. Options the server omits from
200/// `SHOW CREATE CLUSTER` when unset need no entry here.
201///
202/// The interval mirrors `mz_controller_types::DEFAULT_REPLICA_LOGGING_INTERVAL`.
203fn 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
221/// An option's value reduced to a form the two sides of the diff can be compared
222/// by.
223///
224/// Each reduction mirrors a normalization the server performs before it records
225/// the option, so a definition that spells a value differently from `SHOW CREATE
226/// CLUSTER` still compares equal. An option written without a value carries its
227/// implied value, which for every cluster option that permits the spelling is
228/// `true`. Intervals are canonicalized by [`CanonicalIntervals`]. A zero
229/// introspection interval turns introspection off, which the server records as
230/// the absence of an interval and renders as `NULL`.
231///
232/// Comparison only. An option that needs setting is emitted as the definition
233/// wrote it.
234fn 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
246/// Rewrites every interval-valued literal in an option to its canonical form,
247/// reaching nested values such as `LINGER DURATION` inside `AUTO SCALING
248/// STRATEGY`.
249///
250/// The server re-renders durations in canonical interval form, so a definition's
251/// `LINGER DURATION = '60s'` comes back from `SHOW CREATE CLUSTER` as
252/// `'00:01:00'`. Number literals are rewritten too, because the server reads
253/// them as intervals through the same parse.
254struct 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
269/// Whether a value is a zero-length interval, as [`CanonicalIntervals`] renders
270/// one.
271fn 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
278/// Compare a cluster definition against the live cluster and return the options
279/// to `SET` and the option names to `RESET` so the caller can converge live state
280/// onto the definition.
281///
282/// An option the definition declares is `SET` when its value differs from the
283/// live one. An option the definition omits is `RESET` unless the live value is
284/// already the server default, which `defaults` supplies. The comparison is a
285/// difference over option names, so a cluster option Materialize adds later
286/// reconciles without a change here.
287fn 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
313/// Index a statement's options by name, dropping the ones the server discards.
314///
315/// A duplicate name is rejected by the server, so the last one wins here.
316fn 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
327/// Whether the server discards an option rather than recording it, leaving it
328/// out of `SHOW CREATE CLUSTER`.
329///
330/// An empty block, as in `AUTO SCALING STRATEGY = ()`, is how a definition
331/// spells "unset". `DISK` is a deprecated no-op the server accepts and drops.
332/// Either one would read as drift on every apply if the diff kept it, because
333/// setting it can never bring it back.
334fn is_discarded(option: &ClusterOption<Raw>) -> bool {
335    option.name == ClusterOptionName::Disk || option.to_ast_string_simple().ends_with("= ()")
336}
337
338/// Render cluster options (or option names) as a comma-separated `ALTER CLUSTER`
339/// argument list.
340fn 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    /// The canonical statement `SHOW CREATE CLUSTER` renders: every default
353    /// spelled out, whatever the definition said.
354    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    /// A live cluster whose rendered options are exactly `options`, for cases
369    /// that need one of the always-rendered options to hold a non-default value.
370    fn live_exactly(options: &str) -> CreateClusterStatement<Raw> {
371        parse_create_cluster(&format!("CREATE CLUSTER \"scaled\" ({options})")).unwrap()
372    }
373
374    /// Render a diff as `(SET statement parts, RESET names)` for concise asserts.
375    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        // The definition names only SIZE, so every other option the server
393        // renders holds its default, REPLICATION FACTOR = 1 among them.
394        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        // The live value is not the default, so omitting it resets.
430        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        // Nothing in the diff mentions either option. They reconcile because the
471        // comparison is over option names.
472        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        // The definition writes '60s'; the server renders '00:01:00'.
488        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        // Canonicalizing durations must not touch a value that is not one.
503        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        // An empty block writes "no policy", which the server omits from SHOW
515        // CREATE entirely.
516        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        // A value-less option means `true`, which is what the server renders.
528        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        // A zero introspection interval disables introspection, which the
546        // server renders as NULL.
547        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        // WORKLOAD CLASS renders NULL when unset too, but a class named '0' is
571        // a value like any other.
572        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        // DISK is a no-op the server drops, so it never comes back from SHOW
591        // CREATE CLUSTER and setting it could never converge.
592        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        // Where the server default is 2, a live factor of 2 is the unset value.
604        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        // `default_options` spells the interval out as SQL. Guard the constant
613        // it mirrors.
614        assert_eq!(
615            mz_controller_types::DEFAULT_REPLICA_LOGGING_INTERVAL,
616            std::time::Duration::from_secs(1)
617        );
618    }
619}