Skip to main content

mz_adapter/coord/sequencer/inner/
cluster.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
10use std::collections::BTreeSet;
11use std::time::Duration;
12
13use itertools::Itertools;
14use mz_adapter_types::cluster_state::ReconfigurationAudit;
15use mz_catalog::builtin::BUILTINS;
16use mz_catalog::durable::managed_cluster_replica_name;
17use mz_catalog::memory::error::ErrorKind;
18use mz_catalog::memory::objects::{
19    Cluster, ClusterConfig, ClusterVariant, ClusterVariantManaged, DataSourceDesc,
20    ManagedReplicaConfigShape, ReconfigurationState, ReconfigurationStatus, ReconfigurationTarget,
21};
22use mz_compute_types::config::ComputeReplicaConfig;
23use mz_controller::clusters::{
24    ManagedReplicaLocation, ReplicaConfig, ReplicaLocation, ReplicaLogging,
25};
26use mz_controller_types::{ClusterId, DEFAULT_REPLICA_LOGGING_INTERVAL, ReplicaId};
27use mz_ore::cast::CastFrom;
28use mz_ore::collections::CollectionExt;
29use mz_ore::instrument;
30use mz_repr::Timestamp;
31use mz_repr::adt::numeric::Numeric;
32use mz_repr::role_id::RoleId;
33use mz_sql::catalog::{CatalogCluster, CatalogError, ObjectType};
34use mz_sql::names::FullItemName;
35use mz_sql::plan::{
36    self, AlterClusterPlanStrategy, AlterOptionParameter, AlterSetClusterPlan,
37    CreateClusterManagedPlan, CreateClusterPlan, CreateClusterReplicaPlan,
38    CreateClusterUnmanagedPlan, CreateClusterVariant, PlanClusterOption,
39};
40use mz_sql::plan::{AlterClusterPlan, OnTimeoutAction};
41use mz_sql::session::metadata::SessionMetadata;
42use mz_sql::session::vars::{
43    MAX_CREDIT_CONSUMPTION_RATE, MAX_REPLICAS_PER_CLUSTER, SystemVars, Var,
44};
45use mz_storage_types::sources::SourceConnection;
46use tracing::{Instrument, Span};
47
48use mz_adapter_types::dyncfgs::{
49    DEFAULT_CLUSTER_RECONFIGURATION_TIMEOUT, ENABLE_BACKGROUND_ALTER_CLUSTER,
50};
51
52use super::return_if_err;
53use crate::catalog::{self, Op, ReplicaCreateDropReason};
54use crate::coord::{
55    AlterCluster, AlterClusterAwaitReconfiguration, ClusterStage, Coordinator, Message,
56    PlanValidity, StageResult, Staged,
57};
58use crate::{AdapterError, AdapterNotice, ExecuteContext, ExecuteResponse, session::Session};
59
60impl Staged for ClusterStage {
61    type Ctx = ExecuteContext;
62
63    fn validity(&mut self) -> &mut PlanValidity {
64        match self {
65            Self::Alter(stage) => &mut stage.validity,
66            Self::AwaitReconfiguration(stage) => &mut stage.validity,
67        }
68    }
69
70    async fn stage(
71        self,
72        coord: &mut Coordinator,
73        ctx: &mut ExecuteContext,
74    ) -> Result<StageResult<Box<Self>>, crate::AdapterError> {
75        match self {
76            Self::Alter(stage) => {
77                coord
78                    .sequence_alter_cluster_stage(ctx.session(), stage.plan.clone(), stage.validity)
79                    .await
80            }
81            Self::AwaitReconfiguration(stage) => {
82                coord.await_reconfiguration_stage(stage.validity, stage.cluster_id, stage.target)
83            }
84        }
85    }
86
87    fn message(self, ctx: ExecuteContext, span: tracing::Span) -> Message {
88        Message::ClusterStageReady {
89            ctx,
90            span,
91            stage: self,
92        }
93    }
94
95    fn cancel_enabled(&self) -> bool {
96        true
97    }
98}
99
100impl Coordinator {
101    #[instrument]
102    pub(crate) async fn sequence_alter_cluster_staged(
103        &mut self,
104        ctx: ExecuteContext,
105        plan: plan::AlterClusterPlan,
106    ) {
107        let stage = return_if_err!(self.alter_cluster_validate(ctx.session(), plan).await, ctx);
108        self.sequence_staged(ctx, Span::current(), stage).await;
109    }
110
111    #[instrument]
112    async fn alter_cluster_validate(
113        &self,
114        session: &Session,
115        plan: plan::AlterClusterPlan,
116    ) -> Result<ClusterStage, AdapterError> {
117        let validity = PlanValidity::new(
118            self.catalog(),
119            BTreeSet::new(),
120            Some(plan.id.clone()),
121            None,
122            session.role_metadata().clone(),
123        );
124        Ok(ClusterStage::Alter(AlterCluster { validity, plan }))
125    }
126
127    async fn sequence_alter_cluster_stage(
128        &mut self,
129        session: &Session,
130        plan: plan::AlterClusterPlan,
131        validity: PlanValidity,
132    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
133        let AlterClusterPlan {
134            id: cluster_id,
135            name: _,
136            ref options,
137            ref strategy,
138        } = plan;
139
140        use mz_catalog::memory::objects::ClusterVariant::*;
141        use mz_sql::plan::AlterOptionParameter::*;
142        let cluster = self.catalog.get_cluster(cluster_id);
143        let config = cluster.config.clone();
144        let mut new_config = config.clone();
145
146        match (&new_config.variant, &options.managed) {
147            (Managed(_), Reset) | (Managed(_), Unchanged) | (Managed(_), Set(true)) => {}
148            (Managed(_), Set(false)) => new_config.variant = Unmanaged,
149            (Unmanaged, Unchanged) | (Unmanaged, Set(false)) => {}
150            (Unmanaged, Reset) | (Unmanaged, Set(true)) => {
151                // Generate a minimal correct configuration
152
153                // Size adjusted later when sequencing the actual configuration change.
154                let size = "".to_string();
155                let logging = ReplicaLogging {
156                    log_logging: false,
157                    interval: Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
158                };
159                new_config.variant = Managed(ClusterVariantManaged {
160                    size,
161                    availability_zones: Default::default(),
162                    logging,
163                    arrangement_compression: false,
164                    replication_factor: 1,
165                    optimizer_feature_overrides: Default::default(),
166                    schedule: Default::default(),
167                    auto_scaling_strategy: None,
168                    reconfiguration: None,
169                    burst: None,
170                });
171            }
172        }
173
174        match &mut new_config.variant {
175            Managed(ClusterVariantManaged {
176                size,
177                availability_zones,
178                logging,
179                arrangement_compression,
180                replication_factor,
181                optimizer_feature_overrides: _,
182                schedule,
183                auto_scaling_strategy,
184                reconfiguration: _,
185                burst: _,
186            }) => {
187                match &options.size {
188                    Set(s) => size.clone_from(s),
189                    Reset => coord_bail!("SIZE has no default value"),
190                    Unchanged => {}
191                }
192                match &options.availability_zones {
193                    Set(az) => availability_zones.clone_from(az),
194                    Reset => *availability_zones = Default::default(),
195                    Unchanged => {}
196                }
197                match &options.introspection_debugging {
198                    Set(id) => logging.log_logging = *id,
199                    Reset => logging.log_logging = false,
200                    Unchanged => {}
201                }
202                match &options.introspection_interval {
203                    Set(ii) => logging.interval = ii.0,
204                    Reset => logging.interval = Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
205                    Unchanged => {}
206                }
207                match &options.arrangement_compression {
208                    Set(ac) => *arrangement_compression = *ac,
209                    Reset => *arrangement_compression = false,
210                    Unchanged => {}
211                }
212                match &options.replication_factor {
213                    Set(rf) => *replication_factor = *rf,
214                    Reset => {
215                        *replication_factor = self
216                            .catalog
217                            .system_config()
218                            .default_cluster_replication_factor()
219                    }
220                    Unchanged => {}
221                }
222                match &options.schedule {
223                    Set(new_schedule) => {
224                        *schedule = new_schedule.clone();
225                    }
226                    Reset => *schedule = Default::default(),
227                    Unchanged => {}
228                }
229                match &options.auto_scaling_strategy {
230                    Set(new_strategy) => auto_scaling_strategy.clone_from(new_strategy),
231                    // The default is autoscaling disabled.
232                    Reset => *auto_scaling_strategy = None,
233                    Unchanged => {}
234                }
235                if !matches!(options.replicas, Unchanged) {
236                    coord_bail!("Cannot change REPLICAS of managed clusters");
237                }
238            }
239            Unmanaged => {
240                if !matches!(options.size, Unchanged) {
241                    coord_bail!("Cannot change SIZE of unmanaged clusters");
242                }
243                if !matches!(options.availability_zones, Unchanged) {
244                    coord_bail!("Cannot change AVAILABILITY ZONES of unmanaged clusters");
245                }
246                if !matches!(options.introspection_debugging, Unchanged) {
247                    coord_bail!("Cannot change INTROSPECTION DEGUBBING of unmanaged clusters");
248                }
249                if !matches!(options.introspection_interval, Unchanged) {
250                    coord_bail!("Cannot change INTROSPECTION INTERVAL of unmanaged clusters");
251                }
252                if !matches!(options.arrangement_compression, Unchanged) {
253                    coord_bail!(
254                        "Cannot change EXPERIMENTAL ARRANGEMENT COMPRESSION of unmanaged clusters"
255                    );
256                }
257                if !matches!(options.replication_factor, Unchanged) {
258                    coord_bail!("Cannot change REPLICATION FACTOR of unmanaged clusters");
259                }
260                if !matches!(options.auto_scaling_strategy, Unchanged) {
261                    coord_bail!("Cannot change AUTO SCALING STRATEGY of unmanaged clusters");
262                }
263            }
264        }
265
266        match &options.workload_class {
267            Set(wc) => new_config.workload_class.clone_from(wc),
268            Reset => new_config.workload_class = None,
269            Unchanged => {}
270        }
271
272        // Validate a HYDRATION SIZE that this ALTER introduces before
273        // dispatching, so that every ALTER path checks it. An unchanged
274        // HYDRATION SIZE is not re-validated.
275        if let Managed(new_managed) = &new_config.variant {
276            if let Some(on_hydration) = new_managed
277                .auto_scaling_strategy
278                .as_ref()
279                .and_then(|strategy| strategy.on_hydration.as_ref())
280            {
281                let old_hydration_size = match &config.variant {
282                    Managed(managed) => managed
283                        .auto_scaling_strategy
284                        .as_ref()
285                        .and_then(|strategy| strategy.on_hydration.as_ref())
286                        .map(|policy| &policy.hydration_size),
287                    Unmanaged => None,
288                };
289                if old_hydration_size != Some(&on_hydration.hydration_size) {
290                    let role_id = Some(session.role_metadata().current_role);
291                    self.catalog.ensure_valid_replica_size(
292                        &self.catalog().get_role_allowed_cluster_sizes(&role_id),
293                        &on_hydration.hydration_size,
294                        false,
295                    )?;
296                }
297            }
298        }
299
300        let reconfiguration_in_flight = matches!(
301            &config.variant,
302            Managed(managed) if managed
303                .reconfiguration
304                .as_ref()
305                .is_some_and(|record| record.is_in_progress())
306        );
307
308        // The schedule decides which strategy owns the cluster's replica set
309        // (the baseline for MANUAL, on-refresh otherwise), and the sequencer
310        // never writes a reconfiguration record for a scheduled cluster (see
311        // the routing below). Refuse flipping the schedule under an in-flight
312        // record rather than let the two ownership regimes overlap mid-flight.
313        if reconfiguration_in_flight && !matches!(options.schedule, Unchanged) {
314            return Err(AdapterError::AlterClusterScheduleWhileReconfiguring);
315        }
316
317        // A no-op `ALTER` short-circuits, except that an `ALTER` back to the
318        // realized configuration while a reconfiguration is in flight produces a
319        // byte-identical `new_config` and is still meaningful: it must reach
320        // the reshape path below to cancel the record.
321        let cancels_or_retargets =
322            reconfiguration_in_flight && alter_changes_reconfiguration_target(options);
323        if new_config == config && !cancels_or_retargets {
324            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
325                ObjectType::Cluster,
326            )));
327        }
328
329        // An `ALTER` that raises a managed cluster's replication factor above
330        // one deserves a notice when the cluster contains sources that run on
331        // only one replica, since the additional replicas do not benefit those
332        // sources. Computed here, emitted only after the alter succeeds. The
333        // unmanaged conversion paths never change the replica count, so only
334        // the managed-to-managed transition is of interest.
335        let single_replica_sources_notice = match (&config.variant, &new_config.variant) {
336            (Managed(old_managed), Managed(new_managed))
337                if new_managed.replication_factor > old_managed.replication_factor
338                    && new_managed.replication_factor > 1 =>
339            {
340                let sources = self.single_replica_source_names(cluster);
341                (!sources.is_empty()).then(|| {
342                    AdapterNotice::SingleReplicaSourcesOnMultiReplicaCluster {
343                        cluster: cluster.name.clone(),
344                        sources,
345                    }
346                })
347            }
348            _ => None,
349        };
350
351        // A shape-changing `ALTER` on a MANUAL cluster reshapes into a durable
352        // `reconfiguration` record that the controller converges on. A scheduled
353        // cluster without an in-flight record takes the direct path below because
354        // it has no baseline replica set to overlap. Everything else falls through
355        // to the realized-config update without touching the record.
356        //
357        // With a record in flight the statement decides: any target option,
358        // including replication factor, must reach the reshape path. Restoring
359        // the realized configuration cancels even when value-identical. With
360        // nothing in flight the values decide: a shape option set
361        // to its current value reconfigures nothing, and reshaping it anyway
362        // would write a spurious pre-cancelled record.
363        if let (Managed(old_managed), Managed(new_managed)) = (&config.variant, &new_config.variant)
364        {
365            let needs_record = if reconfiguration_in_flight {
366                alter_changes_reconfiguration_target(options)
367            } else {
368                new_managed.replica_config_shape() != old_managed.replica_config_shape()
369            };
370            // A scheduled (non-MANUAL) cluster holds its replication factor
371            // at 0 and the on-refresh strategy owns its replica set, so a
372            // graceful hydrate-overlap has nothing meaningful to wait for.
373            // A config-shape `ALTER` on such a cluster takes the direct
374            // path below instead of writing a record: that path only updates
375            // the realized config, and the controller reconciles any in-window
376            // replica to the new shape on its next tick. The schedule guard
377            // above keeps a schedule change from reaching here mid-record, so a
378            // record on a scheduled cluster can only pre-date the schedule
379            // (written on an older version). For that case the reshape
380            // path stays reachable, so the record can still be retargeted
381            // or cancelled until it settles.
382            let scheduled_direct =
383                !matches!(new_managed.schedule, mz_sql::plan::ClusterSchedule::Manual)
384                    && !reconfiguration_in_flight;
385            // A `WAIT` option would be silently vacuous on the direct
386            // path: there may be no replica at all (window closed), and
387            // an in-window replica is bounced to the new shape without a
388            // hydrate-overlap to wait on. Reject it rather than return an
389            // instant success that waited for nothing.
390            if scheduled_direct && !matches!(strategy, AlterClusterPlanStrategy::None) {
391                return Err(AdapterError::AlterClusterWaitOnScheduledCluster);
392            }
393            if needs_record && !scheduled_direct {
394                let result = self
395                    .reshape_alter_cluster_managed(
396                        session,
397                        cluster_id,
398                        new_config.clone(),
399                        options,
400                        strategy,
401                        validity,
402                    )
403                    .await;
404                if result.is_ok() {
405                    if let Some(notice) = single_replica_sources_notice {
406                        session.add_notice(notice);
407                    }
408                }
409                return result;
410            }
411        }
412
413        match (&config.variant, &new_config.variant) {
414            (Managed(_), Managed(_)) => {
415                // RF-only changes without an in-flight reconfiguration apply
416                // directly. Any WAIT clause has no effect on this path.
417                self.sequence_alter_cluster_managed_to_managed(
418                    session,
419                    cluster_id,
420                    new_config.clone(),
421                )
422                .await?;
423                if let Some(notice) = single_replica_sources_notice {
424                    session.add_notice(notice);
425                }
426            }
427            (Unmanaged, Managed(new_managed)) => {
428                // The conversion path creates no overlap replicas to wait on,
429                // and a scheduled target makes the `WAIT` permanently
430                // meaningless, mirroring the managed-to-managed rejection
431                // above.
432                if !matches!(new_managed.schedule, mz_sql::plan::ClusterSchedule::Manual)
433                    && !matches!(strategy, AlterClusterPlanStrategy::None)
434                {
435                    return Err(AdapterError::AlterClusterWaitOnScheduledCluster);
436                }
437                self.sequence_alter_cluster_unmanaged_to_managed(
438                    session,
439                    cluster_id,
440                    new_config,
441                    options.to_owned(),
442                )
443                .await?;
444            }
445            (Managed(_), Unmanaged) => {
446                self.sequence_alter_cluster_managed_to_unmanaged(session, cluster_id, new_config)
447                    .await?;
448            }
449            (Unmanaged, Unmanaged) => {
450                self.sequence_alter_cluster_unmanaged_to_unmanaged(
451                    session,
452                    cluster_id,
453                    new_config,
454                    options.replicas.clone(),
455                )
456                .await?;
457            }
458        }
459
460        Ok(StageResult::Response(ExecuteResponse::AlteredObject(
461            ObjectType::Cluster,
462        )))
463    }
464
465    /// Reshape a managed→managed `ALTER` into a durable `reconfiguration` record.
466    ///
467    /// Writes (or folds into) the `reconfiguration` record carrying the full target
468    /// config shape and a deadline, while leaving the realized *shape* in place.
469    /// Non-shape fields the `ALTER` changed (`workload_class`, `schedule`,
470    /// `auto_scaling_strategy`, ...) need no hydrate-overlap, so they are applied
471    /// to the realized config immediately. The controller converges the replica
472    /// set onto the target and cuts the realized shape over at hydration.
473    ///
474    /// **Fold semantics.** When a record is already in flight, the target is an
475    /// overlay on the *in-flight target*, not the realized config: a dimension the
476    /// `ALTER` set (`options.*` is `Set`/`Reset`) takes the new value, a dimension
477    /// left `Unchanged` keeps the in-flight target's value. `new_config` was built
478    /// against the realized config, which still holds the pre-reconfiguration shape
479    /// (the realized config is advanced only at cut-over), so seeding `Unchanged`
480    /// dimensions from it would silently revert the in-flight transition along any
481    /// dimension this `ALTER` did not mention. With no record in flight there is
482    /// nothing to fold and the target is exactly `new_config`'s shape.
483    ///
484    /// **Timeout action.** The record carries an `on_timeout` action resolved
485    /// from `WITH (WAIT ...)`, defaulting to `ROLLBACK`. At the deadline,
486    /// `ROLLBACK` marks the record timed out and drops the in-flight target set.
487    /// The realized config stays unchanged and the strategy disengages. For
488    /// `COMMIT`, the baseline yields while the complete target materializes in a
489    /// replacement transaction. A later reconciliation observes that target,
490    /// advances the realized config, and finalizes without requiring hydration.
491    /// Success always takes precedence. A target that hydrates before the
492    /// deadline cuts over regardless of the action.
493    ///
494    /// With `enable_background_alter_cluster` on, the statement returns
495    /// immediately. With it off, the session blocks on a wait-shim
496    /// ([`ClusterStage::AwaitReconfiguration`]) that polls until the controller
497    /// resolves the record, reporting success only if the realized config
498    /// reached the target, preserving today's foreground UX over the same
499    /// durable mechanism.
500    async fn reshape_alter_cluster_managed(
501        &mut self,
502        session: &Session,
503        cluster_id: ClusterId,
504        new_config: ClusterConfig,
505        options: &PlanClusterOption,
506        strategy: &AlterClusterPlanStrategy,
507        validity: PlanValidity,
508    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
509        use mz_sql::plan::AlterOptionParameter::Unchanged;
510
511        let ClusterVariant::Managed(new_managed) = &new_config.variant else {
512            return Err(AdapterError::Internal(
513                "reshape_alter_cluster_managed requires a managed target config".into(),
514            ));
515        };
516
517        // Fold onto the in-flight target when one exists: `new_config` carries the
518        // realized value for any dimension the `ALTER` left `Unchanged`, but the
519        // realized config is the pre-reconfiguration shape, so we instead carry the
520        // in-flight target's value for those dimensions. Only dimensions the `ALTER`
521        // explicitly set diverge from the in-flight target.
522        let cluster = self.catalog.get_cluster(cluster_id);
523        let in_flight = match &cluster.config.variant {
524            ClusterVariant::Managed(managed) => managed
525                .reconfiguration
526                .as_ref()
527                .filter(|record| record.is_in_progress())
528                .cloned(),
529            ClusterVariant::Unmanaged => None,
530        };
531        let new_target = ReconfigurationTarget {
532            size: new_managed.size.clone(),
533            replication_factor: new_managed.replication_factor,
534            availability_zones: new_managed.availability_zones.clone(),
535            logging: new_managed.logging.clone(),
536            arrangement_compression: new_managed.arrangement_compression,
537        };
538        let unchanged = ReconfigurationDimensionsUnchanged {
539            size: matches!(options.size, Unchanged),
540            replication_factor: matches!(options.replication_factor, Unchanged),
541            availability_zones: matches!(options.availability_zones, Unchanged),
542            // The two logging options fold independently, so a debugging-only
543            // `ALTER` cannot revert an in-flight interval change (or vice versa).
544            log_logging: matches!(options.introspection_debugging, Unchanged),
545            interval: matches!(options.introspection_interval, Unchanged),
546            arrangement_compression: matches!(options.arrangement_compression, Unchanged),
547        };
548        let target = fold_reconfiguration_target(
549            in_flight.as_ref().map(|r| &r.target),
550            new_target,
551            unchanged,
552        );
553
554        // Validate the target up front, so a bad reshape errors at `ALTER` time
555        // rather than silently parking an unconvergeable record.
556        // A size disabled after the cluster started using it stays valid for it.
557        let size_in_use = matches!(
558            &cluster.config.variant,
559            ClusterVariant::Managed(managed) if managed.size == target.size
560        ) || in_flight
561            .as_ref()
562            .is_some_and(|record| record.target.size == target.size);
563        let role_id = session.role_metadata().current_role;
564        self.catalog.ensure_valid_replica_size(
565            &self
566                .catalog()
567                .get_role_allowed_cluster_sizes(&Some(role_id)),
568            &target.size,
569            size_in_use,
570        )?;
571        self.ensure_valid_azs(target.availability_zones.iter())?;
572
573        let cancels = match &cluster.config.variant {
574            ClusterVariant::Managed(managed) => target.matches_realized_config(managed),
575            ClusterVariant::Unmanaged => false,
576        };
577        // Bound the target's steady baseline before the controller expands it
578        // into desired replica slots. This deliberately ignores overlap and
579        // other strategies. Concrete create transactions validate the complete
580        // strategy union. Cancellation remains available if the limit has
581        // fallen below the realized configuration's requirements.
582        if cluster_id.is_user() && !cancels {
583            self.validate_resource_limit(
584                0,
585                i64::from(target.replication_factor),
586                SystemVars::max_replicas_per_cluster,
587                "cluster replica",
588                MAX_REPLICAS_PER_CLUSTER.name(),
589            )?;
590
591            let credits_per_replica = self
592                .catalog()
593                .cluster_replica_sizes()
594                .0
595                .get(&target.size)
596                .expect("target replica size was validated")
597                .credits_per_hour;
598            let baseline_credits = credits_per_replica * Numeric::from(target.replication_factor);
599            self.validate_resource_limit_numeric(
600                self.current_credit_consumption_rate(Some(cluster_id)),
601                baseline_credits,
602                |system_vars| {
603                    self.license_key
604                        .max_credit_consumption_rate()
605                        .map_or_else(|| system_vars.max_credit_consumption_rate(), Numeric::from)
606                },
607                "cluster replica",
608                MAX_CREDIT_CONSUMPTION_RATE.name(),
609            )?;
610        }
611
612        // Resolve the deadline and the on-timeout action, both written relative
613        // to the current time so they survive session disconnect and restart.
614        // The target folds per-dimension onto the in-flight one. The deadline
615        // and `on_timeout`, in contrast, are the contract carried by a `WAIT`
616        // clause, so how a folding `ALTER` treats them depends on whether it
617        // carries one:
618        //   - no `WAIT`, reconfiguration in flight -> keep the in-flight
619        //                          record's deadline and `on_timeout`. The
620        //                          statement carries no contract of its own, so
621        //                          an unrelated config-shape `ALTER` must not
622        //                          silently reset the deadline and action the
623        //                          user set on the reconfiguration in progress.
624        //   - no `WAIT`, nothing in flight -> the system-default timeout and the
625        //                          implicit `on_timeout` default (`ROLLBACK`).
626        //   - `WAIT FOR`        -> sugar for `ON TIMEOUT ROLLBACK`.
627        //   - `WAIT UNTIL READY -> the explicit `TIMEOUT` / `ON TIMEOUT`, with
628        //                          `ON TIMEOUT` defaulting to `ROLLBACK` when
629        //                          omitted.
630        // An explicit `WAIT` clause is folded onto an in-flight record wholesale,
631        // which lets a later `ALTER` steer the deadline and timeout action of a
632        // reconfiguration in progress without discarding the hydration progress
633        // its target may already have. `ROLLBACK` (the default) reverts an
634        // un-hydrated reconfiguration to its pre-reconfiguration shape rather
635        // than cutting over to a not-yet-hydrated target, which could induce
636        // downtime.
637        let now = self.now();
638        let deadline_from = |timeout: Duration| -> Timestamp {
639            now.saturating_add(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
640                .into()
641        };
642        let (deadline, on_timeout) = match strategy {
643            AlterClusterPlanStrategy::None => match &in_flight {
644                Some(record) => (record.deadline, record.on_timeout),
645                None => (
646                    deadline_from(
647                        DEFAULT_CLUSTER_RECONFIGURATION_TIMEOUT
648                            .get(self.catalog().system_config().dyncfgs()),
649                    ),
650                    OnTimeoutAction::Rollback,
651                ),
652            },
653            AlterClusterPlanStrategy::For(timeout) => {
654                (deadline_from(*timeout), OnTimeoutAction::Rollback)
655            }
656            AlterClusterPlanStrategy::UntilReady {
657                timeout,
658                on_timeout,
659            } => (
660                deadline_from(*timeout),
661                on_timeout.unwrap_or(OnTimeoutAction::Rollback),
662            ),
663        };
664
665        // Build the durable write from `new_config`, which carries every field the
666        // `ALTER` changed, then reset the config *shape* (every
667        // `ReconfigurationTarget` dimension) back to the realized values: that
668        // transition is deferred to the `reconfiguration` record and applied at
669        // cut-over. This applies non-shape changes (`workload_class`, `schedule`,
670        // `auto_scaling_strategy`, ...) immediately, matching the legacy path,
671        // rather than silently dropping them. Any existing record is folded over by
672        // the `record` we just built.
673        let cluster = self.catalog.get_cluster(cluster_id);
674        let cluster_name = cluster.name().to_string();
675        let ClusterVariant::Managed(realized_now) = &cluster.config.variant else {
676            return Err(AdapterError::Internal(
677                "reshape_alter_cluster_managed requires a managed realized config".into(),
678            ));
679        };
680        let realized_target = realized_now.realized_reconfiguration_target();
681        // The status and the audit intent are two views of the same decision,
682        // made together here: an ALTER back to the realized shape is a cancel,
683        // anything else starts (or re-targets) a reconfiguration.
684        let (status, audit) = if cancels {
685            (
686                ReconfigurationStatus::Cancelled,
687                ReconfigurationAudit::Cancelled,
688            )
689        } else {
690            (
691                ReconfigurationStatus::InProgress,
692                ReconfigurationAudit::Started,
693            )
694        };
695        let record = ReconfigurationState {
696            target: target.clone(),
697            deadline,
698            on_timeout,
699            status,
700        };
701
702        let mut realized = new_config.clone();
703        let ClusterVariant::Managed(realized_managed) = &mut realized.variant else {
704            return Err(AdapterError::Internal(
705                "reshape_alter_cluster_managed requires a managed target config".into(),
706            ));
707        };
708        realized_managed.apply_reconfiguration_target(realized_target);
709        realized_managed.reconfiguration = Some(record);
710
711        self.catalog_transact(
712            Some(session),
713            vec![Op::UpdateClusterConfig {
714                id: cluster_id,
715                name: cluster_name,
716                config: realized,
717                reconfiguration_audit: Some(audit),
718                burst_audit: None,
719            }],
720        )
721        .await?;
722
723        let background =
724            ENABLE_BACKGROUND_ALTER_CLUSTER.get(self.catalog().system_config().dyncfgs());
725        if background {
726            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
727                ObjectType::Cluster,
728            )));
729        }
730
731        // Foreground wait-shim: poll the durable record until it resolves. The
732        // reconfiguration continues in the background regardless of the session. A
733        // disconnect during the wait only stops waiting.
734        Ok(StageResult::Immediate(Box::new(
735            ClusterStage::AwaitReconfiguration(AlterClusterAwaitReconfiguration {
736                validity,
737                cluster_id,
738                target,
739            }),
740        )))
741    }
742
743    /// Polls the durable `reconfiguration` record for the foreground wait-shim.
744    ///
745    /// The controller owns deadline handling. This stage reports success only once
746    /// the realized config reaches `target`, and otherwise keeps polling while
747    /// the record is in progress.
748    fn await_reconfiguration_stage(
749        &self,
750        validity: PlanValidity,
751        cluster_id: ClusterId,
752        target: ReconfigurationTarget,
753    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
754        let Some(cluster) = self.catalog().try_get_cluster(cluster_id) else {
755            // The cluster was dropped out from under the reconfiguration.
756            // There is nothing to wait on.
757            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
758                ObjectType::Cluster,
759            )));
760        };
761        let record = match &cluster.config.variant {
762            ClusterVariant::Managed(managed) => managed.reconfiguration.clone(),
763            ClusterVariant::Unmanaged => None,
764        };
765
766        let realized_matches_target = match &cluster.config.variant {
767            ClusterVariant::Managed(managed) => target.matches_realized_config(managed),
768            ClusterVariant::Unmanaged => false,
769        };
770
771        match reconfiguration_wait_result(record.as_ref(), &target, realized_matches_target) {
772            Some(result) => {
773                result?;
774                Ok(StageResult::Response(ExecuteResponse::AlteredObject(
775                    ObjectType::Cluster,
776                )))
777            }
778            None => {
779                // Still in progress. Re-poll after the configured interval and
780                // wait for the controller to resolve the record. We deliberately
781                // do not consult the deadline here: erroring while the record is
782                // in progress can race the controller and misreport an `ON
783                // TIMEOUT COMMIT` cut-over as a timeout.
784                //
785                // NOTE: If the controller stops resolving a record while it is
786                // in progress, the shim waits indefinitely. Cancelling the session
787                // only stops waiting. It does not abort the durable reconfiguration.
788                let poll_duration = self
789                    .catalog
790                    .system_config()
791                    .cluster_alter_check_ready_interval();
792                let span = Span::current();
793                Ok(StageResult::Handle(mz_ore::task::spawn(
794                    || "Await Cluster Reconfiguration",
795                    async move {
796                        tokio::time::sleep(poll_duration).await;
797                        Ok(Box::new(ClusterStage::AwaitReconfiguration(
798                            AlterClusterAwaitReconfiguration {
799                                validity,
800                                cluster_id,
801                                target,
802                            },
803                        )))
804                    }
805                    .instrument(span),
806                )))
807            }
808        }
809    }
810
811    pub(crate) async fn sequence_create_cluster(
812        &mut self,
813        session: &Session,
814        CreateClusterPlan {
815            name,
816            variant,
817            workload_class,
818            if_not_exists,
819        }: CreateClusterPlan,
820    ) -> Result<ExecuteResponse, AdapterError> {
821        tracing::debug!("sequence_create_cluster");
822
823        let id_ts = self.get_catalog_write_ts().await;
824        let id = self.catalog().allocate_user_cluster_id(id_ts).await?;
825        // The catalog items for the introspection sources are shared between all replicas
826        // of a compute instance, so we create them unconditionally during instance creation.
827        // Whether a replica actually maintains introspection arrangements is determined by the
828        // per-replica introspection configuration.
829        let introspection_sources = BUILTINS::logs().collect();
830        let cluster_variant = match &variant {
831            CreateClusterVariant::Managed(plan) => {
832                let logging = if let Some(config) = plan.compute.introspection {
833                    ReplicaLogging {
834                        log_logging: config.debugging,
835                        interval: Some(config.interval),
836                    }
837                } else {
838                    ReplicaLogging::default()
839                };
840                ClusterVariant::Managed(ClusterVariantManaged {
841                    size: plan.size.clone(),
842                    availability_zones: plan.availability_zones.clone(),
843                    logging,
844                    arrangement_compression: plan.compute.arrangement_compression,
845                    replication_factor: plan.replication_factor,
846                    optimizer_feature_overrides: plan.optimizer_feature_overrides.clone(),
847                    schedule: plan.schedule.clone(),
848                    auto_scaling_strategy: plan.auto_scaling_strategy.clone(),
849                    reconfiguration: None,
850                    burst: None,
851                })
852            }
853            CreateClusterVariant::Unmanaged(_) => ClusterVariant::Unmanaged,
854        };
855        let config = ClusterConfig {
856            variant: cluster_variant,
857            workload_class,
858        };
859        let ops = vec![catalog::Op::CreateCluster {
860            id,
861            name: name.clone(),
862            introspection_sources,
863            owner_id: *session.current_role_id(),
864            config,
865        }];
866
867        match variant {
868            CreateClusterVariant::Managed(plan) => {
869                self.sequence_create_managed_cluster(session, plan, id, ops)
870                    .await
871            }
872            CreateClusterVariant::Unmanaged(plan) => {
873                self.sequence_create_unmanaged_cluster(session, plan, id, ops)
874                    .await
875            }
876        }
877        .or_else(|err| match err {
878            AdapterError::Catalog(mz_catalog::memory::error::Error {
879                kind: ErrorKind::Sql(CatalogError::ClusterAlreadyExists(_)),
880            }) if if_not_exists => {
881                session.add_notice(AdapterNotice::ObjectAlreadyExists {
882                    name,
883                    ty: "cluster",
884                });
885                Ok(ExecuteResponse::CreatedCluster)
886            }
887            err => Err(err),
888        })
889    }
890
891    #[mz_ore::instrument(level = "debug")]
892    async fn sequence_create_managed_cluster(
893        &mut self,
894        session: &Session,
895        CreateClusterManagedPlan {
896            availability_zones,
897            compute,
898            replication_factor,
899            size,
900            optimizer_feature_overrides: _,
901            schedule: _,
902            auto_scaling_strategy,
903        }: CreateClusterManagedPlan,
904        cluster_id: ClusterId,
905        mut ops: Vec<catalog::Op>,
906    ) -> Result<ExecuteResponse, AdapterError> {
907        tracing::debug!("sequence_create_managed_cluster");
908
909        self.ensure_valid_azs(availability_zones.iter())?;
910
911        let role_id = session.role_metadata().current_role;
912        self.catalog.ensure_valid_replica_size(
913            &self
914                .catalog()
915                .get_role_allowed_cluster_sizes(&Some(role_id)),
916            &size,
917            false,
918        )?;
919        // A HYDRATION SIZE is validated like SIZE itself: it must name a real
920        // replica size the session role may use. Without this, a typo would
921        // fail invisibly at burst-arm time (the controller retrying every
922        // tick), and a size-restricted role could burst at a size it may not
923        // CREATE with.
924        if let Some(on_hydration) = auto_scaling_strategy
925            .as_ref()
926            .and_then(|strategy| strategy.on_hydration.as_ref())
927        {
928            self.catalog.ensure_valid_replica_size(
929                &self
930                    .catalog()
931                    .get_role_allowed_cluster_sizes(&Some(role_id)),
932                &on_hydration.hydration_size,
933                false,
934            )?;
935        }
936
937        // Eagerly validate the `max_replicas_per_cluster` limit.
938        // `catalog_transact` will do this validation too, but allocating
939        // replica IDs is expensive enough that we need to do this validation
940        // before allocating replica IDs. See database-issues#6046.
941        if cluster_id.is_user() {
942            self.validate_resource_limit(
943                0,
944                i64::from(replication_factor),
945                SystemVars::max_replicas_per_cluster,
946                "cluster replica",
947                MAX_REPLICAS_PER_CLUSTER.name(),
948            )?;
949        }
950
951        // Pre-allocate replica ids out-of-band via the durable allocator,
952        // picking the id type from the owning cluster. This mirrors how cluster
953        // and item ids are allocated, so nothing allocates a replica id in-apply.
954        let id_ts = self.get_catalog_write_ts().await;
955        let replica_ids = self
956            .catalog()
957            .allocate_replica_ids(cluster_id, u64::from(replication_factor), id_ts)
958            .await?;
959
960        for (replica_id, replica_name) in replica_ids
961            .into_iter()
962            .zip_eq((0..replication_factor).map(managed_cluster_replica_name))
963        {
964            self.create_managed_cluster_replica_op(
965                cluster_id,
966                replica_id,
967                replica_name.clone(),
968                &compute,
969                &size,
970                &mut ops,
971                if availability_zones.is_empty() {
972                    None
973                } else {
974                    Some(availability_zones.as_ref())
975                },
976                false,
977                *session.current_role_id(),
978                ReplicaCreateDropReason::Manual,
979            )?;
980        }
981
982        self.catalog_transact(Some(session), ops).await?;
983
984        Ok(ExecuteResponse::CreatedCluster)
985    }
986
987    fn create_managed_cluster_replica_op(
988        &self,
989        cluster_id: ClusterId,
990        replica_id: ReplicaId,
991        name: String,
992        compute: &mz_sql::plan::ComputeReplicaConfig,
993        size: &String,
994        ops: &mut Vec<Op>,
995        azs: Option<&[String]>,
996        pending: bool,
997        owner_id: RoleId,
998        reason: ReplicaCreateDropReason,
999    ) -> Result<(), AdapterError> {
1000        let location = mz_catalog::durable::ReplicaLocation::Managed {
1001            // Concretized below from the cluster config; this intermediate value
1002            // is discarded, so the list is left empty here.
1003            availability_zones: Vec::new(),
1004            billed_as: None,
1005            internal: false,
1006            size: size.clone(),
1007            pending,
1008        };
1009
1010        let logging = if let Some(config) = compute.introspection {
1011            ReplicaLogging {
1012                log_logging: config.debugging,
1013                interval: Some(config.interval),
1014            }
1015        } else {
1016            ReplicaLogging::default()
1017        };
1018
1019        let config = ReplicaConfig {
1020            location: self.catalog().concretize_replica_location(
1021                location,
1022                &self
1023                    .catalog()
1024                    .get_role_allowed_cluster_sizes(&Some(owner_id)),
1025                azs,
1026                false,
1027            )?,
1028            compute: ComputeReplicaConfig {
1029                logging,
1030                arrangement_compression: compute.arrangement_compression,
1031            },
1032        };
1033
1034        // The caller pre-allocates `replica_id` out-of-band via the durable
1035        // allocator, so nothing allocates a replica id in-apply.
1036        ops.push(catalog::Op::CreateClusterReplica {
1037            cluster_id,
1038            replica_id,
1039            name,
1040            config,
1041            owner_id,
1042            reason,
1043        });
1044        Ok(())
1045    }
1046
1047    fn ensure_valid_azs<'a, I: IntoIterator<Item = &'a String>>(
1048        &self,
1049        azs: I,
1050    ) -> Result<(), AdapterError> {
1051        let cat_azs = self.catalog().state().availability_zones();
1052        for az in azs.into_iter() {
1053            if !cat_azs.contains(az) {
1054                return Err(AdapterError::InvalidClusterReplicaAz {
1055                    az: az.to_string(),
1056                    expected: cat_azs.to_vec(),
1057                });
1058            }
1059        }
1060        Ok(())
1061    }
1062
1063    #[mz_ore::instrument(level = "debug")]
1064    async fn sequence_create_unmanaged_cluster(
1065        &mut self,
1066        session: &Session,
1067        CreateClusterUnmanagedPlan { replicas }: CreateClusterUnmanagedPlan,
1068        id: ClusterId,
1069        mut ops: Vec<catalog::Op>,
1070    ) -> Result<ExecuteResponse, AdapterError> {
1071        tracing::debug!("sequence_create_unmanaged_cluster");
1072
1073        self.ensure_valid_azs(replicas.iter().filter_map(|(_, r)| {
1074            if let mz_sql::plan::ReplicaConfig::Orchestrated {
1075                availability_zone: Some(az),
1076                ..
1077            } = &r
1078            {
1079                Some(az)
1080            } else {
1081                None
1082            }
1083        }))?;
1084
1085        // Eagerly validate the `max_replicas_per_cluster` limit.
1086        // `catalog_transact` will do this validation too, but allocating
1087        // replica IDs is expensive enough that we need to do this validation
1088        // before allocating replica IDs. See database-issues#6046.
1089        if id.is_user() {
1090            self.validate_resource_limit(
1091                0,
1092                i64::try_from(replicas.len()).unwrap_or(i64::MAX),
1093                SystemVars::max_replicas_per_cluster,
1094                "cluster replica",
1095                MAX_REPLICAS_PER_CLUSTER.name(),
1096            )?;
1097        }
1098
1099        // Pre-allocate replica ids out-of-band via the durable allocator,
1100        // picking the id type from the owning cluster. This mirrors how cluster
1101        // and item ids are allocated, so nothing allocates a replica id in-apply.
1102        let id_ts = self.get_catalog_write_ts().await;
1103        let replica_ids = self
1104            .catalog()
1105            .allocate_replica_ids(id, u64::cast_from(replicas.len()), id_ts)
1106            .await?;
1107
1108        for (replica_id, (replica_name, replica_config)) in replica_ids.into_iter().zip_eq(replicas)
1109        {
1110            // If the AZ was not specified, choose one, round-robin, from the ones with
1111            // the lowest number of configured replicas for this cluster.
1112            let (compute, location) = match replica_config {
1113                mz_sql::plan::ReplicaConfig::Unorchestrated {
1114                    storagectl_addrs,
1115                    computectl_addrs,
1116                    compute,
1117                } => {
1118                    let location = mz_catalog::durable::ReplicaLocation::Unmanaged {
1119                        storagectl_addrs,
1120                        computectl_addrs,
1121                    };
1122                    (compute, location)
1123                }
1124                mz_sql::plan::ReplicaConfig::Orchestrated {
1125                    availability_zone,
1126                    billed_as,
1127                    compute,
1128                    internal,
1129                    size,
1130                } => {
1131                    // Only internal users have access to INTERNAL and BILLED AS
1132                    if !session.user().is_internal() && (internal || billed_as.is_some()) {
1133                        coord_bail!("cannot specify INTERNAL or BILLED AS as non-internal user")
1134                    }
1135                    // BILLED AS implies the INTERNAL flag.
1136                    if billed_as.is_some() && !internal {
1137                        coord_bail!("must specify INTERNAL when specifying BILLED AS");
1138                    }
1139                    // Concretizing the location validates `SIZE` only, see
1140                    // `ensure_valid_billed_as_size`.
1141                    if let Some(billed_as) = &billed_as {
1142                        self.ensure_valid_billed_as_size(billed_as)?;
1143                    }
1144
1145                    let location = mz_catalog::durable::ReplicaLocation::Managed {
1146                        // The user-pinned `AVAILABILITY ZONE`, if any, as a zero-
1147                        // or one-element list.
1148                        availability_zones: availability_zone.into_iter().collect(),
1149                        billed_as,
1150                        internal,
1151                        size: size.clone(),
1152                        pending: false,
1153                    };
1154                    (compute, location)
1155                }
1156            };
1157
1158            let logging = if let Some(config) = compute.introspection {
1159                ReplicaLogging {
1160                    log_logging: config.debugging,
1161                    interval: Some(config.interval),
1162                }
1163            } else {
1164                ReplicaLogging::default()
1165            };
1166
1167            let role_id = session.role_metadata().current_role;
1168            let config = ReplicaConfig {
1169                location: self.catalog().concretize_replica_location(
1170                    location,
1171                    &self
1172                        .catalog()
1173                        .get_role_allowed_cluster_sizes(&Some(role_id)),
1174                    None,
1175                    false,
1176                )?,
1177                compute: ComputeReplicaConfig {
1178                    logging,
1179                    arrangement_compression: compute.arrangement_compression,
1180                },
1181            };
1182
1183            ops.push(catalog::Op::CreateClusterReplica {
1184                cluster_id: id,
1185                replica_id,
1186                name: replica_name.clone(),
1187                config,
1188                owner_id: *session.current_role_id(),
1189                reason: ReplicaCreateDropReason::Manual,
1190            });
1191        }
1192
1193        self.catalog_transact(Some(session), ops).await?;
1194
1195        Ok(ExecuteResponse::CreatedCluster)
1196    }
1197
1198    /// Returns the full names of all sources bound to `cluster` whose
1199    /// connections prefer to run on a single replica, so additional replicas
1200    /// do not make them more fault tolerant or increase their throughput.
1201    fn single_replica_source_names(&self, cluster: &Cluster) -> Vec<String> {
1202        cluster
1203            .bound_objects
1204            .iter()
1205            .filter_map(|id| {
1206                let entry = self.catalog().get_entry(id);
1207                let single_replica =
1208                    entry
1209                        .source()
1210                        .is_some_and(|source| match &source.data_source {
1211                            DataSourceDesc::Ingestion { desc, .. }
1212                            | DataSourceDesc::OldSyntaxIngestion { desc, .. } => {
1213                                desc.connection.prefers_single_replica()
1214                            }
1215                            _ => false,
1216                        });
1217                single_replica.then(|| {
1218                    self.catalog()
1219                        .resolve_full_name(entry.name(), None)
1220                        .to_string()
1221                })
1222            })
1223            .collect()
1224    }
1225
1226    /// The number of replicas `cluster` aims to run, for deciding whether to
1227    /// emit the single-replica-sources notice.
1228    ///
1229    /// For a managed cluster this is the replication factor, taking the target
1230    /// of an in-progress reconfiguration over the realized one, plus any
1231    /// INTERNAL or BILLED AS replicas, which are manually managed outside the
1232    /// replication-factor domain. Replicas belonging to a reconfiguration's
1233    /// hydrate-overlap are deliberately not counted: they replace the serving
1234    /// set at cut-over rather than adding to it. Counting the replication
1235    /// factor instead of replicas excludes the ordinary replicas the cluster
1236    /// controller creates for the target shape.
1237    fn notice_relevant_replica_count(&self, cluster: &Cluster) -> usize {
1238        match &cluster.config.variant {
1239            ClusterVariant::Managed(managed) => {
1240                let replication_factor = managed
1241                    .reconfiguration
1242                    .as_ref()
1243                    .filter(|record| record.is_in_progress())
1244                    .map_or(managed.replication_factor, |record| {
1245                        record.target.replication_factor
1246                    });
1247                let manual_replicas = cluster
1248                    .replicas()
1249                    .filter(|r| {
1250                        r.config.location.internal() || r.config.location.billed_as().is_some()
1251                    })
1252                    .count();
1253                usize::cast_from(replication_factor) + manual_replicas
1254            }
1255            ClusterVariant::Unmanaged => cluster.replicas().count(),
1256        }
1257    }
1258
1259    /// Emits a notice if `cluster` aims to run more than one replica while
1260    /// containing sources that run on only one replica. Call after a command
1261    /// that added a replica or such a source.
1262    ///
1263    /// `creating_source` names a source the current command is creating in
1264    /// `cluster`. It is included in the notice even when it is not yet visible
1265    /// in the catalog, which happens when the creation is staged in a DDL
1266    /// transaction that commits later.
1267    pub(crate) fn notify_single_replica_sources(
1268        &self,
1269        session: &Session,
1270        cluster: &Cluster,
1271        creating_source: Option<&FullItemName>,
1272    ) {
1273        if self.notice_relevant_replica_count(cluster) <= 1 {
1274            return;
1275        }
1276        let mut sources = self.single_replica_source_names(cluster);
1277        if let Some(name) = creating_source {
1278            let full_name = name.to_string();
1279            if !sources.contains(&full_name) {
1280                sources.push(full_name);
1281            }
1282        }
1283        if !sources.is_empty() {
1284            session.add_notice(AdapterNotice::SingleReplicaSourcesOnMultiReplicaCluster {
1285                cluster: cluster.name.clone(),
1286                sources,
1287            });
1288        }
1289    }
1290
1291    /// Rejects a `BILLED AS` size that is not in the replica size map.
1292    ///
1293    /// Billing only reads the size's credit rate, so unlike `SIZE` the value
1294    /// may be a disabled size and need not be in the role's allowed sizes.
1295    /// The check lives here rather than in `concretize_replica_location`,
1296    /// which catalog open also runs for every durable replica.
1297    fn ensure_valid_billed_as_size(&self, size: &str) -> Result<(), AdapterError> {
1298        if self.catalog().cluster_replica_sizes().0.contains_key(size) {
1299            Ok(())
1300        } else {
1301            coord_bail!("unknown cluster replica size {size} in BILLED AS")
1302        }
1303    }
1304
1305    #[mz_ore::instrument(level = "debug")]
1306    pub(crate) async fn sequence_create_cluster_replica(
1307        &mut self,
1308        session: &Session,
1309        CreateClusterReplicaPlan {
1310            name,
1311            cluster_id,
1312            config,
1313            if_not_exists,
1314        }: CreateClusterReplicaPlan,
1315    ) -> Result<ExecuteResponse, AdapterError> {
1316        // Choose default AZ if necessary
1317        let (compute, location) = match config {
1318            mz_sql::plan::ReplicaConfig::Unorchestrated {
1319                storagectl_addrs,
1320                computectl_addrs,
1321                compute,
1322            } => {
1323                let location = mz_catalog::durable::ReplicaLocation::Unmanaged {
1324                    storagectl_addrs,
1325                    computectl_addrs,
1326                };
1327                (compute, location)
1328            }
1329            mz_sql::plan::ReplicaConfig::Orchestrated {
1330                availability_zone,
1331                billed_as,
1332                compute,
1333                internal,
1334                size,
1335            } => {
1336                let availability_zone = match availability_zone {
1337                    Some(az) => {
1338                        self.ensure_valid_azs([&az])?;
1339                        Some(az)
1340                    }
1341                    None => None,
1342                };
1343                let location = mz_catalog::durable::ReplicaLocation::Managed {
1344                    // The user-pinned `AVAILABILITY ZONE`, if any, as a zero- or
1345                    // one-element list.
1346                    availability_zones: availability_zone.into_iter().collect(),
1347                    billed_as,
1348                    internal,
1349                    size,
1350                    pending: false,
1351                };
1352                (compute, location)
1353            }
1354        };
1355
1356        let logging = if let Some(config) = compute.introspection {
1357            ReplicaLogging {
1358                log_logging: config.debugging,
1359                interval: Some(config.interval),
1360            }
1361        } else {
1362            ReplicaLogging::default()
1363        };
1364
1365        let role_id = session.role_metadata().current_role;
1366        let config = ReplicaConfig {
1367            location: self.catalog().concretize_replica_location(
1368                location,
1369                &self
1370                    .catalog()
1371                    .get_role_allowed_cluster_sizes(&Some(role_id)),
1372                // Planning ensures all replicas in this codepath
1373                // are unmanaged.
1374                None,
1375                false,
1376            )?,
1377            compute: ComputeReplicaConfig {
1378                logging,
1379                arrangement_compression: compute.arrangement_compression,
1380            },
1381        };
1382
1383        let cluster = self.catalog().get_cluster(cluster_id);
1384
1385        if let ReplicaLocation::Managed(ManagedReplicaLocation {
1386            internal,
1387            billed_as,
1388            ..
1389        }) = &config.location
1390        {
1391            // Only internal users have access to INTERNAL and BILLED AS
1392            if !session.user().is_internal() && (*internal || billed_as.is_some()) {
1393                coord_bail!("cannot specify INTERNAL or BILLED AS as non-internal user")
1394            }
1395            // Managed clusters require the INTERNAL flag.
1396            if cluster.is_managed() && !*internal {
1397                coord_bail!("must specify INTERNAL when creating a replica in a managed cluster");
1398            }
1399            // BILLED AS implies the INTERNAL flag.
1400            if billed_as.is_some() && !*internal {
1401                coord_bail!("must specify INTERNAL when specifying BILLED AS");
1402            }
1403            // Concretizing the location validated `SIZE` only, see
1404            // `ensure_valid_billed_as_size`.
1405            if let Some(billed_as) = billed_as {
1406                self.ensure_valid_billed_as_size(billed_as)?;
1407            }
1408        }
1409
1410        // Replicas have the same owner as their cluster. Extract the owned
1411        // cluster info we need before the borrow is dropped for the awaits below.
1412        let owner_id = cluster.owner_id();
1413
1414        let cluster_name = cluster.name.clone();
1415        // A replica name is only unique within its cluster, so the notice on the
1416        // `IF NOT EXISTS` path below has to name both.
1417        let qualified_name = format!("{cluster_name}.{name}");
1418
1419        // Pre-allocate the replica id out-of-band via the durable allocator,
1420        // picking the id type from the target cluster, which may be a system
1421        // cluster. This mirrors how cluster and item ids are allocated, so
1422        // nothing allocates a replica id in-apply.
1423        let id_ts = self.get_catalog_write_ts().await;
1424        let replica_id = self
1425            .catalog()
1426            .allocate_replica_ids(cluster_id, 1, id_ts)
1427            .await?
1428            .into_element();
1429
1430        let ops = vec![catalog::Op::CreateClusterReplica {
1431            cluster_id,
1432            replica_id,
1433            name: name.clone(),
1434            config,
1435            owner_id,
1436            reason: ReplicaCreateDropReason::Manual,
1437        }];
1438
1439        match self.catalog_transact(Some(session), ops).await {
1440            Ok(()) => {
1441                // The commit made the new replica visible in the catalog, so
1442                // the check sees the updated replica count.
1443                self.notify_single_replica_sources(
1444                    session,
1445                    self.catalog().get_cluster(cluster_id),
1446                    None,
1447                );
1448                Ok(ExecuteResponse::CreatedClusterReplica)
1449            }
1450            Err(AdapterError::Catalog(mz_catalog::memory::error::Error {
1451                kind: ErrorKind::Sql(CatalogError::DuplicateReplica(_, _)),
1452            })) if if_not_exists => {
1453                session.add_notice(AdapterNotice::ObjectAlreadyExists {
1454                    name: qualified_name,
1455                    ty: "cluster replica",
1456                });
1457                Ok(ExecuteResponse::CreatedClusterReplica)
1458            }
1459            Err(err) => Err(err),
1460        }
1461    }
1462
1463    /// Applies a managed→managed `ALTER CLUSTER`.
1464    ///
1465    /// This is a config-only write: the cluster controller owns the replica set
1466    /// and reconciles it to the new realized config on its next tick. Emitting
1467    /// creates and drops here as well would fight it, since it derives replica
1468    /// names from the observed set, so an adapter create by canonical `rN` can
1469    /// collide with a controller-chosen name and an adapter drop by canonical
1470    /// `rN` can miss a churned one.
1471    ///
1472    /// # Panics
1473    ///
1474    /// Panics if the identified cluster is not a managed cluster.
1475    /// Panics if `new_config` is not a configuration for a managed cluster.
1476    pub(crate) async fn sequence_alter_cluster_managed_to_managed(
1477        &mut self,
1478        session: &Session,
1479        cluster_id: ClusterId,
1480        new_config: ClusterConfig,
1481    ) -> Result<(), AdapterError> {
1482        let cluster = self.catalog.get_cluster(cluster_id);
1483        let name = cluster.name().to_string();
1484
1485        let ClusterVariant::Managed(ClusterVariantManaged {
1486            size,
1487            availability_zones,
1488            logging,
1489            arrangement_compression,
1490            replication_factor,
1491            optimizer_feature_overrides: _,
1492            schedule: _,
1493            auto_scaling_strategy,
1494            reconfiguration,
1495            burst: _,
1496        }) = &cluster.config.variant
1497        else {
1498            panic!("expected existing managed cluster config");
1499        };
1500        let ClusterVariant::Managed(new_managed) = &new_config.variant else {
1501            panic!("expected new managed cluster config");
1502        };
1503        let ClusterVariantManaged {
1504            size: new_size,
1505            replication_factor: new_replication_factor,
1506            availability_zones: new_availability_zones,
1507            logging: _,
1508            arrangement_compression: _,
1509            optimizer_feature_overrides: _,
1510            schedule: _,
1511            auto_scaling_strategy: new_auto_scaling_strategy,
1512            reconfiguration: _,
1513            burst: _,
1514        } = new_managed;
1515
1516        let role_id = Some(session.role_metadata().current_role);
1517        // A size disabled after the cluster started using it stays valid for it.
1518        self.catalog.ensure_valid_replica_size(
1519            &self.catalog().get_role_allowed_cluster_sizes(&role_id),
1520            new_size,
1521            new_size == size,
1522        )?;
1523        if new_auto_scaling_strategy != auto_scaling_strategy {
1524            if let Some(on_hydration) = new_auto_scaling_strategy
1525                .as_ref()
1526                .and_then(|strategy| strategy.on_hydration.as_ref())
1527            {
1528                // The planner validated the hydration size against the
1529                // *realized* SIZE only. An in-flight reconfiguration will cut
1530                // the realized SIZE over to its target, so also reject equality
1531                // with that target. Letting it through would end the reshape
1532                // with a no-op burst shape and a stored statement that fails
1533                // its own re-plan.
1534                if reconfiguration.as_ref().is_some_and(|record| {
1535                    record.is_in_progress() && record.target.size == on_hydration.hydration_size
1536                }) {
1537                    coord_bail!(
1538                        "HYDRATION SIZE must differ from the target SIZE \
1539                         ('{}') of the in-progress cluster resize",
1540                        on_hydration.hydration_size
1541                    );
1542                }
1543            }
1544        }
1545
1546        // The committed baseline is an exact count, not a prediction of the
1547        // controller's transient strategy union, and no strategy can shed it.
1548        // Validate it here: `Op::UpdateClusterConfig` contributes nothing to
1549        // `catalog_transact`'s replica accounting, because the controller
1550        // materializes the replicas on a later tick rather than this transaction
1551        // emitting creates. Without the check the ALTER would succeed and the
1552        // controller would then fail its own create transaction on every tick.
1553        // See database-issues#6046.
1554        if new_replication_factor > replication_factor && cluster_id.is_user() {
1555            self.validate_resource_limit(
1556                usize::cast_from(*replication_factor),
1557                i64::from(*new_replication_factor) - i64::from(*replication_factor),
1558                SystemVars::max_replicas_per_cluster,
1559                "cluster replica",
1560                MAX_REPLICAS_PER_CLUSTER.name(),
1561            )?;
1562
1563            let credits_per_replica = self
1564                .catalog()
1565                .cluster_replica_sizes()
1566                .0
1567                .get(new_size)
1568                .expect("new replica size was validated")
1569                .credits_per_hour;
1570            let baseline_credits = credits_per_replica * Numeric::from(*new_replication_factor);
1571            self.validate_resource_limit_numeric(
1572                self.current_credit_consumption_rate(Some(cluster_id)),
1573                baseline_credits,
1574                |system_vars| {
1575                    self.license_key
1576                        .max_credit_consumption_rate()
1577                        .map_or_else(|| system_vars.max_credit_consumption_rate(), Numeric::from)
1578                },
1579                "cluster replica",
1580                MAX_CREDIT_CONSUMPTION_RATE.name(),
1581            )?;
1582        }
1583
1584        let config_changed = new_managed.replica_config_shape()
1585            != ManagedReplicaConfigShape::new(
1586                size,
1587                availability_zones,
1588                logging,
1589                *arrangement_compression,
1590            );
1591        // The controller creates replicas from the realized config without
1592        // re-validating availability zones, so an invalid pool written here
1593        // would produce an unplaceable replica.
1594        if config_changed {
1595            self.ensure_valid_azs(new_availability_zones.iter())?;
1596        }
1597
1598        // A record still in progress belongs to a live reconfiguration this
1599        // cluster-level write did not touch, so carry it through unchanged.
1600        let ops = vec![catalog::Op::UpdateClusterConfig {
1601            id: cluster_id,
1602            name,
1603            config: new_config,
1604            reconfiguration_audit: None,
1605            burst_audit: None,
1606        }];
1607
1608        self.catalog_transact(Some(session), ops).await?;
1609        Ok(())
1610    }
1611
1612    /// # Panics
1613    ///
1614    /// Panics if `new_config` is not a configuration for a managed cluster.
1615    async fn sequence_alter_cluster_unmanaged_to_managed(
1616        &mut self,
1617        session: &Session,
1618        cluster_id: ClusterId,
1619        mut new_config: ClusterConfig,
1620        options: PlanClusterOption,
1621    ) -> Result<(), AdapterError> {
1622        let cluster = self.catalog.get_cluster(cluster_id);
1623        let cluster_name = cluster.name().to_string();
1624
1625        let ClusterVariant::Managed(ClusterVariantManaged {
1626            size: new_size,
1627            replication_factor: new_replication_factor,
1628            availability_zones: new_availability_zones,
1629            logging: _,
1630            arrangement_compression: _,
1631            optimizer_feature_overrides: _,
1632            schedule: _,
1633            auto_scaling_strategy: _,
1634            reconfiguration: _,
1635            burst: _,
1636        }) = &mut new_config.variant
1637        else {
1638            panic!("expected new managed cluster config");
1639        };
1640
1641        // Validate replication factor parameter
1642        let user_replica_count = cluster
1643            .user_replicas()
1644            .count()
1645            .try_into()
1646            .expect("must_fit");
1647        match options.replication_factor {
1648            AlterOptionParameter::Set(_) => {
1649                // Validate that the replication factor matches the current length only if specified.
1650                if user_replica_count != *new_replication_factor {
1651                    coord_bail!(
1652                        "REPLICATION FACTOR {new_replication_factor} does not match number of replicas ({user_replica_count})"
1653                    );
1654                }
1655            }
1656            _ => {
1657                *new_replication_factor = user_replica_count;
1658            }
1659        }
1660
1661        let mut names = BTreeSet::new();
1662        let mut sizes = BTreeSet::new();
1663
1664        self.ensure_valid_azs(new_availability_zones.iter())?;
1665
1666        // Validate per-replica configuration
1667        for replica in cluster.user_replicas() {
1668            names.insert(replica.name.clone());
1669            match &replica.config.location {
1670                ReplicaLocation::Unmanaged(_) => coord_bail!(
1671                    "Cannot convert unmanaged cluster with unmanaged replicas to managed cluster"
1672                ),
1673                ReplicaLocation::Managed(location) => {
1674                    sizes.insert(location.size.clone());
1675
1676                    // An unmanaged cluster's replica carries its single
1677                    // user-pinned AZ (if any) as the sole entry; every pin must
1678                    // fall within the managed cluster's `AVAILABILITY ZONES`.
1679                    for az in &location.availability_zones {
1680                        if !new_availability_zones.contains(az) {
1681                            coord_bail!(
1682                                "unmanaged replica has availability zone {az} which is not \
1683                                in managed {new_availability_zones:?}"
1684                            )
1685                        }
1686                    }
1687                }
1688            }
1689        }
1690
1691        if sizes.is_empty() {
1692            assert!(
1693                cluster.user_replicas().next().is_none(),
1694                "Cluster should not have replicas"
1695            );
1696            // We didn't collect any size, so the user has to name it.
1697            match &options.size {
1698                AlterOptionParameter::Reset | AlterOptionParameter::Unchanged => {
1699                    coord_bail!("Missing SIZE for empty cluster")
1700                }
1701                AlterOptionParameter::Set(size) => {
1702                    let role_id = Some(session.role_metadata().current_role);
1703                    self.catalog.ensure_valid_replica_size(
1704                        &self.catalog().get_role_allowed_cluster_sizes(&role_id),
1705                        size,
1706                        false,
1707                    )?;
1708                }
1709            }
1710        } else if sizes.len() == 1 {
1711            let size = sizes.into_iter().next().expect("must exist");
1712            match &options.size {
1713                AlterOptionParameter::Set(sz) if *sz != size => {
1714                    coord_bail!("Cluster replicas of size {size} do not match expected SIZE {sz}");
1715                }
1716                _ => *new_size = size,
1717            }
1718        } else {
1719            let formatted = sizes
1720                .iter()
1721                .map(String::as_str)
1722                .collect::<Vec<_>>()
1723                .join(", ");
1724            coord_bail!(
1725                "Cannot convert unmanaged cluster to managed, non-unique replica sizes: {formatted}"
1726            );
1727        }
1728
1729        for i in 0..*new_replication_factor {
1730            let name = managed_cluster_replica_name(i);
1731            names.remove(&name);
1732        }
1733        if !names.is_empty() {
1734            let formatted = names
1735                .iter()
1736                .map(String::as_str)
1737                .collect::<Vec<_>>()
1738                .join(", ");
1739            coord_bail!(
1740                "Cannot convert unmanaged cluster to managed, invalid replica names: {formatted}"
1741            );
1742        }
1743
1744        let ops = vec![catalog::Op::UpdateClusterConfig {
1745            id: cluster_id,
1746            name: cluster_name,
1747            config: new_config,
1748            reconfiguration_audit: None,
1749            burst_audit: None,
1750        }];
1751
1752        self.catalog_transact(Some(session), ops).await?;
1753        Ok(())
1754    }
1755
1756    async fn sequence_alter_cluster_managed_to_unmanaged(
1757        &mut self,
1758        session: &Session,
1759        cluster_id: ClusterId,
1760        new_config: ClusterConfig,
1761    ) -> Result<(), AdapterError> {
1762        let cluster = self.catalog().get_cluster(cluster_id);
1763
1764        // The unmanaged variant has no reconfiguration field, so converting
1765        // would silently drop an in-progress record with no terminal status
1766        // and no audit event, and strand any overlap replicas the controller
1767        // already created. Refuse instead: the user can cancel (ALTER back to
1768        // the realized size) or wait for the record to settle first.
1769        if let ClusterVariant::Managed(managed) = &cluster.config.variant {
1770            if managed
1771                .reconfiguration
1772                .as_ref()
1773                .is_some_and(|record| record.is_in_progress())
1774            {
1775                return Err(AdapterError::AlterClusterUnmanagedWhileReconfiguring);
1776            }
1777            // Same hazard for an in-flight burst: the unmanaged variant has no
1778            // burst field either, so converting would drop the record with no
1779            // `Finished` audit event and strand the billed burst replica as an
1780            // ordinary unmanaged replica nothing ever tears down. Absence of a
1781            // record means the burst has settled, so no in-progress check is
1782            // needed.
1783            if managed.burst.is_some() {
1784                return Err(AdapterError::AlterClusterUnmanagedWhileBursting);
1785            }
1786        }
1787
1788        let ops = vec![catalog::Op::UpdateClusterConfig {
1789            id: cluster_id,
1790            name: cluster.name().to_string(),
1791            config: new_config,
1792            reconfiguration_audit: None,
1793            burst_audit: None,
1794        }];
1795
1796        self.catalog_transact(Some(session), ops).await?;
1797        Ok(())
1798    }
1799
1800    async fn sequence_alter_cluster_unmanaged_to_unmanaged(
1801        &mut self,
1802        session: &Session,
1803        cluster_id: ClusterId,
1804        new_config: ClusterConfig,
1805        replicas: AlterOptionParameter<Vec<(String, mz_sql::plan::ReplicaConfig)>>,
1806    ) -> Result<(), AdapterError> {
1807        if !matches!(replicas, AlterOptionParameter::Unchanged) {
1808            coord_bail!("Cannot alter replicas in unmanaged cluster");
1809        }
1810
1811        let cluster = self.catalog().get_cluster(cluster_id);
1812
1813        let ops = vec![catalog::Op::UpdateClusterConfig {
1814            id: cluster_id,
1815            name: cluster.name().to_string(),
1816            config: new_config,
1817            reconfiguration_audit: None,
1818            burst_audit: None,
1819        }];
1820
1821        self.catalog_transact(Some(session), ops).await?;
1822        Ok(())
1823    }
1824
1825    /// Convert a [`AlterSetClusterPlan`] to a sequence of catalog operators and adjust state.
1826    pub(crate) async fn sequence_alter_set_cluster(
1827        &self,
1828        _session: &Session,
1829        AlterSetClusterPlan { id, set_cluster: _ }: AlterSetClusterPlan,
1830    ) -> Result<ExecuteResponse, AdapterError> {
1831        // TODO: This function needs to be implemented.
1832
1833        // Satisfy Clippy that this is an async func.
1834        async {}.await;
1835        let entry = self.catalog().get_entry(&id);
1836        match entry.item().typ() {
1837            _ => {
1838                // Unexpected; planner permitted unsupported plan.
1839                Err(AdapterError::Unsupported("ALTER SET CLUSTER"))
1840            }
1841        }
1842    }
1843}
1844
1845/// Which reconfiguration-target dimensions an `ALTER` left unset (`Unchanged`).
1846/// Drives [`fold_reconfiguration_target`]. Logging is two sub-dimensions
1847/// because `INTROSPECTION DEBUGGING` and `INTROSPECTION INTERVAL` are
1848/// independently alterable.
1849struct ReconfigurationDimensionsUnchanged {
1850    size: bool,
1851    replication_factor: bool,
1852    availability_zones: bool,
1853    log_logging: bool,
1854    interval: bool,
1855    arrangement_compression: bool,
1856}
1857
1858/// Returns the foreground result, or `None` while the awaited target is pending.
1859///
1860/// Records are overwritten by later ALTERs, so success is defined by realized
1861/// state rather than record identity. A waiter follows a record only while that
1862/// record still carries its target.
1863fn reconfiguration_wait_result(
1864    record: Option<&ReconfigurationState>,
1865    awaited_target: &ReconfigurationTarget,
1866    realized_matches_target: bool,
1867) -> Option<Result<(), AdapterError>> {
1868    if realized_matches_target {
1869        return Some(Ok(()));
1870    }
1871    let Some(record) = record.filter(|record| record.target == *awaited_target) else {
1872        return Some(Err(AdapterError::AlterClusterSuperseded));
1873    };
1874    match record.status {
1875        ReconfigurationStatus::InProgress => None,
1876        ReconfigurationStatus::ResourceExhausted => {
1877            Some(Err(AdapterError::AlterClusterResourceExhausted))
1878        }
1879        ReconfigurationStatus::TimedOut => Some(Err(AdapterError::AlterClusterTimeout)),
1880        ReconfigurationStatus::Finalized | ReconfigurationStatus::Cancelled => {
1881            Some(Err(AdapterError::AlterClusterSuperseded))
1882        }
1883    }
1884}
1885
1886/// Whether an `ALTER` statement sets a reconfiguration target dimension.
1887///
1888/// A statement-level check, used while a reconfiguration is in flight: an
1889/// `ALTER` back to the realized configuration sets an option without changing its
1890/// value, yet must reach the reshape path to cancel the record. All target
1891/// dimensions, including replication factor, must update the in-flight target
1892/// rather than the realized config so cut-over does not overwrite them. With
1893/// nothing in flight the routing compares shapes instead (see
1894/// `sequence_alter_cluster_stage`).
1895fn alter_changes_reconfiguration_target(options: &PlanClusterOption) -> bool {
1896    use mz_sql::plan::AlterOptionParameter::Unchanged;
1897    let PlanClusterOption {
1898        availability_zones,
1899        introspection_debugging,
1900        introspection_interval,
1901        arrangement_compression,
1902        managed: _,
1903        replicas: _,
1904        replication_factor,
1905        size,
1906        schedule: _,
1907        workload_class: _,
1908        auto_scaling_strategy: _,
1909    } = options;
1910    !matches!(size, Unchanged)
1911        || !matches!(replication_factor, Unchanged)
1912        || !matches!(availability_zones, Unchanged)
1913        || !matches!(introspection_debugging, Unchanged)
1914        || !matches!(introspection_interval, Unchanged)
1915        || !matches!(arrangement_compression, Unchanged)
1916}
1917
1918/// Fold a new `ALTER` onto an in-flight reconfiguration target.
1919///
1920/// `new_target` was built against the *realized* config, so any dimension the
1921/// `ALTER` left `Unchanged` carries the realized (pre-reconfiguration) value. When
1922/// a reconfiguration is in flight (`in_flight` is `Some`), the realized config is
1923/// the pre-reconfiguration shape, so for each `Unchanged` dimension we instead
1924/// keep the in-flight target's value. Only dimensions the `ALTER` explicitly set
1925/// re-target. With nothing in flight (`in_flight` is `None`) the target is exactly
1926/// `new_target`. This is what keeps an `ALTER` that touches one dimension (e.g.
1927/// AZ-only) from silently reverting the in-flight transition along every dimension
1928/// it did not mention.
1929fn fold_reconfiguration_target(
1930    in_flight: Option<&ReconfigurationTarget>,
1931    new_target: ReconfigurationTarget,
1932    unchanged: ReconfigurationDimensionsUnchanged,
1933) -> ReconfigurationTarget {
1934    let Some(prev) = in_flight else {
1935        return new_target;
1936    };
1937    ReconfigurationTarget {
1938        size: if unchanged.size {
1939            prev.size.clone()
1940        } else {
1941            new_target.size
1942        },
1943        replication_factor: if unchanged.replication_factor {
1944            prev.replication_factor
1945        } else {
1946            new_target.replication_factor
1947        },
1948        availability_zones: if unchanged.availability_zones {
1949            prev.availability_zones.clone()
1950        } else {
1951            new_target.availability_zones
1952        },
1953        logging: ReplicaLogging {
1954            log_logging: if unchanged.log_logging {
1955                prev.logging.log_logging
1956            } else {
1957                new_target.logging.log_logging
1958            },
1959            interval: if unchanged.interval {
1960                prev.logging.interval
1961            } else {
1962                new_target.logging.interval
1963            },
1964        },
1965        arrangement_compression: if unchanged.arrangement_compression {
1966            prev.arrangement_compression
1967        } else {
1968            new_target.arrangement_compression
1969        },
1970    }
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975    use mz_controller::clusters::ReplicaLogging;
1976    use mz_controller_types::DEFAULT_REPLICA_LOGGING_INTERVAL;
1977
1978    use super::*;
1979
1980    fn target(size: &str, rf: u32, azs: &[&str], log_logging: bool) -> ReconfigurationTarget {
1981        ReconfigurationTarget {
1982            size: size.to_string(),
1983            replication_factor: rf,
1984            availability_zones: azs.iter().map(|s| s.to_string()).collect(),
1985            logging: ReplicaLogging {
1986                log_logging,
1987                interval: Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
1988            },
1989            arrangement_compression: false,
1990        }
1991    }
1992
1993    fn all_changed() -> ReconfigurationDimensionsUnchanged {
1994        ReconfigurationDimensionsUnchanged {
1995            size: false,
1996            replication_factor: false,
1997            availability_zones: false,
1998            log_logging: false,
1999            interval: false,
2000            arrangement_compression: false,
2001        }
2002    }
2003
2004    fn all_unchanged() -> ReconfigurationDimensionsUnchanged {
2005        ReconfigurationDimensionsUnchanged {
2006            size: true,
2007            replication_factor: true,
2008            availability_zones: true,
2009            log_logging: true,
2010            interval: true,
2011            arrangement_compression: true,
2012        }
2013    }
2014
2015    #[mz_ore::test]
2016    fn foreground_wait_succeeds_when_target_is_realized() {
2017        let awaited = target("200cc", 1, &[], false);
2018        let record = ReconfigurationState {
2019            target: target("300cc", 1, &[], false),
2020            deadline: Timestamp::from(0),
2021            on_timeout: OnTimeoutAction::Rollback,
2022            status: ReconfigurationStatus::InProgress,
2023        };
2024
2025        assert!(matches!(
2026            reconfiguration_wait_result(Some(&record), &awaited, true),
2027            Some(Ok(()))
2028        ));
2029    }
2030
2031    #[mz_ore::test]
2032    fn foreground_wait_follows_matching_target() {
2033        let awaited = target("200cc", 1, &[], false);
2034        let mut record = ReconfigurationState {
2035            target: awaited.clone(),
2036            deadline: Timestamp::from(0),
2037            on_timeout: OnTimeoutAction::Rollback,
2038            status: ReconfigurationStatus::InProgress,
2039        };
2040
2041        assert!(reconfiguration_wait_result(Some(&record), &awaited, false).is_none());
2042
2043        record.status = ReconfigurationStatus::ResourceExhausted;
2044        assert!(matches!(
2045            reconfiguration_wait_result(Some(&record), &awaited, false),
2046            Some(Err(AdapterError::AlterClusterResourceExhausted))
2047        ));
2048
2049        record.status = ReconfigurationStatus::TimedOut;
2050        assert!(matches!(
2051            reconfiguration_wait_result(Some(&record), &awaited, false),
2052            Some(Err(AdapterError::AlterClusterTimeout))
2053        ));
2054    }
2055
2056    #[mz_ore::test]
2057    fn foreground_wait_reports_superseded_target() {
2058        let awaited = target("200cc", 1, &[], false);
2059        let record = ReconfigurationState {
2060            target: target("300cc", 1, &[], false),
2061            deadline: Timestamp::from(0),
2062            on_timeout: OnTimeoutAction::Rollback,
2063            status: ReconfigurationStatus::InProgress,
2064        };
2065
2066        assert!(matches!(
2067            reconfiguration_wait_result(Some(&record), &awaited, false),
2068            Some(Err(AdapterError::AlterClusterSuperseded))
2069        ));
2070        assert!(matches!(
2071            reconfiguration_wait_result(None, &awaited, false),
2072            Some(Err(AdapterError::AlterClusterSuperseded))
2073        ));
2074    }
2075
2076    #[mz_ore::test]
2077    fn fold_with_no_record_takes_new_target() {
2078        // No reconfiguration in flight: the target is exactly the new one.
2079        let new = target("200cc", 3, &["az1"], true);
2080        let folded = fold_reconfiguration_target(None, new.clone(), all_changed());
2081        assert_eq!(folded, new);
2082    }
2083
2084    #[mz_ore::test]
2085    fn fold_rf_only_keeps_in_flight_shape() {
2086        // A 200cc size change is in flight. A later rf-only ALTER must NOT revert
2087        // the in-flight size/AZ/logging back to the realized (100cc) values that
2088        // `new_target` carries for the dimensions the ALTER left unchanged.
2089        let in_flight = target("200cc", 1, &["az2"], true);
2090        // new_target reflects realized 100cc/az1 for every dimension but rf, which
2091        // the ALTER set to 5.
2092        let new = target("100cc", 5, &["az1"], false);
2093        let unchanged = ReconfigurationDimensionsUnchanged {
2094            size: true,
2095            replication_factor: false,
2096            availability_zones: true,
2097            log_logging: true,
2098            interval: true,
2099            arrangement_compression: true,
2100        };
2101        let folded = fold_reconfiguration_target(Some(&in_flight), new, unchanged);
2102        // The in-flight size/AZ/logging survive. Only rf is re-targeted.
2103        assert_eq!(folded, target("200cc", 5, &["az2"], true));
2104    }
2105
2106    #[mz_ore::test]
2107    fn fold_with_all_set_overwrites_every_dimension() {
2108        // Every dimension explicitly set: the fold takes all of new_target.
2109        let in_flight = target("200cc", 1, &["az2"], true);
2110        let new = target("400cc", 9, &["az9"], false);
2111        let folded = fold_reconfiguration_target(Some(&in_flight), new.clone(), all_changed());
2112        assert_eq!(folded, new);
2113    }
2114
2115    #[mz_ore::test]
2116    fn fold_all_unchanged_is_alter_back_to_in_flight() {
2117        // An all-unchanged fold keeps the in-flight target intact rather than
2118        // reverting it to the realized shape. Unreachable from the `ALTER`
2119        // path (non-shape statements no longer reach the fold), pinned as a
2120        // property of the pure function.
2121        let in_flight = target("200cc", 2, &["az2"], true);
2122        let realized_shaped = target("100cc", 1, &["az1"], false);
2123        let folded =
2124            fold_reconfiguration_target(Some(&in_flight), realized_shaped, all_unchanged());
2125        assert_eq!(folded, in_flight);
2126    }
2127
2128    #[mz_ore::test]
2129    fn fold_logging_subdimensions_fold_independently() {
2130        // An interval change is in flight. A later ALTER that sets only
2131        // INTROSPECTION DEBUGGING must not revert the in-flight interval to the
2132        // realized value that `new_target` carries for options the ALTER left
2133        // unset.
2134        let mut in_flight = target("100cc", 1, &["az1"], false);
2135        in_flight.logging.interval = Some(Duration::from_secs(5));
2136        let new = target("100cc", 1, &["az1"], true);
2137        let unchanged = ReconfigurationDimensionsUnchanged {
2138            size: true,
2139            replication_factor: true,
2140            availability_zones: true,
2141            log_logging: false,
2142            interval: true,
2143            arrangement_compression: true,
2144        };
2145        let folded = fold_reconfiguration_target(Some(&in_flight), new, unchanged);
2146        assert_eq!(
2147            folded.logging,
2148            ReplicaLogging {
2149                log_logging: true,
2150                interval: Some(Duration::from_secs(5)),
2151            }
2152        );
2153    }
2154}