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