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