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