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::{BTreeMap, BTreeSet};
11use std::time::{Duration, Instant};
12
13use itertools::Itertools;
14use maplit::btreeset;
15use mz_adapter_types::cluster_state::ReconfigurationAudit;
16use mz_catalog::builtin::BUILTINS;
17use mz_catalog::durable::managed_cluster_replica_name;
18use mz_catalog::memory::error::ErrorKind;
19use mz_catalog::memory::objects::{
20    Cluster, ClusterConfig, ClusterReplica, ClusterVariant, ClusterVariantManaged, DataSourceDesc,
21    ManagedReplicaConfigShape, ReconfigurationState, ReconfigurationStatus, ReconfigurationTarget,
22};
23use mz_compute_types::config::ComputeReplicaConfig;
24use mz_controller::clusters::{
25    ClusterStatus, ManagedReplicaLocation, ReplicaConfig, ReplicaLocation, ReplicaLogging,
26};
27use mz_controller_types::{ClusterId, DEFAULT_REPLICA_LOGGING_INTERVAL, ReplicaId};
28use mz_ore::cast::CastFrom;
29use mz_ore::collections::CollectionExt;
30use mz_ore::instrument;
31use mz_repr::Timestamp;
32use mz_repr::adt::numeric::Numeric;
33use mz_repr::role_id::RoleId;
34use mz_sql::ast::{Ident, QualifiedReplica};
35use mz_sql::catalog::{CatalogCluster, CatalogError, ObjectType};
36use mz_sql::names::QualifiedItemName;
37use mz_sql::plan::{
38    self, AlterClusterPlanStrategy, AlterClusterRenamePlan, AlterClusterReplicaRenamePlan,
39    AlterClusterSwapPlan, AlterOptionParameter, AlterSetClusterPlan,
40    ComputeReplicaIntrospectionConfig, CreateClusterManagedPlan, CreateClusterPlan,
41    CreateClusterReplicaPlan, CreateClusterUnmanagedPlan, CreateClusterVariant, PlanClusterOption,
42};
43use mz_sql::plan::{AlterClusterPlan, OnTimeoutAction};
44use mz_sql::session::metadata::SessionMetadata;
45use mz_sql::session::vars::{
46    MAX_CREDIT_CONSUMPTION_RATE, MAX_REPLICAS_PER_CLUSTER, SystemVars, Var,
47};
48use mz_storage_types::sources::SourceConnection;
49use tracing::{Instrument, Span, debug};
50
51use mz_adapter_types::dyncfgs::{
52    DEFAULT_CLUSTER_RECONFIGURATION_TIMEOUT, ENABLE_BACKGROUND_ALTER_CLUSTER,
53};
54
55use super::return_if_err;
56use crate::AdapterError::AlterClusterWhilePendingReplicas;
57use crate::catalog::{self, Op, ReplicaCreateDropReason};
58use crate::config::{
59    ClusterEvalContext, ClusterScopeContext, ReplicaEvalContext, ReplicaScopeContext,
60};
61use crate::coord::{
62    AlterCluster, AlterClusterAwaitReconfiguration, AlterClusterFinalize,
63    AlterClusterWaitForHydrated, ClusterReplicaStatuses, ClusterStage, Coordinator, Message,
64    PlanValidity, StageResult, Staged,
65};
66use crate::{AdapterError, AdapterNotice, ExecuteContext, ExecuteResponse, session::Session};
67
68const PENDING_REPLICA_SUFFIX: &str = "-pending";
69
70impl Staged for ClusterStage {
71    type Ctx = ExecuteContext;
72
73    fn validity(&mut self) -> &mut PlanValidity {
74        match self {
75            Self::Alter(stage) => &mut stage.validity,
76            Self::WaitForHydrated(stage) => &mut stage.validity,
77            Self::Finalize(stage) => &mut stage.validity,
78            Self::AwaitReconfiguration(stage) => &mut stage.validity,
79        }
80    }
81
82    async fn stage(
83        self,
84        coord: &mut Coordinator,
85        ctx: &mut ExecuteContext,
86    ) -> Result<StageResult<Box<Self>>, crate::AdapterError> {
87        match self {
88            Self::Alter(stage) => {
89                coord
90                    .sequence_alter_cluster_stage(ctx.session(), stage.plan.clone(), stage.validity)
91                    .await
92            }
93            Self::WaitForHydrated(stage) => {
94                let AlterClusterWaitForHydrated {
95                    validity,
96                    plan,
97                    new_config,
98                    workload_class,
99                    timeout_time,
100                    on_timeout,
101                } = stage;
102                coord
103                    .check_if_pending_replicas_hydrated_stage(
104                        ctx.session(),
105                        plan,
106                        new_config,
107                        workload_class,
108                        timeout_time,
109                        on_timeout,
110                        validity,
111                    )
112                    .await
113            }
114            Self::Finalize(stage) => {
115                coord
116                    .finalize_alter_cluster_stage(
117                        ctx.session(),
118                        stage.plan.clone(),
119                        stage.new_config.clone(),
120                        stage.workload_class.clone(),
121                    )
122                    .await
123            }
124            Self::AwaitReconfiguration(stage) => {
125                coord.await_reconfiguration_stage(stage.validity, stage.cluster_id, stage.target)
126            }
127        }
128    }
129
130    fn message(self, ctx: ExecuteContext, span: tracing::Span) -> Message {
131        Message::ClusterStageReady {
132            ctx,
133            span,
134            stage: self,
135        }
136    }
137
138    fn cancel_enabled(&self) -> bool {
139        true
140    }
141}
142
143impl Coordinator {
144    #[instrument]
145    pub(crate) async fn sequence_alter_cluster_staged(
146        &mut self,
147        ctx: ExecuteContext,
148        plan: plan::AlterClusterPlan,
149    ) {
150        let stage = return_if_err!(self.alter_cluster_validate(ctx.session(), plan).await, ctx);
151        self.sequence_staged(ctx, Span::current(), stage).await;
152    }
153
154    #[instrument]
155    async fn alter_cluster_validate(
156        &self,
157        session: &Session,
158        plan: plan::AlterClusterPlan,
159    ) -> Result<ClusterStage, AdapterError> {
160        let validity = PlanValidity::new(
161            self.catalog(),
162            BTreeSet::new(),
163            Some(plan.id.clone()),
164            None,
165            session.role_metadata().clone(),
166        );
167        Ok(ClusterStage::Alter(AlterCluster { validity, plan }))
168    }
169
170    async fn sequence_alter_cluster_stage(
171        &mut self,
172        session: &Session,
173        plan: plan::AlterClusterPlan,
174        validity: PlanValidity,
175    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
176        let AlterClusterPlan {
177            id: cluster_id,
178            name: _,
179            ref options,
180            ref strategy,
181        } = plan;
182
183        use mz_catalog::memory::objects::ClusterVariant::*;
184        use mz_sql::plan::AlterOptionParameter::*;
185        let cluster = self.catalog.get_cluster(cluster_id);
186        let config = cluster.config.clone();
187        let mut new_config = config.clone();
188
189        match (&new_config.variant, &options.managed) {
190            (Managed(_), Reset) | (Managed(_), Unchanged) | (Managed(_), Set(true)) => {}
191            (Managed(_), Set(false)) => new_config.variant = Unmanaged,
192            (Unmanaged, Unchanged) | (Unmanaged, Set(false)) => {}
193            (Unmanaged, Reset) | (Unmanaged, Set(true)) => {
194                // Generate a minimal correct configuration
195
196                // Size adjusted later when sequencing the actual configuration change.
197                let size = "".to_string();
198                let logging = ReplicaLogging {
199                    log_logging: false,
200                    interval: Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
201                };
202                new_config.variant = Managed(ClusterVariantManaged {
203                    size,
204                    availability_zones: Default::default(),
205                    logging,
206                    arrangement_compression: false,
207                    replication_factor: 1,
208                    optimizer_feature_overrides: Default::default(),
209                    schedule: Default::default(),
210                    auto_scaling_strategy: None,
211                    reconfiguration: None,
212                    burst: None,
213                });
214            }
215        }
216
217        match &mut new_config.variant {
218            Managed(ClusterVariantManaged {
219                size,
220                availability_zones,
221                logging,
222                arrangement_compression,
223                replication_factor,
224                optimizer_feature_overrides: _,
225                schedule,
226                auto_scaling_strategy,
227                reconfiguration: _,
228                burst: _,
229            }) => {
230                match &options.size {
231                    Set(s) => size.clone_from(s),
232                    Reset => coord_bail!("SIZE has no default value"),
233                    Unchanged => {}
234                }
235                match &options.availability_zones {
236                    Set(az) => availability_zones.clone_from(az),
237                    Reset => *availability_zones = Default::default(),
238                    Unchanged => {}
239                }
240                match &options.introspection_debugging {
241                    Set(id) => logging.log_logging = *id,
242                    Reset => logging.log_logging = false,
243                    Unchanged => {}
244                }
245                match &options.introspection_interval {
246                    Set(ii) => logging.interval = ii.0,
247                    Reset => logging.interval = Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
248                    Unchanged => {}
249                }
250                match &options.arrangement_compression {
251                    Set(ac) => *arrangement_compression = *ac,
252                    Reset => *arrangement_compression = false,
253                    Unchanged => {}
254                }
255                match &options.replication_factor {
256                    Set(rf) => *replication_factor = *rf,
257                    Reset => {
258                        *replication_factor = self
259                            .catalog
260                            .system_config()
261                            .default_cluster_replication_factor()
262                    }
263                    Unchanged => {}
264                }
265                match &options.schedule {
266                    Set(new_schedule) => {
267                        *schedule = new_schedule.clone();
268                    }
269                    Reset => *schedule = Default::default(),
270                    Unchanged => {}
271                }
272                match &options.auto_scaling_strategy {
273                    Set(new_strategy) => auto_scaling_strategy.clone_from(new_strategy),
274                    // The default is autoscaling disabled.
275                    Reset => *auto_scaling_strategy = None,
276                    Unchanged => {}
277                }
278                if !matches!(options.replicas, Unchanged) {
279                    coord_bail!("Cannot change REPLICAS of managed clusters");
280                }
281            }
282            Unmanaged => {
283                if !matches!(options.size, Unchanged) {
284                    coord_bail!("Cannot change SIZE of unmanaged clusters");
285                }
286                if !matches!(options.availability_zones, Unchanged) {
287                    coord_bail!("Cannot change AVAILABILITY ZONES of unmanaged clusters");
288                }
289                if !matches!(options.introspection_debugging, Unchanged) {
290                    coord_bail!("Cannot change INTROSPECTION DEGUBBING of unmanaged clusters");
291                }
292                if !matches!(options.introspection_interval, Unchanged) {
293                    coord_bail!("Cannot change INTROSPECTION INTERVAL of unmanaged clusters");
294                }
295                if !matches!(options.arrangement_compression, Unchanged) {
296                    coord_bail!(
297                        "Cannot change EXPERIMENTAL ARRANGEMENT COMPRESSION of unmanaged clusters"
298                    );
299                }
300                if !matches!(options.replication_factor, Unchanged) {
301                    coord_bail!("Cannot change REPLICATION FACTOR of unmanaged clusters");
302                }
303                if !matches!(options.auto_scaling_strategy, Unchanged) {
304                    coord_bail!("Cannot change AUTO SCALING STRATEGY of unmanaged clusters");
305                }
306            }
307        }
308
309        match &options.workload_class {
310            Set(wc) => new_config.workload_class.clone_from(wc),
311            Reset => new_config.workload_class = None,
312            Unchanged => {}
313        }
314
315        let reconfiguration_in_flight = matches!(
316            &config.variant,
317            Managed(managed) if managed
318                .reconfiguration
319                .as_ref()
320                .is_some_and(|record| record.is_in_progress())
321        );
322
323        // The schedule decides which strategy owns the cluster's replica set
324        // (the baseline for MANUAL, on-refresh otherwise), and the sequencer
325        // never writes a reconfiguration record for a scheduled cluster (see
326        // the routing below). Refuse flipping the schedule under an in-flight
327        // record rather than let the two ownership regimes overlap mid-flight.
328        if reconfiguration_in_flight && !matches!(options.schedule, Unchanged) {
329            return Err(AdapterError::AlterClusterScheduleWhileReconfiguring);
330        }
331
332        // Replication factor is one of the dimensions the cut-over sets
333        // atomically from the record's target (`fold_reconfiguration_target`),
334        // so a change applied independently while a reconfiguration is in
335        // flight would be silently clobbered at cut-over. Refused even when the
336        // same statement also re-targets the shape, so a record's target
337        // replication factor is always the one it started with.
338        if reconfiguration_in_flight && !matches!(options.replication_factor, Unchanged) {
339            return Err(AdapterError::AlterClusterReplicationFactorWhileReconfiguring);
340        }
341
342        // A no-op `ALTER` short-circuits, except that an `ALTER` back to the
343        // realized shape while a reconfiguration is in flight produces a
344        // byte-identical `new_config` and is still meaningful: it must reach
345        // the reshape path below to cancel the record.
346        let cancels_or_retargets =
347            reconfiguration_in_flight && alter_changes_replica_shape(options);
348        if new_config == config && !cancels_or_retargets {
349            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
350                ObjectType::Cluster,
351            )));
352        }
353
354        // An `ALTER` that raises a managed cluster's replication factor above
355        // one deserves a notice when the cluster contains sources that run on
356        // only one replica, since the additional replicas do not benefit those
357        // sources. Computed here, emitted only after the alter succeeds. The
358        // unmanaged conversion paths never change the replica count, so only
359        // the managed-to-managed transition is of interest.
360        let single_replica_sources_notice = match (&config.variant, &new_config.variant) {
361            (Managed(old_managed), Managed(new_managed))
362                if new_managed.replication_factor > old_managed.replication_factor
363                    && new_managed.replication_factor > 1 =>
364            {
365                let sources = self.single_replica_source_names(cluster);
366                (!sources.is_empty()).then(|| {
367                    AdapterNotice::SingleReplicaSourcesOnMultiReplicaCluster {
368                        cluster: cluster.name.clone(),
369                        sources,
370                    }
371                })
372            }
373            _ => None,
374        };
375
376        // A shape-changing `ALTER` reshapes into a durable `reconfiguration`
377        // record (starting, retargeting, or cancelling one) instead of going
378        // through the legacy 3-stage machine. Everything else falls through to
379        // the realized-config update below without touching the record, in
380        // flight or not.
381        //
382        // With a record in flight the statement decides: an `ALTER` back to the
383        // realized shape is value-identical yet must reach the reshape path to
384        // cancel. With nothing in flight the values decide: a shape option set
385        // to its current value reconfigures nothing, and reshaping it anyway
386        // would write a spurious pre-cancelled record.
387        if let (Managed(old_managed), Managed(new_managed)) = (&config.variant, &new_config.variant)
388        {
389            let needs_record = if reconfiguration_in_flight {
390                alter_changes_replica_shape(options)
391            } else {
392                new_managed.replica_config_shape() != old_managed.replica_config_shape()
393            };
394            // A scheduled (non-MANUAL) cluster holds its replication factor
395            // at 0 and the on-refresh strategy owns its replica set, so a
396            // graceful hydrate-overlap has nothing meaningful to wait for.
397            // A config-shape `ALTER` on such a cluster takes the direct
398            // path below instead of writing a record: that path only updates
399            // the realized config, and the controller reconciles any in-window
400            // replica to the new shape on its next tick. The schedule guard
401            // above keeps a schedule change from reaching here mid-record, so a
402            // record on a scheduled cluster can only pre-date the schedule
403            // (written on an older version). For that case the reshape
404            // path stays reachable, so the record can still be retargeted
405            // or cancelled until it settles.
406            let scheduled_direct =
407                !matches!(new_managed.schedule, mz_sql::plan::ClusterSchedule::Manual)
408                    && !reconfiguration_in_flight;
409            // A `WAIT` option would be silently vacuous on the direct
410            // path: there may be no replica at all (window closed), and
411            // an in-window replica is bounced to the new shape without a
412            // hydrate-overlap to wait on. Reject it rather than return an
413            // instant success that waited for nothing, mirroring the
414            // planner's rejection of a `WAIT` without a shape change.
415            if scheduled_direct && !matches!(strategy, AlterClusterPlanStrategy::None) {
416                return Err(AdapterError::AlterClusterWaitOnScheduledCluster);
417            }
418            if needs_record && !scheduled_direct {
419                let result = self
420                    .reshape_alter_cluster_managed(
421                        session,
422                        cluster_id,
423                        new_config.clone(),
424                        options,
425                        strategy,
426                        validity,
427                    )
428                    .await;
429                if result.is_ok() {
430                    if let Some(notice) = single_replica_sources_notice {
431                        session.add_notice(notice);
432                    }
433                }
434                return result;
435            }
436        }
437
438        match (&config.variant, &new_config.variant) {
439            (Managed(_), Managed(new_config_managed)) => {
440                let alter_followup = self
441                    .sequence_alter_cluster_managed_to_managed(
442                        Some(session),
443                        cluster_id,
444                        new_config.clone(),
445                        ReplicaCreateDropReason::Manual,
446                        strategy.clone(),
447                    )
448                    .await?;
449                if let Some(notice) = single_replica_sources_notice {
450                    session.add_notice(notice);
451                }
452                if alter_followup == NeedsFinalization::Yes {
453                    // For non backgrounded zero-downtime alters, store the
454                    // cluster_id in the ConnMeta to allow for cancellation.
455                    self.active_conns
456                        .get_mut(session.conn_id())
457                        .expect("There must be an active connection")
458                        .pending_cluster_alters
459                        .insert(cluster_id.clone());
460                    let new_config_managed = new_config_managed.clone();
461                    return match &strategy {
462                        AlterClusterPlanStrategy::None => Err(AdapterError::Internal(
463                            "AlterClusterPlanStrategy must not be None if NeedsFinalization is Yes"
464                                .into(),
465                        )),
466                        AlterClusterPlanStrategy::For(duration) => {
467                            let span = Span::current();
468                            let plan = plan.clone();
469                            let duration = duration.clone().to_owned();
470                            let workload_class = new_config.workload_class.clone();
471                            Ok(StageResult::Handle(mz_ore::task::spawn(
472                                || "Finalize Alter Cluster",
473                                async move {
474                                    tokio::time::sleep(duration).await;
475                                    let stage = ClusterStage::Finalize(AlterClusterFinalize {
476                                        validity,
477                                        plan,
478                                        new_config: new_config_managed,
479                                        workload_class,
480                                    });
481                                    Ok(Box::new(stage))
482                                }
483                                .instrument(span),
484                            )))
485                        }
486                        AlterClusterPlanStrategy::UntilReady {
487                            timeout,
488                            on_timeout,
489                        } => Ok(StageResult::Immediate(Box::new(
490                            ClusterStage::WaitForHydrated(AlterClusterWaitForHydrated {
491                                validity,
492                                plan: plan.clone(),
493                                new_config: new_config_managed.clone(),
494                                workload_class: new_config.workload_class.clone(),
495                                timeout_time: Instant::now() + timeout.to_owned(),
496                                // The legacy foreground wait uses COMMIT as
497                                // its implicit default. The controller-owned
498                                // paths default to ROLLBACK.
499                                on_timeout: on_timeout.unwrap_or(OnTimeoutAction::Commit),
500                            }),
501                        ))),
502                    };
503                }
504            }
505            (Unmanaged, Managed(new_managed)) => {
506                // The conversion path creates no overlap replicas to wait on,
507                // and a scheduled target makes the `WAIT` permanently
508                // meaningless, mirroring the managed-to-managed rejection
509                // above.
510                if !matches!(new_managed.schedule, mz_sql::plan::ClusterSchedule::Manual)
511                    && !matches!(strategy, AlterClusterPlanStrategy::None)
512                {
513                    return Err(AdapterError::AlterClusterWaitOnScheduledCluster);
514                }
515                self.sequence_alter_cluster_unmanaged_to_managed(
516                    session,
517                    cluster_id,
518                    new_config,
519                    options.to_owned(),
520                )
521                .await?;
522            }
523            (Managed(_), Unmanaged) => {
524                self.sequence_alter_cluster_managed_to_unmanaged(session, cluster_id, new_config)
525                    .await?;
526            }
527            (Unmanaged, Unmanaged) => {
528                self.sequence_alter_cluster_unmanaged_to_unmanaged(
529                    session,
530                    cluster_id,
531                    new_config,
532                    options.replicas.clone(),
533                )
534                .await?;
535            }
536        }
537
538        Ok(StageResult::Response(ExecuteResponse::AlteredObject(
539            ObjectType::Cluster,
540        )))
541    }
542
543    /// Validates that a reconfiguration to `target` fits the resource budget.
544    fn validate_reconfiguration_resource_limits(
545        &self,
546        cluster_id: ClusterId,
547        target: &ReconfigurationTarget,
548    ) -> Result<(), AdapterError> {
549        // System clusters are exempt from `max_replicas_per_cluster` and from
550        // credit accounting everywhere else (see the `is_user` guards in
551        // `catalog_transact`'s validation), so a reconfiguration of one has no
552        // budget to fit either.
553        if !cluster_id.is_user() {
554            return Ok(());
555        }
556        let cluster = self.catalog().get_cluster(cluster_id);
557        let ClusterVariant::Managed(realized) = &cluster.config.variant else {
558            return Ok(());
559        };
560
561        // An `ALTER` back to the realized shape cancels the reconfiguration and
562        // materializes nothing new, so there is nothing to validate. The peak
563        // model below would double count the realized set and spuriously reject
564        // the cancel, exactly when the environment is at its limits and the
565        // escape hatch matters most.
566        if target.matches_realized_config(realized) {
567            return Ok(());
568        }
569
570        // Both checks below model the transient peak: the controller runs the
571        // realized and target sets side by side until cut-over, so this cluster's
572        // peak contribution is both shapes at once, computed from config as
573        // realized plus target. That slightly over-counts a same-shape overlap,
574        // where existing replicas double as target replicas, but it matches the
575        // legacy wait path, which creates the full target set as pending replicas
576        // at `ALTER` time and therefore enforces both limits on the overlap.
577        // Rejecting here is also strictly better than the asynchronous abort the
578        // controller falls back to when a limit shrinks or the environment grows
579        // after the record is written.
580
581        // Per-cluster replica count: the peak is `realized_rf + target_rf`,
582        // deterministic from the cluster's own config. `validate_resource_limit`
583        // returns early on an rf-0 target.
584        self.validate_resource_limit(
585            usize::cast_from(realized.replication_factor),
586            i64::from(target.replication_factor),
587            SystemVars::max_replicas_per_cluster,
588            "cluster replica",
589            MAX_REPLICAS_PER_CLUSTER.name(),
590        )?;
591
592        // Global credit rate: the peak is `credit(realized) + credit(target)`.
593        self.validate_reconfiguration_credit_peak(cluster_id, realized, target)?;
594
595        Ok(())
596    }
597
598    /// Validates that the transient credit-rate peak of a reconfiguration, the
599    /// realized plus the target shape, fits the environment-wide budget.
600    ///
601    /// The base is the live consumption of every other cluster. It excludes
602    /// this cluster's own replicas so a re-target of an in-flight record does
603    /// not additionally count an already-materialized overlap on top of the
604    /// modeled peak.
605    fn validate_reconfiguration_credit_peak(
606        &self,
607        cluster_id: ClusterId,
608        realized: &ClusterVariantManaged,
609        target: &ReconfigurationTarget,
610    ) -> Result<(), AdapterError> {
611        let shape_credit = |size: &str, replication_factor: u32| -> Numeric {
612            let per_replica = self
613                .catalog()
614                .cluster_replica_sizes()
615                .0
616                .get(size)
617                .map(|allocation| allocation.credits_per_hour)
618                // Sizes are validated by `ensure_valid_replica_size` before we get
619                // here, so an unknown size contributes nothing rather than panics.
620                .unwrap_or_else(Numeric::zero);
621            per_replica * Numeric::from(replication_factor)
622        };
623        let mut peak_credit = shape_credit(&target.size, target.replication_factor);
624        peak_credit += shape_credit(&realized.size, realized.replication_factor);
625        self.validate_resource_limit_numeric(
626            self.current_credit_consumption_rate(Some(cluster_id)),
627            peak_credit,
628            |system_vars| {
629                self.license_key
630                    .max_credit_consumption_rate()
631                    .map_or_else(|| system_vars.max_credit_consumption_rate(), Numeric::from)
632            },
633            "cluster replica",
634            MAX_CREDIT_CONSUMPTION_RATE.name(),
635        )?;
636
637        Ok(())
638    }
639
640    /// Reshape a managed→managed `ALTER` into a durable `reconfiguration` record.
641    ///
642    /// Writes (or folds into) the `reconfiguration` record carrying the full target
643    /// config shape and a deadline, while leaving the realized *shape* in place.
644    /// Non-shape fields the `ALTER` changed (`workload_class`, `schedule`,
645    /// `auto_scaling_strategy`, ...) need no hydrate-overlap, so they are applied
646    /// to the realized config immediately. The controller converges the replica
647    /// set onto the target and cuts the realized shape over at hydration.
648    ///
649    /// **Fold semantics.** When a record is already in flight, the target is an
650    /// overlay on the *in-flight target*, not the realized config: a dimension the
651    /// `ALTER` set (`options.*` is `Set`/`Reset`) takes the new value, a dimension
652    /// left `Unchanged` keeps the in-flight target's value. `new_config` was built
653    /// against the realized config, which still holds the pre-reconfiguration shape
654    /// (the realized config is advanced only at cut-over), so seeding `Unchanged`
655    /// dimensions from it would silently revert the in-flight transition along any
656    /// dimension this `ALTER` did not mention. With no record in flight there is
657    /// nothing to fold and the target is exactly `new_config`'s shape.
658    ///
659    /// **Timeout action.** The record carries an `on_timeout` action (resolved
660    /// from `WITH (WAIT ...)`, defaulting to `ROLLBACK`), which the controller
661    /// applies at the deadline only if the target has not hydrated: `ROLLBACK`
662    /// marks the record timed out and drops the in-flight target set, leaving the
663    /// realized config untouched, so the cluster reverts to its
664    /// pre-reconfiguration shape and the strategy disengages. `COMMIT` cuts the
665    /// realized config over to the not-fully-hydrated target and marks the record
666    /// finalized. Success always takes precedence. A target that hydrates before the deadline cuts over regardless
667    /// of the action.
668    ///
669    /// With `enable_background_alter_cluster` on, the statement returns
670    /// immediately. With it off, the session blocks on a wait-shim
671    /// ([`ClusterStage::AwaitReconfiguration`]) that polls until the controller
672    /// resolves the record, reporting success only if the realized config
673    /// reached the target, preserving today's foreground UX over the same
674    /// durable mechanism.
675    async fn reshape_alter_cluster_managed(
676        &mut self,
677        session: &Session,
678        cluster_id: ClusterId,
679        new_config: ClusterConfig,
680        options: &PlanClusterOption,
681        strategy: &AlterClusterPlanStrategy,
682        validity: PlanValidity,
683    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
684        use mz_sql::plan::AlterOptionParameter::Unchanged;
685
686        let ClusterVariant::Managed(new_managed) = &new_config.variant else {
687            return Err(AdapterError::Internal(
688                "reshape_alter_cluster_managed requires a managed target config".into(),
689            ));
690        };
691
692        // Fold onto the in-flight target when one exists: `new_config` carries the
693        // realized value for any dimension the `ALTER` left `Unchanged`, but the
694        // realized config is the pre-reconfiguration shape, so we instead carry the
695        // in-flight target's value for those dimensions. Only dimensions the `ALTER`
696        // explicitly set diverge from the in-flight target.
697        let cluster = self.catalog.get_cluster(cluster_id);
698        let in_flight = match &cluster.config.variant {
699            ClusterVariant::Managed(managed) => managed
700                .reconfiguration
701                .as_ref()
702                .filter(|record| record.is_in_progress())
703                .cloned(),
704            ClusterVariant::Unmanaged => None,
705        };
706        let new_target = ReconfigurationTarget {
707            size: new_managed.size.clone(),
708            replication_factor: new_managed.replication_factor,
709            availability_zones: new_managed.availability_zones.clone(),
710            logging: new_managed.logging.clone(),
711            arrangement_compression: new_managed.arrangement_compression,
712        };
713        let unchanged = ReconfigurationDimensionsUnchanged {
714            size: matches!(options.size, Unchanged),
715            replication_factor: matches!(options.replication_factor, Unchanged),
716            availability_zones: matches!(options.availability_zones, Unchanged),
717            // The two logging options fold independently, so a debugging-only
718            // `ALTER` cannot revert an in-flight interval change (or vice versa).
719            log_logging: matches!(options.introspection_debugging, Unchanged),
720            interval: matches!(options.introspection_interval, Unchanged),
721            arrangement_compression: matches!(options.arrangement_compression, Unchanged),
722        };
723        let target = fold_reconfiguration_target(
724            in_flight.as_ref().map(|r| &r.target),
725            new_target,
726            unchanged,
727        );
728
729        // Validate the target up front, so a bad reshape errors at `ALTER` time
730        // rather than silently parking an unconvergeable record.
731        let role_id = session.role_metadata().current_role;
732        self.catalog.ensure_valid_replica_size(
733            &self
734                .catalog()
735                .get_role_allowed_cluster_sizes(&Some(role_id)),
736            &target.size,
737            false,
738        )?;
739        self.ensure_valid_azs(target.availability_zones.iter())?;
740        // Validate the reconfiguration's resource footprint up front, so a
741        // reshape that cannot fit errors at `ALTER` time rather than writing a
742        // record the controller aborts asynchronously.
743        self.validate_reconfiguration_resource_limits(cluster_id, &target)?;
744
745        // Resolve the deadline and the on-timeout action, both written relative
746        // to the current time so they survive session disconnect and restart.
747        // The target folds per-dimension onto the in-flight one. The deadline
748        // and `on_timeout`, in contrast, are the contract carried by a `WAIT`
749        // clause, so how a folding `ALTER` treats them depends on whether it
750        // carries one:
751        //   - no `WAIT`, reconfiguration in flight -> keep the in-flight
752        //                          record's deadline and `on_timeout`. The
753        //                          statement carries no contract of its own, so
754        //                          an unrelated config-shape `ALTER` must not
755        //                          silently reset the deadline and action the
756        //                          user set on the reconfiguration in progress.
757        //   - no `WAIT`, nothing in flight -> the system-default timeout and the
758        //                          implicit `on_timeout` default (`ROLLBACK`).
759        //   - `WAIT FOR`        -> sugar for `ON TIMEOUT COMMIT` (cut over at the
760        //                          deadline regardless of hydration).
761        //   - `WAIT UNTIL READY -> the explicit `TIMEOUT` / `ON TIMEOUT`, with
762        //                          `ON TIMEOUT` defaulting to `ROLLBACK` when
763        //                          omitted.
764        // An explicit `WAIT` clause is folded onto an in-flight record wholesale,
765        // which lets a later `ALTER` steer the deadline and timeout action of a
766        // reconfiguration in progress without discarding the hydration progress
767        // its target may already have. `ROLLBACK` (the default) reverts an
768        // un-hydrated reconfiguration to its pre-reconfiguration shape rather
769        // than cutting over to a not-yet-hydrated target, which could induce
770        // downtime. The legacy foreground path uses implicit `COMMIT`.
771        let now = self.now();
772        let deadline_from = |timeout: Duration| -> Timestamp {
773            now.saturating_add(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
774                .into()
775        };
776        let (deadline, on_timeout) = match strategy {
777            AlterClusterPlanStrategy::None => match &in_flight {
778                Some(record) => (record.deadline, record.on_timeout),
779                None => (
780                    deadline_from(
781                        DEFAULT_CLUSTER_RECONFIGURATION_TIMEOUT
782                            .get(self.catalog().system_config().dyncfgs()),
783                    ),
784                    OnTimeoutAction::Rollback,
785                ),
786            },
787            AlterClusterPlanStrategy::For(timeout) => {
788                (deadline_from(*timeout), OnTimeoutAction::Commit)
789            }
790            AlterClusterPlanStrategy::UntilReady {
791                timeout,
792                on_timeout,
793            } => (
794                deadline_from(*timeout),
795                on_timeout.unwrap_or(OnTimeoutAction::Rollback),
796            ),
797        };
798
799        // Build the durable write from `new_config`, which carries every field the
800        // `ALTER` changed, then reset the config *shape* (every
801        // `ReconfigurationTarget` dimension) back to the realized values: that
802        // transition is deferred to the `reconfiguration` record and applied at
803        // cut-over. This applies non-shape changes (`workload_class`, `schedule`,
804        // `auto_scaling_strategy`, ...) immediately, matching the legacy path,
805        // rather than silently dropping them. Any existing record is folded over by
806        // the `record` we just built.
807        let cluster = self.catalog.get_cluster(cluster_id);
808        let cluster_name = cluster.name().to_string();
809        let ClusterVariant::Managed(realized_now) = &cluster.config.variant else {
810            return Err(AdapterError::Internal(
811                "reshape_alter_cluster_managed requires a managed realized config".into(),
812            ));
813        };
814        let realized_target = realized_now.realized_reconfiguration_target();
815        // The status and the audit intent are two views of the same decision,
816        // made together here: an ALTER back to the realized shape is a cancel,
817        // anything else starts (or re-targets) a reconfiguration.
818        let (status, audit) = if target.matches_realized_config(realized_now) {
819            (
820                ReconfigurationStatus::Cancelled,
821                ReconfigurationAudit::Cancelled,
822            )
823        } else {
824            (
825                ReconfigurationStatus::InProgress,
826                ReconfigurationAudit::Started,
827            )
828        };
829        let record = ReconfigurationState {
830            target: target.clone(),
831            deadline,
832            on_timeout,
833            status,
834        };
835
836        let mut realized = new_config.clone();
837        let ClusterVariant::Managed(realized_managed) = &mut realized.variant else {
838            return Err(AdapterError::Internal(
839                "reshape_alter_cluster_managed requires a managed target config".into(),
840            ));
841        };
842        realized_managed.apply_reconfiguration_target(realized_target);
843        realized_managed.reconfiguration = Some(record);
844
845        self.catalog_transact(
846            Some(session),
847            vec![Op::UpdateClusterConfig {
848                id: cluster_id,
849                name: cluster_name,
850                config: realized,
851                reconfiguration_audit: Some(audit),
852                burst_audit: None,
853            }],
854        )
855        .await?;
856
857        let background =
858            ENABLE_BACKGROUND_ALTER_CLUSTER.get(self.catalog().system_config().dyncfgs());
859        if background {
860            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
861                ObjectType::Cluster,
862            )));
863        }
864
865        // Foreground wait-shim: poll the durable record until it resolves. The
866        // reconfiguration continues in the background regardless of the session. A
867        // disconnect during the wait only stops waiting.
868        Ok(StageResult::Immediate(Box::new(
869            ClusterStage::AwaitReconfiguration(AlterClusterAwaitReconfiguration {
870                validity,
871                cluster_id,
872                target,
873            }),
874        )))
875    }
876
877    /// Polls the durable `reconfiguration` record for the foreground wait-shim.
878    ///
879    /// The controller owns deadline handling. This stage reports success only once
880    /// the realized config reaches `target`, and otherwise keeps polling while
881    /// the record is in progress.
882    fn await_reconfiguration_stage(
883        &self,
884        validity: PlanValidity,
885        cluster_id: ClusterId,
886        target: ReconfigurationTarget,
887    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
888        let Some(cluster) = self.catalog().try_get_cluster(cluster_id) else {
889            // The cluster was dropped out from under the reconfiguration.
890            // There is nothing to wait on.
891            return Ok(StageResult::Response(ExecuteResponse::AlteredObject(
892                ObjectType::Cluster,
893            )));
894        };
895        let record = match &cluster.config.variant {
896            ClusterVariant::Managed(managed) => managed.reconfiguration.clone(),
897            ClusterVariant::Unmanaged => None,
898        };
899
900        let realized_matches_target = match &cluster.config.variant {
901            ClusterVariant::Managed(managed) => target.matches_realized_config(managed),
902            ClusterVariant::Unmanaged => false,
903        };
904
905        match record {
906            None => {
907                // Defensive fallback for old or manually-edited catalogs. New
908                // controller writes retain a terminal record.
909                if realized_matches_target {
910                    Ok(StageResult::Response(ExecuteResponse::AlteredObject(
911                        ObjectType::Cluster,
912                    )))
913                } else {
914                    Err(AdapterError::AlterClusterTimeout)
915                }
916            }
917            Some(record) if !record.is_in_progress() => {
918                if matches!(
919                    record.status,
920                    ReconfigurationStatus::Finalized | ReconfigurationStatus::Cancelled
921                ) && realized_matches_target
922                {
923                    Ok(StageResult::Response(ExecuteResponse::AlteredObject(
924                        ObjectType::Cluster,
925                    )))
926                } else {
927                    Err(AdapterError::AlterClusterTimeout)
928                }
929            }
930            Some(_) => {
931                // Still in progress. Re-poll after the configured interval and
932                // wait for the controller to resolve the record. We deliberately
933                // do not consult the deadline here: erroring while the record is
934                // in progress can race the controller and misreport an `ON
935                // TIMEOUT COMMIT` cut-over as a timeout.
936                //
937                // NOTE: If the controller stops resolving a record while it is
938                // in progress, the shim waits indefinitely. Cancelling the session
939                // only stops waiting. It does not abort the durable reconfiguration.
940                let poll_duration = self
941                    .catalog
942                    .system_config()
943                    .cluster_alter_check_ready_interval();
944                let span = Span::current();
945                Ok(StageResult::Handle(mz_ore::task::spawn(
946                    || "Await Cluster Reconfiguration",
947                    async move {
948                        tokio::time::sleep(poll_duration).await;
949                        Ok(Box::new(ClusterStage::AwaitReconfiguration(
950                            AlterClusterAwaitReconfiguration {
951                                validity,
952                                cluster_id,
953                                target,
954                            },
955                        )))
956                    }
957                    .instrument(span),
958                )))
959            }
960        }
961    }
962
963    async fn finalize_alter_cluster_stage(
964        &mut self,
965        session: &Session,
966        AlterClusterPlan {
967            id: cluster_id,
968            name: cluster_name,
969            ..
970        }: AlterClusterPlan,
971        new_config: ClusterVariantManaged,
972        workload_class: Option<String>,
973    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
974        let cluster = self.catalog.get_cluster(cluster_id);
975        let mut ops = vec![];
976
977        // Gather the ops to remove the non pending replicas
978        // Also skip any billed_as free replicas
979        let remove_replicas = cluster
980            .replicas()
981            .filter_map(|r| {
982                if !r.config.location.pending() && !r.config.location.internal() {
983                    Some(catalog::DropObjectInfo::ClusterReplica((
984                        cluster_id.clone(),
985                        r.replica_id,
986                        ReplicaCreateDropReason::Manual,
987                    )))
988                } else {
989                    None
990                }
991            })
992            .collect();
993        ops.push(catalog::Op::DropObjects(remove_replicas));
994
995        // Gather the Ops to remove the "-pending" suffix from the name and set
996        // pending to false
997        let finalize_replicas: Vec<catalog::Op> = cluster
998            .replicas()
999            .filter_map(|r| {
1000                if r.config.location.pending() {
1001                    let cluster_ident = match Ident::new(cluster.name.clone()) {
1002                        Ok(id) => id,
1003                        Err(err) => {
1004                            return Some(Err(AdapterError::internal(
1005                                "Unexpected error parsing cluster name",
1006                                err,
1007                            )));
1008                        }
1009                    };
1010                    let replica_ident = match Ident::new(r.name.clone()) {
1011                        Ok(id) => id,
1012                        Err(err) => {
1013                            return Some(Err(AdapterError::internal(
1014                                "Unexpected error parsing replica name",
1015                                err,
1016                            )));
1017                        }
1018                    };
1019                    Some(Ok((cluster_ident, replica_ident, r)))
1020                } else {
1021                    None
1022                }
1023            })
1024            // Early collection is to handle errors from generating of the
1025            // Idents
1026            .collect::<Result<Vec<(Ident, Ident, &ClusterReplica)>, _>>()?
1027            .into_iter()
1028            .map(|(cluster_ident, replica_ident, replica)| {
1029                let mut new_replica_config = replica.config.clone();
1030                debug!("Promoting replica: {}", replica.name);
1031                match new_replica_config.location {
1032                    mz_controller::clusters::ReplicaLocation::Managed(ManagedReplicaLocation {
1033                        ref mut pending,
1034                        ..
1035                    }) => {
1036                        *pending = false;
1037                    }
1038                    mz_controller::clusters::ReplicaLocation::Unmanaged(_) => {}
1039                }
1040
1041                let mut replica_ops = vec![];
1042                let to_name = replica.name.strip_suffix(PENDING_REPLICA_SUFFIX);
1043                if let Some(to_name) = to_name {
1044                    replica_ops.push(catalog::Op::RenameClusterReplica {
1045                        cluster_id: cluster_id.clone(),
1046                        replica_id: replica.replica_id.to_owned(),
1047                        name: QualifiedReplica {
1048                            cluster: cluster_ident,
1049                            replica: replica_ident,
1050                        },
1051                        to_name: to_name.to_owned(),
1052                    });
1053                }
1054                replica_ops.push(catalog::Op::UpdateClusterReplicaConfig {
1055                    cluster_id,
1056                    replica_id: replica.replica_id.to_owned(),
1057                    config: new_replica_config,
1058                });
1059                replica_ops
1060            })
1061            .flatten()
1062            .collect();
1063
1064        ops.extend(finalize_replicas);
1065
1066        // Add the Op to update the cluster state. A stale in-progress
1067        // reconfiguration record carried by this legacy write is retained as
1068        // cancelled, with the matching audit intent declared.
1069        let mut final_config = ClusterConfig {
1070            variant: ClusterVariant::Managed(new_config),
1071            workload_class: workload_class.clone(),
1072        };
1073        let reconfiguration_audit = cancel_carried_reconfiguration(&mut final_config);
1074        ops.push(Op::UpdateClusterConfig {
1075            id: cluster_id,
1076            name: cluster_name,
1077            config: final_config,
1078            reconfiguration_audit,
1079            burst_audit: None,
1080        });
1081        self.catalog_transact(Some(session), ops).await?;
1082        // Remove the cluster being altered from the ConnMeta
1083        // pending_cluster_alters BTreeSet
1084        self.active_conns
1085            .get_mut(session.conn_id())
1086            .expect("There must be an active connection")
1087            .pending_cluster_alters
1088            .remove(&cluster_id);
1089
1090        Ok(StageResult::Response(ExecuteResponse::AlteredObject(
1091            ObjectType::Cluster,
1092        )))
1093    }
1094
1095    async fn check_if_pending_replicas_hydrated_stage(
1096        &mut self,
1097        session: &Session,
1098        plan: AlterClusterPlan,
1099        new_config: ClusterVariantManaged,
1100        workload_class: Option<String>,
1101        timeout_time: Instant,
1102        on_timeout: OnTimeoutAction,
1103        validity: PlanValidity,
1104    ) -> Result<StageResult<Box<ClusterStage>>, AdapterError> {
1105        // wait and re-signal wait for hydrated if not hydrated
1106        let cluster = self.catalog.get_cluster(plan.id);
1107        let pending_replicas = cluster
1108            .replicas()
1109            .filter_map(|r| {
1110                if r.config.location.pending() {
1111                    Some(r.replica_id.clone())
1112                } else {
1113                    None
1114                }
1115            })
1116            .collect_vec();
1117        // Check For timeout
1118        if Instant::now() > timeout_time {
1119            // Timed out handle timeout action
1120            match on_timeout {
1121                OnTimeoutAction::Rollback => {
1122                    self.active_conns
1123                        .get_mut(session.conn_id())
1124                        .expect("There must be an active connection")
1125                        .pending_cluster_alters
1126                        .remove(&cluster.id);
1127                    self.drop_reconfiguration_replicas(btreeset!(cluster.id))
1128                        .await?;
1129                    return Err(AdapterError::AlterClusterTimeout);
1130                }
1131                OnTimeoutAction::Commit => {
1132                    let span = Span::current();
1133                    let poll_duration = self
1134                        .catalog
1135                        .system_config()
1136                        .cluster_alter_check_ready_interval()
1137                        .clone();
1138                    return Ok(StageResult::Handle(mz_ore::task::spawn(
1139                        || "Finalize Alter Cluster",
1140                        async move {
1141                            tokio::time::sleep(poll_duration).await;
1142                            let stage = ClusterStage::Finalize(AlterClusterFinalize {
1143                                validity,
1144                                plan,
1145                                new_config,
1146                                workload_class,
1147                            });
1148                            Ok(Box::new(stage))
1149                        }
1150                        .instrument(span),
1151                    )));
1152                }
1153            }
1154        }
1155        let compute_hydrated_fut = self
1156            .controller
1157            .compute
1158            .collections_hydrated_for_replicas(cluster.id, pending_replicas.clone(), [].into())
1159            .map_err(|e| AdapterError::internal("Failed to check hydration", e))?;
1160
1161        let storage_hydrated = self
1162            .controller
1163            .storage
1164            .collections_hydrated_on_replicas(
1165                Some(pending_replicas.clone()),
1166                &cluster.id,
1167                &[].into(),
1168            )
1169            .map_err(|e| AdapterError::internal("Failed to check hydration", e))?;
1170
1171        // Also require every pending replica to be online, in case it has no
1172        // objects that need hydration on it (e.g. a single-replica source).
1173        let replicas_online = pending_replicas.iter().all(|replica_id| {
1174            let status = self
1175                .cluster_replica_statuses
1176                .try_get_cluster_replica_statuses(cluster.id, *replica_id)
1177                .map(ClusterReplicaStatuses::cluster_replica_status);
1178            matches!(status, Some(ClusterStatus::Online))
1179        });
1180
1181        let span = Span::current();
1182        Ok(StageResult::Handle(mz_ore::task::spawn(
1183            || "Alter Cluster: wait for hydrated",
1184            async move {
1185                let compute_hydrated = compute_hydrated_fut
1186                    .await
1187                    .map_err(|e| AdapterError::internal("Failed to check hydration", e))?;
1188
1189                if compute_hydrated && storage_hydrated && replicas_online {
1190                    // We're done
1191                    Ok(Box::new(ClusterStage::Finalize(AlterClusterFinalize {
1192                        validity,
1193                        plan,
1194                        new_config: new_config.clone(),
1195                        workload_class: workload_class.clone(),
1196                    })))
1197                } else {
1198                    // Check later
1199                    tokio::time::sleep(Duration::from_secs(1)).await;
1200                    let stage = ClusterStage::WaitForHydrated(AlterClusterWaitForHydrated {
1201                        validity,
1202                        plan,
1203                        new_config,
1204                        workload_class,
1205                        timeout_time,
1206                        on_timeout,
1207                    });
1208                    Ok(Box::new(stage))
1209                }
1210            }
1211            .instrument(span),
1212        )))
1213    }
1214
1215    #[mz_ore::instrument(level = "debug")]
1216    pub(crate) async fn sequence_create_cluster(
1217        &mut self,
1218        session: &Session,
1219        CreateClusterPlan {
1220            name,
1221            variant,
1222            workload_class,
1223            if_not_exists,
1224        }: CreateClusterPlan,
1225    ) -> Result<ExecuteResponse, AdapterError> {
1226        tracing::debug!("sequence_create_cluster");
1227
1228        let id_ts = self.get_catalog_write_ts().await;
1229        let id = self.catalog().allocate_user_cluster_id(id_ts).await?;
1230        // The catalog items for the introspection sources are shared between all replicas
1231        // of a compute instance, so we create them unconditionally during instance creation.
1232        // Whether a replica actually maintains introspection arrangements is determined by the
1233        // per-replica introspection configuration.
1234        let introspection_sources = BUILTINS::logs().collect();
1235        let cluster_variant = match &variant {
1236            CreateClusterVariant::Managed(plan) => {
1237                let logging = if let Some(config) = plan.compute.introspection {
1238                    ReplicaLogging {
1239                        log_logging: config.debugging,
1240                        interval: Some(config.interval),
1241                    }
1242                } else {
1243                    ReplicaLogging::default()
1244                };
1245                ClusterVariant::Managed(ClusterVariantManaged {
1246                    size: plan.size.clone(),
1247                    availability_zones: plan.availability_zones.clone(),
1248                    logging,
1249                    arrangement_compression: plan.compute.arrangement_compression,
1250                    replication_factor: plan.replication_factor,
1251                    optimizer_feature_overrides: plan.optimizer_feature_overrides.clone(),
1252                    schedule: plan.schedule.clone(),
1253                    auto_scaling_strategy: plan.auto_scaling_strategy.clone(),
1254                    reconfiguration: None,
1255                    burst: None,
1256                })
1257            }
1258            CreateClusterVariant::Unmanaged(_) => ClusterVariant::Unmanaged,
1259        };
1260        let config = ClusterConfig {
1261            variant: cluster_variant,
1262            workload_class,
1263        };
1264        let ops = vec![catalog::Op::CreateCluster {
1265            id,
1266            name: name.clone(),
1267            introspection_sources,
1268            owner_id: *session.current_role_id(),
1269            config,
1270        }];
1271
1272        match variant {
1273            CreateClusterVariant::Managed(plan) => {
1274                self.sequence_create_managed_cluster(session, plan, id, name.clone(), ops)
1275                    .await
1276            }
1277            CreateClusterVariant::Unmanaged(plan) => {
1278                self.sequence_create_unmanaged_cluster(session, plan, id, name.clone(), ops)
1279                    .await
1280            }
1281        }
1282        .or_else(|err| match err {
1283            AdapterError::Catalog(mz_catalog::memory::error::Error {
1284                kind: ErrorKind::Sql(CatalogError::ClusterAlreadyExists(_)),
1285            }) if if_not_exists => {
1286                session.add_notice(AdapterNotice::ObjectAlreadyExists {
1287                    name,
1288                    ty: "cluster",
1289                });
1290                Ok(ExecuteResponse::CreatedCluster)
1291            }
1292            err => Err(err),
1293        })
1294    }
1295
1296    #[mz_ore::instrument(level = "debug")]
1297    async fn sequence_create_managed_cluster(
1298        &mut self,
1299        session: &Session,
1300        CreateClusterManagedPlan {
1301            availability_zones,
1302            compute,
1303            replication_factor,
1304            size,
1305            optimizer_feature_overrides: _,
1306            schedule: _,
1307            auto_scaling_strategy,
1308        }: CreateClusterManagedPlan,
1309        cluster_id: ClusterId,
1310        cluster_name: String,
1311        mut ops: Vec<catalog::Op>,
1312    ) -> Result<ExecuteResponse, AdapterError> {
1313        tracing::debug!("sequence_create_managed_cluster");
1314
1315        self.ensure_valid_azs(availability_zones.iter())?;
1316
1317        let role_id = session.role_metadata().current_role;
1318        self.catalog.ensure_valid_replica_size(
1319            &self
1320                .catalog()
1321                .get_role_allowed_cluster_sizes(&Some(role_id)),
1322            &size,
1323            false,
1324        )?;
1325        // A HYDRATION SIZE is validated like SIZE itself: it must name a real
1326        // replica size the session role may use. Without this, a typo would
1327        // fail invisibly at burst-arm time (the controller retrying every
1328        // tick), and a size-restricted role could burst at a size it may not
1329        // CREATE with.
1330        if let Some(on_hydration) = auto_scaling_strategy
1331            .as_ref()
1332            .and_then(|strategy| strategy.on_hydration.as_ref())
1333        {
1334            self.catalog.ensure_valid_replica_size(
1335                &self
1336                    .catalog()
1337                    .get_role_allowed_cluster_sizes(&Some(role_id)),
1338                &on_hydration.hydration_size,
1339                false,
1340            )?;
1341        }
1342
1343        // Eagerly validate the `max_replicas_per_cluster` limit.
1344        // `catalog_transact` will do this validation too, but allocating
1345        // replica IDs is expensive enough that we need to do this validation
1346        // before allocating replica IDs. See database-issues#6046.
1347        if cluster_id.is_user() {
1348            self.validate_resource_limit(
1349                0,
1350                i64::from(replication_factor),
1351                SystemVars::max_replicas_per_cluster,
1352                "cluster replica",
1353                MAX_REPLICAS_PER_CLUSTER.name(),
1354            )?;
1355        }
1356
1357        // Pre-allocate replica ids out-of-band via the durable allocator,
1358        // picking the id type from the owning cluster, so each replica's scoped
1359        // overrides can be folded into the create transaction below (the
1360        // overrides are keyed by the replica id). This mirrors how cluster and
1361        // item ids are allocated, so nothing allocates a replica id in-apply.
1362        let id_ts = self.get_catalog_write_ts().await;
1363        let replica_ids = self
1364            .catalog()
1365            .allocate_replica_ids(cluster_id, u64::from(replication_factor), id_ts)
1366            .await?;
1367
1368        let cluster_ctx = ClusterScopeContext {
1369            id: cluster_id.to_string(),
1370            name: cluster_name.clone(),
1371            is_builtin: cluster_id.is_system(),
1372        };
1373
1374        let mut replica_ctxs = Vec::new();
1375        for (replica_id, replica_name) in replica_ids
1376            .into_iter()
1377            .zip_eq((0..replication_factor).map(managed_cluster_replica_name))
1378        {
1379            let size_family = self.create_managed_cluster_replica_op(
1380                cluster_id,
1381                replica_id,
1382                replica_name.clone(),
1383                &compute,
1384                &size,
1385                &mut ops,
1386                if availability_zones.is_empty() {
1387                    None
1388                } else {
1389                    Some(availability_zones.as_ref())
1390                },
1391                false,
1392                *session.current_role_id(),
1393                ReplicaCreateDropReason::Manual,
1394            )?;
1395            replica_ctxs.push(ReplicaEvalContext {
1396                cluster_id,
1397                replica_id,
1398                cluster: cluster_ctx.clone(),
1399                replica: ReplicaScopeContext {
1400                    id: replica_id.to_string(),
1401                    name: replica_name,
1402                    is_builtin: cluster_id.is_system(),
1403                    size: size.clone(),
1404                    size_family,
1405                    cluster_id: cluster_id.to_string(),
1406                    cluster_name: cluster_name.clone(),
1407                },
1408            });
1409        }
1410
1411        // Fold the new cluster's cluster-coherent and the replicas' replica-local
1412        // scoped overrides into the create transaction. Folding (rather than a
1413        // post-transact resolve) makes the committed diff drive the
1414        // replica-scoped controller push before create_replica, which
1415        // render-frozen flags require, and gives the new cluster its optimizer
1416        // overrides for its first plan.
1417        let cluster_eval = ClusterEvalContext {
1418            cluster_id,
1419            cluster: cluster_ctx,
1420        };
1421        if let Some(scoped_op) = self.scoped_overrides_create_op(&[cluster_eval], &replica_ctxs) {
1422            ops.push(scoped_op);
1423        }
1424
1425        self.catalog_transact(Some(session), ops).await?;
1426
1427        Ok(ExecuteResponse::CreatedCluster)
1428    }
1429
1430    fn create_managed_cluster_replica_op(
1431        &self,
1432        cluster_id: ClusterId,
1433        replica_id: ReplicaId,
1434        name: String,
1435        compute: &mz_sql::plan::ComputeReplicaConfig,
1436        size: &String,
1437        ops: &mut Vec<Op>,
1438        azs: Option<&[String]>,
1439        pending: bool,
1440        owner_id: RoleId,
1441        reason: ReplicaCreateDropReason,
1442    ) -> Result<String, AdapterError> {
1443        let location = mz_catalog::durable::ReplicaLocation::Managed {
1444            // Concretized below from the cluster config; this intermediate value
1445            // is discarded, so the list is left empty here.
1446            availability_zones: Vec::new(),
1447            billed_as: None,
1448            internal: false,
1449            size: size.clone(),
1450            pending,
1451        };
1452
1453        let logging = if let Some(config) = compute.introspection {
1454            ReplicaLogging {
1455                log_logging: config.debugging,
1456                interval: Some(config.interval),
1457            }
1458        } else {
1459            ReplicaLogging::default()
1460        };
1461
1462        let config = ReplicaConfig {
1463            location: self.catalog().concretize_replica_location(
1464                location,
1465                &self
1466                    .catalog()
1467                    .get_role_allowed_cluster_sizes(&Some(owner_id)),
1468                azs,
1469                false,
1470            )?,
1471            compute: ComputeReplicaConfig {
1472                logging,
1473                arrangement_compression: compute.arrangement_compression,
1474            },
1475        };
1476
1477        // The caller pre-allocates `replica_id` out-of-band via the durable
1478        // allocator, so nothing allocates a replica id in-apply.
1479        //
1480        // Extract the size family before `config` moves into the op, for the
1481        // replica's scoped eval context.
1482        let size_family = match &config.location {
1483            ReplicaLocation::Managed(location) => location.allocation.family().to_string(),
1484            // A managed replica always concretizes to a managed location.
1485            ReplicaLocation::Unmanaged(_) => {
1486                unreachable!("managed cluster replica has a managed location")
1487            }
1488        };
1489
1490        ops.push(catalog::Op::CreateClusterReplica {
1491            cluster_id,
1492            replica_id,
1493            name,
1494            config,
1495            owner_id,
1496            reason,
1497        });
1498        Ok(size_family)
1499    }
1500
1501    fn ensure_valid_azs<'a, I: IntoIterator<Item = &'a String>>(
1502        &self,
1503        azs: I,
1504    ) -> Result<(), AdapterError> {
1505        let cat_azs = self.catalog().state().availability_zones();
1506        for az in azs.into_iter() {
1507            if !cat_azs.contains(az) {
1508                return Err(AdapterError::InvalidClusterReplicaAz {
1509                    az: az.to_string(),
1510                    expected: cat_azs.to_vec(),
1511                });
1512            }
1513        }
1514        Ok(())
1515    }
1516
1517    #[mz_ore::instrument(level = "debug")]
1518    async fn sequence_create_unmanaged_cluster(
1519        &mut self,
1520        session: &Session,
1521        CreateClusterUnmanagedPlan { replicas }: CreateClusterUnmanagedPlan,
1522        id: ClusterId,
1523        cluster_name: String,
1524        mut ops: Vec<catalog::Op>,
1525    ) -> Result<ExecuteResponse, AdapterError> {
1526        tracing::debug!("sequence_create_unmanaged_cluster");
1527
1528        self.ensure_valid_azs(replicas.iter().filter_map(|(_, r)| {
1529            if let mz_sql::plan::ReplicaConfig::Orchestrated {
1530                availability_zone: Some(az),
1531                ..
1532            } = &r
1533            {
1534                Some(az)
1535            } else {
1536                None
1537            }
1538        }))?;
1539
1540        // Eagerly validate the `max_replicas_per_cluster` limit.
1541        // `catalog_transact` will do this validation too, but allocating
1542        // replica IDs is expensive enough that we need to do this validation
1543        // before allocating replica IDs. See database-issues#6046.
1544        if id.is_user() {
1545            self.validate_resource_limit(
1546                0,
1547                i64::try_from(replicas.len()).unwrap_or(i64::MAX),
1548                SystemVars::max_replicas_per_cluster,
1549                "cluster replica",
1550                MAX_REPLICAS_PER_CLUSTER.name(),
1551            )?;
1552        }
1553
1554        // Pre-allocate replica ids out-of-band via the durable allocator,
1555        // picking the id type from the owning cluster, so each replica's scoped
1556        // overrides can be folded into the create transaction below. This
1557        // mirrors how cluster and item ids are allocated, so nothing allocates
1558        // a replica id in-apply.
1559        let id_ts = self.get_catalog_write_ts().await;
1560        let replica_ids = self
1561            .catalog()
1562            .allocate_replica_ids(id, u64::cast_from(replicas.len()), id_ts)
1563            .await?;
1564
1565        let cluster_ctx = ClusterScopeContext {
1566            id: id.to_string(),
1567            name: cluster_name.clone(),
1568            is_builtin: id.is_system(),
1569        };
1570        let mut replica_ctxs = Vec::new();
1571
1572        for (replica_id, (replica_name, replica_config)) in replica_ids.into_iter().zip_eq(replicas)
1573        {
1574            // If the AZ was not specified, choose one, round-robin, from the ones with
1575            // the lowest number of configured replicas for this cluster.
1576            let (compute, location) = match replica_config {
1577                mz_sql::plan::ReplicaConfig::Unorchestrated {
1578                    storagectl_addrs,
1579                    computectl_addrs,
1580                    compute,
1581                } => {
1582                    let location = mz_catalog::durable::ReplicaLocation::Unmanaged {
1583                        storagectl_addrs,
1584                        computectl_addrs,
1585                    };
1586                    (compute, location)
1587                }
1588                mz_sql::plan::ReplicaConfig::Orchestrated {
1589                    availability_zone,
1590                    billed_as,
1591                    compute,
1592                    internal,
1593                    size,
1594                } => {
1595                    // Only internal users have access to INTERNAL and BILLED AS
1596                    if !session.user().is_internal() && (internal || billed_as.is_some()) {
1597                        coord_bail!("cannot specify INTERNAL or BILLED AS as non-internal user")
1598                    }
1599                    // BILLED AS implies the INTERNAL flag.
1600                    if billed_as.is_some() && !internal {
1601                        coord_bail!("must specify INTERNAL when specifying BILLED AS");
1602                    }
1603
1604                    let location = mz_catalog::durable::ReplicaLocation::Managed {
1605                        // The user-pinned `AVAILABILITY ZONE`, if any, as a zero-
1606                        // or one-element list.
1607                        availability_zones: availability_zone.into_iter().collect(),
1608                        billed_as,
1609                        internal,
1610                        size: size.clone(),
1611                        pending: false,
1612                    };
1613                    (compute, location)
1614                }
1615            };
1616
1617            let logging = if let Some(config) = compute.introspection {
1618                ReplicaLogging {
1619                    log_logging: config.debugging,
1620                    interval: Some(config.interval),
1621                }
1622            } else {
1623                ReplicaLogging::default()
1624            };
1625
1626            let role_id = session.role_metadata().current_role;
1627            let config = ReplicaConfig {
1628                location: self.catalog().concretize_replica_location(
1629                    location,
1630                    &self
1631                        .catalog()
1632                        .get_role_allowed_cluster_sizes(&Some(role_id)),
1633                    None,
1634                    false,
1635                )?,
1636                compute: ComputeReplicaConfig {
1637                    logging,
1638                    arrangement_compression: compute.arrangement_compression,
1639                },
1640            };
1641
1642            // Only orchestrated (managed-location) replicas have a size and size
1643            // family, so only they carry replica-local overrides.
1644            if let ReplicaLocation::Managed(location) = &config.location {
1645                replica_ctxs.push(ReplicaEvalContext {
1646                    cluster_id: id,
1647                    replica_id,
1648                    cluster: cluster_ctx.clone(),
1649                    replica: ReplicaScopeContext {
1650                        id: replica_id.to_string(),
1651                        name: replica_name.clone(),
1652                        is_builtin: id.is_system(),
1653                        size: location.size.clone(),
1654                        size_family: location.allocation.family().to_string(),
1655                        cluster_id: id.to_string(),
1656                        cluster_name: cluster_name.clone(),
1657                    },
1658                });
1659            }
1660
1661            ops.push(catalog::Op::CreateClusterReplica {
1662                cluster_id: id,
1663                replica_id,
1664                name: replica_name.clone(),
1665                config,
1666                owner_id: *session.current_role_id(),
1667                reason: ReplicaCreateDropReason::Manual,
1668            });
1669        }
1670
1671        // Fold the new cluster's and replicas' scoped overrides into the create
1672        // transaction (see the managed path for rationale).
1673        let cluster_eval = ClusterEvalContext {
1674            cluster_id: id,
1675            cluster: cluster_ctx,
1676        };
1677        if let Some(scoped_op) = self.scoped_overrides_create_op(&[cluster_eval], &replica_ctxs) {
1678            ops.push(scoped_op);
1679        }
1680
1681        self.catalog_transact(Some(session), ops).await?;
1682
1683        Ok(ExecuteResponse::CreatedCluster)
1684    }
1685
1686    /// Returns the full names of all sources bound to `cluster` whose
1687    /// connections prefer to run on a single replica, so additional replicas
1688    /// do not make them more fault tolerant or increase their throughput.
1689    fn single_replica_source_names(&self, cluster: &Cluster) -> Vec<String> {
1690        cluster
1691            .bound_objects
1692            .iter()
1693            .filter_map(|id| {
1694                let entry = self.catalog().get_entry(id);
1695                let single_replica =
1696                    entry
1697                        .source()
1698                        .is_some_and(|source| match &source.data_source {
1699                            DataSourceDesc::Ingestion { desc, .. }
1700                            | DataSourceDesc::OldSyntaxIngestion { desc, .. } => {
1701                                desc.connection.prefers_single_replica()
1702                            }
1703                            _ => false,
1704                        });
1705                single_replica.then(|| {
1706                    self.catalog()
1707                        .resolve_full_name(entry.name(), None)
1708                        .to_string()
1709                })
1710            })
1711            .collect()
1712    }
1713
1714    /// The number of replicas `cluster` aims to run, for deciding whether to
1715    /// emit the single-replica-sources notice.
1716    ///
1717    /// For a managed cluster this is the replication factor, taking the target
1718    /// of an in-progress reconfiguration over the realized one, plus any
1719    /// INTERNAL or BILLED AS replicas, which are manually managed outside the
1720    /// replication-factor domain. Replicas belonging to a reconfiguration's
1721    /// hydrate-overlap are deliberately not counted: they replace the serving
1722    /// set at cut-over rather than adding to it. Counting the replication
1723    /// factor instead of replicas excludes them under both reconfiguration
1724    /// mechanisms, the legacy graceful alter (which marks them pending) and
1725    /// the cluster controller (which creates them as ordinary replicas of the
1726    /// target shape).
1727    fn notice_relevant_replica_count(&self, cluster: &Cluster) -> usize {
1728        match &cluster.config.variant {
1729            ClusterVariant::Managed(managed) => {
1730                let replication_factor = managed
1731                    .reconfiguration
1732                    .as_ref()
1733                    .filter(|record| record.is_in_progress())
1734                    .map_or(managed.replication_factor, |record| {
1735                        record.target.replication_factor
1736                    });
1737                let manual_replicas = cluster
1738                    .replicas()
1739                    .filter(|r| {
1740                        r.config.location.internal() || r.config.location.billed_as().is_some()
1741                    })
1742                    .count();
1743                usize::cast_from(replication_factor) + manual_replicas
1744            }
1745            ClusterVariant::Unmanaged => cluster.replicas().count(),
1746        }
1747    }
1748
1749    /// Emits a notice if `cluster` aims to run more than one replica while
1750    /// containing sources that run on only one replica. Call after a command
1751    /// that added a replica or such a source.
1752    ///
1753    /// `creating_source` names a source the current command is creating in
1754    /// `cluster`. It is included in the notice even when it is not yet visible
1755    /// in the catalog, which happens when the creation is staged in a DDL
1756    /// transaction that commits later.
1757    pub(crate) fn notify_single_replica_sources(
1758        &self,
1759        session: &Session,
1760        cluster: &Cluster,
1761        creating_source: Option<&QualifiedItemName>,
1762    ) {
1763        if self.notice_relevant_replica_count(cluster) <= 1 {
1764            return;
1765        }
1766        let mut sources = self.single_replica_source_names(cluster);
1767        if let Some(name) = creating_source {
1768            let full_name = self.catalog().resolve_full_name(name, None).to_string();
1769            if !sources.contains(&full_name) {
1770                sources.push(full_name);
1771            }
1772        }
1773        if !sources.is_empty() {
1774            session.add_notice(AdapterNotice::SingleReplicaSourcesOnMultiReplicaCluster {
1775                cluster: cluster.name.clone(),
1776                sources,
1777            });
1778        }
1779    }
1780
1781    #[mz_ore::instrument(level = "debug")]
1782    pub(crate) async fn sequence_create_cluster_replica(
1783        &mut self,
1784        session: &Session,
1785        CreateClusterReplicaPlan {
1786            name,
1787            cluster_id,
1788            config,
1789            if_not_exists,
1790        }: CreateClusterReplicaPlan,
1791    ) -> Result<ExecuteResponse, AdapterError> {
1792        // Choose default AZ if necessary
1793        let (compute, location) = match config {
1794            mz_sql::plan::ReplicaConfig::Unorchestrated {
1795                storagectl_addrs,
1796                computectl_addrs,
1797                compute,
1798            } => {
1799                let location = mz_catalog::durable::ReplicaLocation::Unmanaged {
1800                    storagectl_addrs,
1801                    computectl_addrs,
1802                };
1803                (compute, location)
1804            }
1805            mz_sql::plan::ReplicaConfig::Orchestrated {
1806                availability_zone,
1807                billed_as,
1808                compute,
1809                internal,
1810                size,
1811            } => {
1812                let availability_zone = match availability_zone {
1813                    Some(az) => {
1814                        self.ensure_valid_azs([&az])?;
1815                        Some(az)
1816                    }
1817                    None => None,
1818                };
1819                let location = mz_catalog::durable::ReplicaLocation::Managed {
1820                    // The user-pinned `AVAILABILITY ZONE`, if any, as a zero- or
1821                    // one-element list.
1822                    availability_zones: availability_zone.into_iter().collect(),
1823                    billed_as,
1824                    internal,
1825                    size,
1826                    pending: false,
1827                };
1828                (compute, location)
1829            }
1830        };
1831
1832        let logging = if let Some(config) = compute.introspection {
1833            ReplicaLogging {
1834                log_logging: config.debugging,
1835                interval: Some(config.interval),
1836            }
1837        } else {
1838            ReplicaLogging::default()
1839        };
1840
1841        let role_id = session.role_metadata().current_role;
1842        let config = ReplicaConfig {
1843            location: self.catalog().concretize_replica_location(
1844                location,
1845                &self
1846                    .catalog()
1847                    .get_role_allowed_cluster_sizes(&Some(role_id)),
1848                // Planning ensures all replicas in this codepath
1849                // are unmanaged.
1850                None,
1851                false,
1852            )?,
1853            compute: ComputeReplicaConfig {
1854                logging,
1855                arrangement_compression: compute.arrangement_compression,
1856            },
1857        };
1858
1859        let cluster = self.catalog().get_cluster(cluster_id);
1860
1861        if let ReplicaLocation::Managed(ManagedReplicaLocation {
1862            internal,
1863            billed_as,
1864            ..
1865        }) = &config.location
1866        {
1867            // Only internal users have access to INTERNAL and BILLED AS
1868            if !session.user().is_internal() && (*internal || billed_as.is_some()) {
1869                coord_bail!("cannot specify INTERNAL or BILLED AS as non-internal user")
1870            }
1871            // Managed clusters require the INTERNAL flag.
1872            if cluster.is_managed() && !*internal {
1873                coord_bail!("must specify INTERNAL when creating a replica in a managed cluster");
1874            }
1875            // BILLED AS implies the INTERNAL flag.
1876            if billed_as.is_some() && !*internal {
1877                coord_bail!("must specify INTERNAL when specifying BILLED AS");
1878            }
1879        }
1880
1881        // Replicas have the same owner as their cluster. Extract the owned
1882        // cluster info we need before the borrow is dropped for the awaits below.
1883        let owner_id = cluster.owner_id();
1884
1885        let cluster_name = cluster.name.clone();
1886        let is_builtin = cluster_id.is_system();
1887        // A replica name is only unique within its cluster, so the notice on the
1888        // `IF NOT EXISTS` path below has to name both.
1889        let qualified_name = format!("{cluster_name}.{name}");
1890
1891        // Pre-allocate the replica id out-of-band via the durable allocator,
1892        // picking the id type from the target cluster, which may be a system
1893        // cluster, so the replica's scoped overrides can be folded into the same
1894        // transaction. The overrides are keyed by replica id, and the
1895        // replica-scoped controller push must run before `create_replica`. This
1896        // mirrors how cluster and item ids are allocated, so nothing allocates a
1897        // replica id in-apply.
1898        let id_ts = self.get_catalog_write_ts().await;
1899        let replica_id = self
1900            .catalog()
1901            .allocate_replica_ids(cluster_id, 1, id_ts)
1902            .await?
1903            .into_element();
1904
1905        // Build the replica's eval context from the plan before `config` moves
1906        // into the op. Only managed replicas have a size (and size family).
1907        let replica_ctx = match &config.location {
1908            ReplicaLocation::Managed(location) => Some(ReplicaEvalContext {
1909                cluster_id,
1910                replica_id,
1911                cluster: ClusterScopeContext {
1912                    id: cluster_id.to_string(),
1913                    name: cluster_name.clone(),
1914                    is_builtin,
1915                },
1916                replica: ReplicaScopeContext {
1917                    id: replica_id.to_string(),
1918                    name: name.to_string(),
1919                    is_builtin,
1920                    size: location.size.clone(),
1921                    size_family: location.allocation.family().to_string(),
1922                    cluster_id: cluster_id.to_string(),
1923                    cluster_name,
1924                },
1925            }),
1926            ReplicaLocation::Unmanaged(_) => None,
1927        };
1928
1929        let mut ops = vec![catalog::Op::CreateClusterReplica {
1930            cluster_id,
1931            replica_id,
1932            name: name.clone(),
1933            config,
1934            owner_id,
1935            reason: ReplicaCreateDropReason::Manual,
1936        }];
1937
1938        // The cluster already exists, so only this replica's local overrides
1939        // need resolving. Fold them into the create transaction so the
1940        // replica-scoped push runs before `create_replica`.
1941        if let Some(replica_ctx) = replica_ctx {
1942            if let Some(scoped_op) = self.scoped_overrides_create_op(&[], &[replica_ctx]) {
1943                ops.push(scoped_op);
1944            }
1945        }
1946
1947        match self.catalog_transact(Some(session), ops).await {
1948            Ok(()) => {
1949                // The commit made the new replica visible in the catalog, so
1950                // the check sees the updated replica count.
1951                self.notify_single_replica_sources(
1952                    session,
1953                    self.catalog().get_cluster(cluster_id),
1954                    None,
1955                );
1956                Ok(ExecuteResponse::CreatedClusterReplica)
1957            }
1958            Err(AdapterError::Catalog(mz_catalog::memory::error::Error {
1959                kind: ErrorKind::Sql(CatalogError::DuplicateReplica(_, _)),
1960            })) if if_not_exists => {
1961                session.add_notice(AdapterNotice::ObjectAlreadyExists {
1962                    name: qualified_name,
1963                    ty: "cluster replica",
1964                });
1965                Ok(ExecuteResponse::CreatedClusterReplica)
1966            }
1967            Err(err) => Err(err),
1968        }
1969    }
1970
1971    /// When this is called by the automated cluster scheduling, `scheduling_decision_reason` should
1972    /// contain information on why is a cluster being turned On/Off. It will be forwarded to the
1973    /// `details` field of the audit log event that records creating or dropping replicas.
1974    ///
1975    /// # Panics
1976    ///
1977    /// Panics if the identified cluster is not a managed cluster.
1978    /// Panics if `new_config` is not a configuration for a managed cluster.
1979    pub(crate) async fn sequence_alter_cluster_managed_to_managed(
1980        &mut self,
1981        session: Option<&Session>,
1982        cluster_id: ClusterId,
1983        new_config: ClusterConfig,
1984        reason: ReplicaCreateDropReason,
1985        strategy: AlterClusterPlanStrategy,
1986    ) -> Result<NeedsFinalization, AdapterError> {
1987        let cluster = self.catalog.get_cluster(cluster_id);
1988        let name = cluster.name().to_string();
1989        let owner_id = cluster.owner_id();
1990
1991        let mut ops = vec![];
1992        let mut finalization_needed = NeedsFinalization::No;
1993
1994        let ClusterVariant::Managed(ClusterVariantManaged {
1995            size,
1996            availability_zones,
1997            logging,
1998            arrangement_compression,
1999            replication_factor,
2000            optimizer_feature_overrides: _,
2001            schedule: _,
2002            auto_scaling_strategy,
2003            reconfiguration,
2004            burst: _,
2005        }) = &cluster.config.variant
2006        else {
2007            panic!("expected existing managed cluster config");
2008        };
2009        // Clone the existing managed config out of the cluster so the immutable
2010        // catalog borrow can be released before the out-of-band replica id
2011        // allocation below, which needs mutable access to self.
2012        let size = size.clone();
2013        let availability_zones = availability_zones.clone();
2014        let logging = logging.clone();
2015        let arrangement_compression = *arrangement_compression;
2016        let replication_factor = *replication_factor;
2017        let ClusterVariant::Managed(new_managed) = &new_config.variant else {
2018            panic!("expected new managed cluster config");
2019        };
2020        let ClusterVariantManaged {
2021            size: new_size,
2022            replication_factor: new_replication_factor,
2023            availability_zones: new_availability_zones,
2024            logging: new_logging,
2025            arrangement_compression: new_arrangement_compression,
2026            optimizer_feature_overrides: _,
2027            schedule: _,
2028            auto_scaling_strategy: new_auto_scaling_strategy,
2029            reconfiguration: _,
2030            burst: _,
2031        } = new_managed;
2032
2033        let role_id = session.map(|s| s.role_metadata().current_role);
2034        self.catalog.ensure_valid_replica_size(
2035            &self.catalog().get_role_allowed_cluster_sizes(&role_id),
2036            new_size,
2037            false,
2038        )?;
2039        // A newly set (or changed) AUTO SCALING STRATEGY gets its HYDRATION
2040        // SIZE validated like SIZE itself: it must name a real replica size the
2041        // session role may use. Only a changed strategy is checked, so an
2042        // existing policy does not block unrelated ALTERs if the size
2043        // allow-list later shrinks (matching how SIZE itself behaves).
2044        if new_auto_scaling_strategy != auto_scaling_strategy {
2045            if let Some(on_hydration) = new_auto_scaling_strategy
2046                .as_ref()
2047                .and_then(|strategy| strategy.on_hydration.as_ref())
2048            {
2049                self.catalog.ensure_valid_replica_size(
2050                    &self.catalog().get_role_allowed_cluster_sizes(&role_id),
2051                    &on_hydration.hydration_size,
2052                    false,
2053                )?;
2054                // The planner validated the hydration size against the
2055                // *realized* SIZE only. An in-flight reconfiguration will cut
2056                // the realized SIZE over to its target, so also reject equality
2057                // with that target. Letting it through would end the reshape
2058                // with a no-op burst shape and a stored statement that fails
2059                // its own re-plan.
2060                if reconfiguration.as_ref().is_some_and(|record| {
2061                    record.is_in_progress() && record.target.size == on_hydration.hydration_size
2062                }) {
2063                    coord_bail!(
2064                        "HYDRATION SIZE must differ from the target SIZE \
2065                         ('{}') of the in-progress cluster resize",
2066                        on_hydration.hydration_size
2067                    );
2068                }
2069            }
2070        }
2071
2072        // check for active updates
2073        if cluster.replicas().any(|r| r.config.location.pending()) {
2074            return Err(AlterClusterWhilePendingReplicas);
2075        }
2076
2077        // Resolve existing replica ids by name before releasing the catalog
2078        // borrow, so the drop branches below can build their ops without it.
2079        let replica_id_by_name: BTreeMap<String, ReplicaId> = cluster
2080            .replicas()
2081            .map(|r| (r.name.clone(), r.replica_id))
2082            .collect();
2083        // The cluster's observed owned replica set, with the same exclusions
2084        // as the controller's ownership test: internal and billed-as replicas
2085        // are manually managed, pending ones belong to an in-flight
2086        // reconfiguration (rejected above, so none exist here).
2087        let owned_replica_ids: Vec<ReplicaId> = cluster
2088            .replicas()
2089            .filter(|r| {
2090                !r.config.location.internal()
2091                    && r.config.location.billed_as().is_none()
2092                    && !r.config.location.pending()
2093            })
2094            .map(|r| r.replica_id)
2095            .collect();
2096
2097        let compute = mz_sql::plan::ComputeReplicaConfig {
2098            introspection: new_logging
2099                .interval
2100                .map(|interval| ComputeReplicaIntrospectionConfig {
2101                    debugging: new_logging.log_logging,
2102                    interval,
2103                }),
2104            arrangement_compression: *new_arrangement_compression,
2105        };
2106
2107        // Eagerly validate the `max_replicas_per_cluster` limit.
2108        // `catalog_transact` will do this validation too, but allocating
2109        // replica IDs is expensive enough that we need to do this validation
2110        // before allocating replica IDs. See database-issues#6046.
2111        if *new_replication_factor > replication_factor {
2112            if cluster_id.is_user() {
2113                self.validate_resource_limit(
2114                    usize::cast_from(replication_factor),
2115                    i64::from(*new_replication_factor) - i64::from(replication_factor),
2116                    SystemVars::max_replicas_per_cluster,
2117                    "cluster replica",
2118                    MAX_REPLICAS_PER_CLUSTER.name(),
2119                )?;
2120            }
2121        }
2122
2123        // The controller owns the replica set of every managed cluster, so a
2124        // non-record change reaching this path is replication-factor only.
2125        // Config-shape changes (size/logging/AZ) are reshaped into a durable
2126        // reconfiguration record before they get here. The controller reconciles
2127        // the replica set to the realized config's new count on its next tick, so
2128        // we update only the realized config and emit no create/drop here. Doing
2129        // both fights the controller. It derives replica names from the observed
2130        // set, so an adapter create by canonical `rN` can collide with a
2131        // controller-chosen name, and an adapter drop by canonical `rN` can miss a
2132        // churned one.
2133        //
2134        // The direct create/drop branches below are unreachable while this holds.
2135        // They go together with the staged reconfiguration machine.
2136        let controller_owns = true;
2137
2138        // Count exactly as many replica ids as the branches below consume. The
2139        // config-changed branches recreate all replicas. A pure scale-up creates
2140        // only the delta. Scale-down and no-op create none. A controller-owned
2141        // alter emits no create/drop at all, so it must not allocate. Allocating
2142        // there burns those ids durably and throws them away. The controller
2143        // allocates its own when it materializes the change.
2144        let config_changed = new_managed.replica_config_shape()
2145            != ManagedReplicaConfigShape::new(
2146                &size,
2147                &availability_zones,
2148                &logging,
2149                arrangement_compression,
2150            );
2151        let needed_replica_ids = if controller_owns {
2152            0
2153        } else if config_changed {
2154            *new_replication_factor
2155        } else if *new_replication_factor > replication_factor {
2156            *new_replication_factor - replication_factor
2157        } else {
2158            0
2159        };
2160        // Allocate the replica ids out-of-band via the durable allocator, only
2161        // after the eager limit validation above so a rejected alter allocates
2162        // nothing. Pick the id type from the target cluster, which may be a
2163        // system cluster. This mirrors how cluster and item ids are allocated,
2164        // so nothing allocates a replica id in-apply. Fetch the catalog write
2165        // timestamp lazily here, since it needs mutable access to self (the
2166        // cluster borrow above is already released) and scale-down, no-op, and
2167        // automated scheduling turn-off alters must not pay an oracle
2168        // round-trip just to allocate nothing.
2169        let mut new_replica_ids = if needed_replica_ids > 0 {
2170            let id_ts = self.get_catalog_write_ts().await;
2171            let ids = self
2172                .catalog()
2173                .allocate_replica_ids(cluster_id, u64::from(needed_replica_ids), id_ts)
2174                .await?;
2175            ids.into_iter()
2176        } else {
2177            Vec::<ReplicaId>::new().into_iter()
2178        };
2179
2180        // Collect an eval context for each replica recreated below, so the alter
2181        // transaction folds the replicas' replica-scoped overrides the same way
2182        // the create paths do. ALTER CLUSTER SET (SIZE ...) to a different size
2183        // family flips size-family-keyed render-frozen flags, so the override
2184        // must reach the controller before the recreated replica renders. Only
2185        // the replica scope is folded. The cluster already exists and its
2186        // cluster-scoped overrides are unaffected by this alter.
2187        let cluster_ctx = ClusterScopeContext {
2188            id: cluster_id.to_string(),
2189            name: name.clone(),
2190            is_builtin: cluster_id.is_system(),
2191        };
2192        let mut replica_ctxs = Vec::new();
2193
2194        if controller_owns {
2195            // Defer all replica create/drop to the controller. Only the realized
2196            // config update below is applied here. The target must still be
2197            // valid: the controller creates replicas from the realized config
2198            // without re-validating availability zones, so an invalid pool
2199            // written here would produce an unplaceable replica.
2200            if config_changed {
2201                self.ensure_valid_azs(new_availability_zones.iter())?;
2202            }
2203        } else if config_changed {
2204            self.ensure_valid_azs(new_availability_zones.iter())?;
2205            // If we're not doing a zero-downtime reconfig tear down all
2206            // replicas, create new ones else create the pending replicas and
2207            // return early asking for finalization
2208            match strategy {
2209                AlterClusterPlanStrategy::None => {
2210                    // Names can drift from the canonical `r1..rN` while the
2211                    // controller owns the set (its name generator avoids
2212                    // observed names), so a factor-derived name list can miss
2213                    // replicas after a break-glass handoff. This branch
2214                    // recreates the entire replica set anyway, so dropping the
2215                    // observed owned set by id closes that. In the pure
2216                    // canonical world the two sets are identical.
2217                    let replica_ids_and_reasons = owned_replica_ids
2218                        .iter()
2219                        .map(|replica_id| {
2220                            catalog::DropObjectInfo::ClusterReplica((
2221                                cluster_id,
2222                                *replica_id,
2223                                reason.clone(),
2224                            ))
2225                        })
2226                        .collect();
2227                    ops.push(catalog::Op::DropObjects(replica_ids_and_reasons));
2228                    for replica_name in
2229                        (0..*new_replication_factor).map(managed_cluster_replica_name)
2230                    {
2231                        // The replica id is pre-allocated above like the create
2232                        // paths so its scoped overrides can be folded below.
2233                        let replica_id = new_replica_ids
2234                            .next()
2235                            .expect("pre-allocated enough replica ids");
2236                        let size_family = self.create_managed_cluster_replica_op(
2237                            cluster_id,
2238                            replica_id,
2239                            replica_name.clone(),
2240                            &compute,
2241                            new_size,
2242                            &mut ops,
2243                            Some(new_availability_zones.as_ref()),
2244                            false,
2245                            owner_id,
2246                            reason.clone(),
2247                        )?;
2248                        replica_ctxs.push(ReplicaEvalContext {
2249                            cluster_id,
2250                            replica_id,
2251                            cluster: cluster_ctx.clone(),
2252                            replica: ReplicaScopeContext {
2253                                id: replica_id.to_string(),
2254                                name: replica_name,
2255                                is_builtin: cluster_id.is_system(),
2256                                size: new_size.clone(),
2257                                size_family,
2258                                cluster_id: cluster_id.to_string(),
2259                                cluster_name: cluster_ctx.name.clone(),
2260                            },
2261                        });
2262                    }
2263                }
2264                AlterClusterPlanStrategy::For(_) | AlterClusterPlanStrategy::UntilReady { .. } => {
2265                    for replica_name in
2266                        (0..*new_replication_factor).map(managed_cluster_replica_name)
2267                    {
2268                        let replica_name = format!("{replica_name}{PENDING_REPLICA_SUFFIX}");
2269                        let replica_id = new_replica_ids
2270                            .next()
2271                            .expect("pre-allocated enough replica ids");
2272                        let size_family = self.create_managed_cluster_replica_op(
2273                            cluster_id,
2274                            replica_id,
2275                            replica_name.clone(),
2276                            &compute,
2277                            new_size,
2278                            &mut ops,
2279                            Some(new_availability_zones.as_ref()),
2280                            true,
2281                            owner_id,
2282                            reason.clone(),
2283                        )?;
2284                        replica_ctxs.push(ReplicaEvalContext {
2285                            cluster_id,
2286                            replica_id,
2287                            cluster: cluster_ctx.clone(),
2288                            replica: ReplicaScopeContext {
2289                                id: replica_id.to_string(),
2290                                name: replica_name,
2291                                is_builtin: cluster_id.is_system(),
2292                                size: new_size.clone(),
2293                                size_family,
2294                                cluster_id: cluster_id.to_string(),
2295                                cluster_name: cluster_ctx.name.clone(),
2296                            },
2297                        });
2298                    }
2299                    finalization_needed = NeedsFinalization::Yes;
2300                }
2301            }
2302        } else if *new_replication_factor < replication_factor {
2303            // Adjust replica count down
2304            let replica_ids = (*new_replication_factor..replication_factor)
2305                .map(managed_cluster_replica_name)
2306                .filter_map(|name| replica_id_by_name.get(&name).copied())
2307                .map(|replica_id| {
2308                    catalog::DropObjectInfo::ClusterReplica((
2309                        cluster_id,
2310                        replica_id,
2311                        reason.clone(),
2312                    ))
2313                })
2314                .collect();
2315            ops.push(catalog::Op::DropObjects(replica_ids));
2316        } else if *new_replication_factor > replication_factor {
2317            // Adjust replica count up
2318            for replica_name in
2319                (replication_factor..*new_replication_factor).map(managed_cluster_replica_name)
2320            {
2321                let replica_id = new_replica_ids
2322                    .next()
2323                    .expect("pre-allocated enough replica ids");
2324                let size_family = self.create_managed_cluster_replica_op(
2325                    cluster_id,
2326                    replica_id,
2327                    replica_name.clone(),
2328                    &compute,
2329                    new_size,
2330                    &mut ops,
2331                    // AVAILABILITY ZONES hasn't changed, so existing replicas don't need to be
2332                    // rescheduled.
2333                    Some(new_availability_zones.as_ref()),
2334                    false,
2335                    owner_id,
2336                    reason.clone(),
2337                )?;
2338                replica_ctxs.push(ReplicaEvalContext {
2339                    cluster_id,
2340                    replica_id,
2341                    cluster: cluster_ctx.clone(),
2342                    replica: ReplicaScopeContext {
2343                        id: replica_id.to_string(),
2344                        name: replica_name,
2345                        is_builtin: cluster_id.is_system(),
2346                        size: new_size.clone(),
2347                        size_family,
2348                        cluster_id: cluster_id.to_string(),
2349                        cluster_name: cluster_ctx.name.clone(),
2350                    },
2351                });
2352            }
2353        }
2354
2355        // If finalization is needed, finalization should update the cluster
2356        // config. Otherwise the config write happens here. A record still in
2357        // progress belongs to a live, converging reconfiguration this write
2358        // didn't touch, so carry it through untouched.
2359        match finalization_needed {
2360            NeedsFinalization::No => {
2361                let mut new_config = new_config;
2362                let reconfiguration_audit = if controller_owns {
2363                    None
2364                } else {
2365                    cancel_carried_reconfiguration(&mut new_config)
2366                };
2367                ops.push(catalog::Op::UpdateClusterConfig {
2368                    id: cluster_id,
2369                    name: name.clone(),
2370                    config: new_config,
2371                    reconfiguration_audit,
2372                    burst_audit: None,
2373                });
2374            }
2375            NeedsFinalization::Yes => {}
2376        }
2377
2378        // Fold the recreated replicas' replica-scoped overrides into the same
2379        // transaction, so the committed diff drives the replica-scoped controller
2380        // push before create_replica. Render-frozen flags (chosen at
2381        // arrangement-build time) require the override to land before the replica
2382        // renders. Scale-down and no-op alters recreate no replicas, so this is
2383        // empty and folds nothing.
2384        if let Some(scoped_op) = self.scoped_overrides_create_op(&[], &replica_ctxs) {
2385            ops.push(scoped_op);
2386        }
2387
2388        self.catalog_transact(session, ops).await?;
2389        Ok(finalization_needed)
2390    }
2391
2392    /// # Panics
2393    ///
2394    /// Panics if `new_config` is not a configuration for a managed cluster.
2395    async fn sequence_alter_cluster_unmanaged_to_managed(
2396        &mut self,
2397        session: &Session,
2398        cluster_id: ClusterId,
2399        mut new_config: ClusterConfig,
2400        options: PlanClusterOption,
2401    ) -> Result<(), AdapterError> {
2402        let cluster = self.catalog.get_cluster(cluster_id);
2403        let cluster_name = cluster.name().to_string();
2404
2405        let ClusterVariant::Managed(ClusterVariantManaged {
2406            size: new_size,
2407            replication_factor: new_replication_factor,
2408            availability_zones: new_availability_zones,
2409            logging: _,
2410            arrangement_compression: _,
2411            optimizer_feature_overrides: _,
2412            schedule: _,
2413            auto_scaling_strategy: _,
2414            reconfiguration: _,
2415            burst: _,
2416        }) = &mut new_config.variant
2417        else {
2418            panic!("expected new managed cluster config");
2419        };
2420
2421        // Validate replication factor parameter
2422        let user_replica_count = cluster
2423            .user_replicas()
2424            .count()
2425            .try_into()
2426            .expect("must_fit");
2427        match options.replication_factor {
2428            AlterOptionParameter::Set(_) => {
2429                // Validate that the replication factor matches the current length only if specified.
2430                if user_replica_count != *new_replication_factor {
2431                    coord_bail!(
2432                        "REPLICATION FACTOR {new_replication_factor} does not match number of replicas ({user_replica_count})"
2433                    );
2434                }
2435            }
2436            _ => {
2437                *new_replication_factor = user_replica_count;
2438            }
2439        }
2440
2441        let mut names = BTreeSet::new();
2442        let mut sizes = BTreeSet::new();
2443
2444        self.ensure_valid_azs(new_availability_zones.iter())?;
2445
2446        // Validate per-replica configuration
2447        for replica in cluster.user_replicas() {
2448            names.insert(replica.name.clone());
2449            match &replica.config.location {
2450                ReplicaLocation::Unmanaged(_) => coord_bail!(
2451                    "Cannot convert unmanaged cluster with unmanaged replicas to managed cluster"
2452                ),
2453                ReplicaLocation::Managed(location) => {
2454                    sizes.insert(location.size.clone());
2455
2456                    // An unmanaged cluster's replica carries its single
2457                    // user-pinned AZ (if any) as the sole entry; every pin must
2458                    // fall within the managed cluster's `AVAILABILITY ZONES`.
2459                    for az in &location.availability_zones {
2460                        if !new_availability_zones.contains(az) {
2461                            coord_bail!(
2462                                "unmanaged replica has availability zone {az} which is not \
2463                                in managed {new_availability_zones:?}"
2464                            )
2465                        }
2466                    }
2467                }
2468            }
2469        }
2470
2471        if sizes.is_empty() {
2472            assert!(
2473                cluster.user_replicas().next().is_none(),
2474                "Cluster should not have replicas"
2475            );
2476            // We didn't collect any size, so the user has to name it.
2477            match &options.size {
2478                AlterOptionParameter::Reset | AlterOptionParameter::Unchanged => {
2479                    coord_bail!("Missing SIZE for empty cluster")
2480                }
2481                AlterOptionParameter::Set(_) => {} // Was set within the calling function.
2482            }
2483        } else if sizes.len() == 1 {
2484            let size = sizes.into_iter().next().expect("must exist");
2485            match &options.size {
2486                AlterOptionParameter::Set(sz) if *sz != size => {
2487                    coord_bail!("Cluster replicas of size {size} do not match expected SIZE {sz}");
2488                }
2489                _ => *new_size = size,
2490            }
2491        } else {
2492            let formatted = sizes
2493                .iter()
2494                .map(String::as_str)
2495                .collect::<Vec<_>>()
2496                .join(", ");
2497            coord_bail!(
2498                "Cannot convert unmanaged cluster to managed, non-unique replica sizes: {formatted}"
2499            );
2500        }
2501
2502        for i in 0..*new_replication_factor {
2503            let name = managed_cluster_replica_name(i);
2504            names.remove(&name);
2505        }
2506        if !names.is_empty() {
2507            let formatted = names
2508                .iter()
2509                .map(String::as_str)
2510                .collect::<Vec<_>>()
2511                .join(", ");
2512            coord_bail!(
2513                "Cannot convert unmanaged cluster to managed, invalid replica names: {formatted}"
2514            );
2515        }
2516
2517        let ops = vec![catalog::Op::UpdateClusterConfig {
2518            id: cluster_id,
2519            name: cluster_name,
2520            config: new_config,
2521            reconfiguration_audit: None,
2522            burst_audit: None,
2523        }];
2524
2525        self.catalog_transact(Some(session), ops).await?;
2526        Ok(())
2527    }
2528
2529    async fn sequence_alter_cluster_managed_to_unmanaged(
2530        &mut self,
2531        session: &Session,
2532        cluster_id: ClusterId,
2533        new_config: ClusterConfig,
2534    ) -> Result<(), AdapterError> {
2535        let cluster = self.catalog().get_cluster(cluster_id);
2536
2537        // The unmanaged variant has no reconfiguration field, so converting
2538        // would silently drop an in-progress record with no terminal status
2539        // and no audit event, and strand any overlap replicas the controller
2540        // already created. Refuse instead: the user can cancel (ALTER back to
2541        // the realized size) or wait for the record to settle first.
2542        if let ClusterVariant::Managed(managed) = &cluster.config.variant {
2543            if managed
2544                .reconfiguration
2545                .as_ref()
2546                .is_some_and(|record| record.is_in_progress())
2547            {
2548                return Err(AdapterError::AlterClusterUnmanagedWhileReconfiguring);
2549            }
2550            // Same hazard for an in-flight burst: the unmanaged variant has no
2551            // burst field either, so converting would drop the record with no
2552            // `Finished` audit event and strand the billed burst replica as an
2553            // ordinary unmanaged replica nothing ever tears down. Absence of a
2554            // record means the burst has settled, so no in-progress check is
2555            // needed.
2556            if managed.burst.is_some() {
2557                return Err(AdapterError::AlterClusterUnmanagedWhileBursting);
2558            }
2559        }
2560
2561        let ops = vec![catalog::Op::UpdateClusterConfig {
2562            id: cluster_id,
2563            name: cluster.name().to_string(),
2564            config: new_config,
2565            reconfiguration_audit: None,
2566            burst_audit: None,
2567        }];
2568
2569        self.catalog_transact(Some(session), ops).await?;
2570        Ok(())
2571    }
2572
2573    async fn sequence_alter_cluster_unmanaged_to_unmanaged(
2574        &mut self,
2575        session: &Session,
2576        cluster_id: ClusterId,
2577        new_config: ClusterConfig,
2578        replicas: AlterOptionParameter<Vec<(String, mz_sql::plan::ReplicaConfig)>>,
2579    ) -> Result<(), AdapterError> {
2580        if !matches!(replicas, AlterOptionParameter::Unchanged) {
2581            coord_bail!("Cannot alter replicas in unmanaged cluster");
2582        }
2583
2584        let cluster = self.catalog().get_cluster(cluster_id);
2585
2586        let ops = vec![catalog::Op::UpdateClusterConfig {
2587            id: cluster_id,
2588            name: cluster.name().to_string(),
2589            config: new_config,
2590            reconfiguration_audit: None,
2591            burst_audit: None,
2592        }];
2593
2594        self.catalog_transact(Some(session), ops).await?;
2595        Ok(())
2596    }
2597
2598    pub(crate) async fn sequence_alter_cluster_rename(
2599        &mut self,
2600        ctx: &mut ExecuteContext,
2601        AlterClusterRenamePlan { id, name, to_name }: AlterClusterRenamePlan,
2602    ) -> Result<ExecuteResponse, AdapterError> {
2603        let op = Op::RenameCluster {
2604            id,
2605            name,
2606            to_name,
2607            check_reserved_names: true,
2608        };
2609        match self
2610            .catalog_transact_with_ddl_transaction(ctx, vec![op], |_, _| Box::pin(async {}))
2611            .await
2612        {
2613            Ok(()) => Ok(ExecuteResponse::AlteredObject(ObjectType::Cluster)),
2614            Err(err) => Err(err),
2615        }
2616    }
2617
2618    pub(crate) async fn sequence_alter_cluster_swap(
2619        &mut self,
2620        ctx: &mut ExecuteContext,
2621        AlterClusterSwapPlan {
2622            id_a,
2623            id_b,
2624            name_a,
2625            name_b,
2626            name_temp,
2627        }: AlterClusterSwapPlan,
2628    ) -> Result<ExecuteResponse, AdapterError> {
2629        let op_a = Op::RenameCluster {
2630            id: id_a,
2631            name: name_a.clone(),
2632            to_name: name_temp.clone(),
2633            check_reserved_names: false,
2634        };
2635        let op_b = Op::RenameCluster {
2636            id: id_b,
2637            name: name_b.clone(),
2638            to_name: name_a,
2639            check_reserved_names: false,
2640        };
2641        let op_temp = Op::RenameCluster {
2642            id: id_a,
2643            name: name_temp,
2644            to_name: name_b,
2645            check_reserved_names: false,
2646        };
2647
2648        match self
2649            .catalog_transact_with_ddl_transaction(ctx, vec![op_a, op_b, op_temp], |_, _| {
2650                Box::pin(async {})
2651            })
2652            .await
2653        {
2654            Ok(()) => Ok(ExecuteResponse::AlteredObject(ObjectType::Cluster)),
2655            Err(err) => Err(err),
2656        }
2657    }
2658
2659    pub(crate) async fn sequence_alter_cluster_replica_rename(
2660        &mut self,
2661        session: &Session,
2662        AlterClusterReplicaRenamePlan {
2663            cluster_id,
2664            replica_id,
2665            name,
2666            to_name,
2667        }: AlterClusterReplicaRenamePlan,
2668    ) -> Result<ExecuteResponse, AdapterError> {
2669        let op = catalog::Op::RenameClusterReplica {
2670            cluster_id,
2671            replica_id,
2672            name,
2673            to_name,
2674        };
2675        match self.catalog_transact(Some(session), vec![op]).await {
2676            Ok(()) => Ok(ExecuteResponse::AlteredObject(ObjectType::ClusterReplica)),
2677            Err(err) => Err(err),
2678        }
2679    }
2680
2681    /// Convert a [`AlterSetClusterPlan`] to a sequence of catalog operators and adjust state.
2682    pub(crate) async fn sequence_alter_set_cluster(
2683        &self,
2684        _session: &Session,
2685        AlterSetClusterPlan { id, set_cluster: _ }: AlterSetClusterPlan,
2686    ) -> Result<ExecuteResponse, AdapterError> {
2687        // TODO: This function needs to be implemented.
2688
2689        // Satisfy Clippy that this is an async func.
2690        async {}.await;
2691        let entry = self.catalog().get_entry(&id);
2692        match entry.item().typ() {
2693            _ => {
2694                // Unexpected; planner permitted unsupported plan.
2695                Err(AdapterError::Unsupported("ALTER SET CLUSTER"))
2696            }
2697        }
2698    }
2699}
2700
2701/// Which reconfiguration-target dimensions an `ALTER` left unset (`Unchanged`).
2702/// Drives [`fold_reconfiguration_target`]. Logging is two sub-dimensions
2703/// because `INTROSPECTION DEBUGGING` and `INTROSPECTION INTERVAL` are
2704/// independently alterable.
2705struct ReconfigurationDimensionsUnchanged {
2706    size: bool,
2707    replication_factor: bool,
2708    availability_zones: bool,
2709    log_logging: bool,
2710    interval: bool,
2711    arrangement_compression: bool,
2712}
2713
2714/// Retains a stale in-progress reconfiguration record carried by a direct
2715/// config write as cancelled, returning the audit intent to declare with the
2716/// write.
2717///
2718/// A direct write reshapes the replica set itself, superseding whatever target
2719/// the record carries. Leaving the record in progress would have the controller
2720/// keep converging on that stale target.
2721fn cancel_carried_reconfiguration(config: &mut ClusterConfig) -> Option<ReconfigurationAudit> {
2722    let ClusterVariant::Managed(managed) = &mut config.variant else {
2723        return None;
2724    };
2725    let record = managed.reconfiguration.as_mut()?;
2726    if !record.is_in_progress() {
2727        return None;
2728    }
2729    record.status = ReconfigurationStatus::Cancelled;
2730    Some(ReconfigurationAudit::Cancelled)
2731}
2732
2733/// Whether an `ALTER` statement sets a replica config shape dimension (`SIZE`,
2734/// `AVAILABILITY ZONES`, either `INTROSPECTION` option, or `EXPERIMENTAL
2735/// ARRANGEMENT COMPRESSION`), the changes that need a durable
2736/// `reconfiguration` record and a hydrate-overlap.
2737///
2738/// A statement-level check, used while a reconfiguration is in flight: an
2739/// `ALTER` back to the realized shape sets a shape option without changing its
2740/// value, yet must reach the reshape path to cancel the record. With nothing
2741/// in flight the routing compares values instead (see
2742/// `sequence_alter_cluster_stage`).
2743fn alter_changes_replica_shape(options: &PlanClusterOption) -> bool {
2744    use mz_sql::plan::AlterOptionParameter::Unchanged;
2745    let PlanClusterOption {
2746        availability_zones,
2747        introspection_debugging,
2748        introspection_interval,
2749        arrangement_compression,
2750        managed: _,
2751        replicas: _,
2752        replication_factor: _,
2753        size,
2754        schedule: _,
2755        workload_class: _,
2756        auto_scaling_strategy: _,
2757    } = options;
2758    !matches!(size, Unchanged)
2759        || !matches!(availability_zones, Unchanged)
2760        || !matches!(introspection_debugging, Unchanged)
2761        || !matches!(introspection_interval, Unchanged)
2762        || !matches!(arrangement_compression, Unchanged)
2763}
2764
2765/// Fold a new `ALTER` onto an in-flight reconfiguration target.
2766///
2767/// `new_target` was built against the *realized* config, so any dimension the
2768/// `ALTER` left `Unchanged` carries the realized (pre-reconfiguration) value. When
2769/// a reconfiguration is in flight (`in_flight` is `Some`), the realized config is
2770/// the pre-reconfiguration shape, so for each `Unchanged` dimension we instead
2771/// keep the in-flight target's value. Only dimensions the `ALTER` explicitly set
2772/// re-target. With nothing in flight (`in_flight` is `None`) the target is exactly
2773/// `new_target`. This is what keeps an `ALTER` that touches one dimension (e.g.
2774/// AZ-only) from silently reverting the in-flight transition along every dimension
2775/// it did not mention.
2776///
2777/// Replication factor folds the same way, but only matters for the
2778/// nothing-in-flight case: a change to it while a reconfiguration is in
2779/// flight is refused before an `ALTER` reaches here, so
2780/// `unchanged.replication_factor` is always `true` when `in_flight` is
2781/// `Some`.
2782fn fold_reconfiguration_target(
2783    in_flight: Option<&ReconfigurationTarget>,
2784    new_target: ReconfigurationTarget,
2785    unchanged: ReconfigurationDimensionsUnchanged,
2786) -> ReconfigurationTarget {
2787    let Some(prev) = in_flight else {
2788        return new_target;
2789    };
2790    ReconfigurationTarget {
2791        size: if unchanged.size {
2792            prev.size.clone()
2793        } else {
2794            new_target.size
2795        },
2796        replication_factor: if unchanged.replication_factor {
2797            prev.replication_factor
2798        } else {
2799            new_target.replication_factor
2800        },
2801        availability_zones: if unchanged.availability_zones {
2802            prev.availability_zones.clone()
2803        } else {
2804            new_target.availability_zones
2805        },
2806        logging: ReplicaLogging {
2807            log_logging: if unchanged.log_logging {
2808                prev.logging.log_logging
2809            } else {
2810                new_target.logging.log_logging
2811            },
2812            interval: if unchanged.interval {
2813                prev.logging.interval
2814            } else {
2815                new_target.logging.interval
2816            },
2817        },
2818        arrangement_compression: if unchanged.arrangement_compression {
2819            prev.arrangement_compression
2820        } else {
2821            new_target.arrangement_compression
2822        },
2823    }
2824}
2825
2826/// The type of finalization needed after an
2827/// operation such as alter_cluster_managed_to_managed.
2828#[derive(PartialEq)]
2829pub(crate) enum NeedsFinalization {
2830    /// Wait for the provided duration before finalizing
2831    Yes,
2832    No,
2833}
2834
2835#[cfg(test)]
2836mod tests {
2837    use mz_controller::clusters::ReplicaLogging;
2838    use mz_controller_types::DEFAULT_REPLICA_LOGGING_INTERVAL;
2839
2840    use super::*;
2841
2842    fn target(size: &str, rf: u32, azs: &[&str], log_logging: bool) -> ReconfigurationTarget {
2843        ReconfigurationTarget {
2844            size: size.to_string(),
2845            replication_factor: rf,
2846            availability_zones: azs.iter().map(|s| s.to_string()).collect(),
2847            logging: ReplicaLogging {
2848                log_logging,
2849                interval: Some(DEFAULT_REPLICA_LOGGING_INTERVAL),
2850            },
2851            arrangement_compression: false,
2852        }
2853    }
2854
2855    fn all_changed() -> ReconfigurationDimensionsUnchanged {
2856        ReconfigurationDimensionsUnchanged {
2857            size: false,
2858            replication_factor: false,
2859            availability_zones: false,
2860            log_logging: false,
2861            interval: false,
2862            arrangement_compression: false,
2863        }
2864    }
2865
2866    fn all_unchanged() -> ReconfigurationDimensionsUnchanged {
2867        ReconfigurationDimensionsUnchanged {
2868            size: true,
2869            replication_factor: true,
2870            availability_zones: true,
2871            log_logging: true,
2872            interval: true,
2873            arrangement_compression: true,
2874        }
2875    }
2876
2877    #[mz_ore::test]
2878    fn fold_with_no_record_takes_new_target() {
2879        // No reconfiguration in flight: the target is exactly the new one.
2880        let new = target("200cc", 3, &["az1"], true);
2881        let folded = fold_reconfiguration_target(None, new.clone(), all_changed());
2882        assert_eq!(folded, new);
2883    }
2884
2885    #[mz_ore::test]
2886    fn fold_rf_only_keeps_in_flight_shape() {
2887        // A 200cc size change is in flight. A later rf-only ALTER must NOT revert
2888        // the in-flight size/AZ/logging back to the realized (100cc) values that
2889        // `new_target` carries for the dimensions the ALTER left unchanged.
2890        let in_flight = target("200cc", 1, &["az2"], true);
2891        // new_target reflects realized 100cc/az1 for every dimension but rf, which
2892        // the ALTER set to 5.
2893        let new = target("100cc", 5, &["az1"], false);
2894        let unchanged = ReconfigurationDimensionsUnchanged {
2895            size: true,
2896            replication_factor: false,
2897            availability_zones: true,
2898            log_logging: true,
2899            interval: true,
2900            arrangement_compression: true,
2901        };
2902        let folded = fold_reconfiguration_target(Some(&in_flight), new, unchanged);
2903        // The in-flight size/AZ/logging survive. Only rf is re-targeted.
2904        assert_eq!(folded, target("200cc", 5, &["az2"], true));
2905    }
2906
2907    #[mz_ore::test]
2908    fn fold_with_all_set_overwrites_every_dimension() {
2909        // Every dimension explicitly set: the fold takes all of new_target.
2910        let in_flight = target("200cc", 1, &["az2"], true);
2911        let new = target("400cc", 9, &["az9"], false);
2912        let folded = fold_reconfiguration_target(Some(&in_flight), new.clone(), all_changed());
2913        assert_eq!(folded, new);
2914    }
2915
2916    #[mz_ore::test]
2917    fn fold_all_unchanged_is_alter_back_to_in_flight() {
2918        // An all-unchanged fold keeps the in-flight target intact rather than
2919        // reverting it to the realized shape. Unreachable from the `ALTER`
2920        // path (non-shape statements no longer reach the fold), pinned as a
2921        // property of the pure function.
2922        let in_flight = target("200cc", 2, &["az2"], true);
2923        let realized_shaped = target("100cc", 1, &["az1"], false);
2924        let folded =
2925            fold_reconfiguration_target(Some(&in_flight), realized_shaped, all_unchanged());
2926        assert_eq!(folded, in_flight);
2927    }
2928
2929    #[mz_ore::test]
2930    fn fold_logging_subdimensions_fold_independently() {
2931        // An interval change is in flight. A later ALTER that sets only
2932        // INTROSPECTION DEBUGGING must not revert the in-flight interval to the
2933        // realized value that `new_target` carries for options the ALTER left
2934        // unset.
2935        let mut in_flight = target("100cc", 1, &["az1"], false);
2936        in_flight.logging.interval = Some(Duration::from_secs(5));
2937        let new = target("100cc", 1, &["az1"], true);
2938        let unchanged = ReconfigurationDimensionsUnchanged {
2939            size: true,
2940            replication_factor: true,
2941            availability_zones: true,
2942            log_logging: false,
2943            interval: true,
2944            arrangement_compression: true,
2945        };
2946        let folded = fold_reconfiguration_target(Some(&in_flight), new, unchanged);
2947        assert_eq!(
2948            folded.logging,
2949            ReplicaLogging {
2950                log_logging: true,
2951                interval: Some(Duration::from_secs(5)),
2952            }
2953        );
2954    }
2955}