Skip to main content

mz_adapter/coord/
cluster_controller.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
10//! Driver and glue for the [`mz_cluster_controller`] reconciler.
11//!
12//! The controller crate is pure: it knows nothing about the Coordinator. This
13//! module is the half of the [`ClusterControllerCtx`] boundary that does: it runs
14//! the controller as a **separate task** and implements the ctx by marshaling
15//! each pull/apply to the Coordinator over the internal command channel, because
16//! the catalog and the live compute/storage signals are reachable only from the
17//! coordinator loop. Pulls are batched so the round-trip count per tick is
18//! bounded.
19//!
20//! Everything here is gated by [`ENABLE_CLUSTER_CONTROLLER`] (default off). With
21//! the gate off the task does not tick, so the legacy scheduling and graceful
22//! paths remain the sole writers of the replica set. With the gate on the
23//! controller owns the *user* managed-cluster replica set; the legacy entry
24//! points no-op. (System/builtin clusters are never controller-owned. The
25//! catalog's bootstrap migration owns their replicas.)
26
27use std::collections::BTreeSet;
28use std::sync::Arc;
29use std::time::Duration;
30
31use mz_adapter_types::dyncfgs::{CLUSTER_CONTROLLER_TICK_INTERVAL, ENABLE_CLUSTER_CONTROLLER};
32use mz_catalog::memory::objects::{ClusterConfig, ClusterVariant};
33use mz_cluster_controller::ClusterController;
34use mz_cluster_controller::ctx::{
35    ApplyOutcome, AvailabilityZones, ClusterControllerCtx, ClusterState, Decision,
36    ExpectedClusterState, ObservedReplica, OnTimeout, ReconfigurationRecord, ReconfigurationStatus,
37    ReplicaShape, StateWrite,
38};
39use mz_cluster_controller::strategy::GRACEFUL_RECONFIGURATION_STRATEGY_NAME;
40use mz_compute_types::config::ComputeReplicaConfig;
41use mz_controller_types::{ClusterId, ReplicaId};
42use mz_ore::task::spawn;
43use mz_repr::Timestamp;
44use tokio::sync::{mpsc, oneshot};
45use tracing::{debug, warn};
46
47use crate::catalog::{DropObjectInfo, Op, ReplicaCreateDropReason};
48use crate::coord::{Coordinator, Message};
49use crate::error::AdapterError;
50
51/// A request the controller task marshals to the Coordinator to satisfy one
52/// [`ClusterControllerCtx`] call. Each variant carries a oneshot for the reply.
53///
54/// `ManagedClusterIds` and `ClusterStates` are the per-tick batched reads. The
55/// `ClusterStates` reply also carries `now`. `HydratedReplicas` is a
56/// per-cluster live signal a strategy pulls on demand.
57#[derive(Debug)]
58pub enum ClusterControllerRequest {
59    /// The ids of all *user* managed clusters the controller owns this tick.
60    /// System/builtin clusters are excluded. Their replica set is owned by the
61    /// catalog's bootstrap migration, not the controller.
62    ManagedClusterIds { tx: oneshot::Sender<Vec<ClusterId>> },
63    /// A consistent durable view of the given clusters and their replicas, plus
64    /// the current time.
65    ClusterStates {
66        clusters: Vec<ClusterId>,
67        tx: oneshot::Sender<(Vec<ClusterState>, Timestamp)>,
68    },
69    /// Of `replicas` on `cluster`, which have all current collections hydrated.
70    HydratedReplicas {
71        cluster_id: ClusterId,
72        replicas: Vec<ReplicaId>,
73        tx: oneshot::Sender<BTreeSet<ReplicaId>>,
74    },
75    /// Apply a tick's batch of decisions under their compare-and-append guards.
76    Apply {
77        decisions: Vec<Decision>,
78        tx: oneshot::Sender<ApplyOutcome>,
79    },
80    /// The current configured reconcile cadence. Read once per tick so a runtime
81    /// change to `cluster_controller_tick_interval` takes effect without a
82    /// restart.
83    TickInterval { tx: oneshot::Sender<Duration> },
84}
85
86struct ReplicaHydrationCheck {
87    replica_id: ReplicaId,
88    compute_hydrated: oneshot::Receiver<bool>,
89}
90
91/// The controller-task side of the boundary: a [`ClusterControllerCtx`] that
92/// marshals every call to the Coordinator over `internal_cmd_tx`.
93struct CoordCtx {
94    internal_cmd_tx: mpsc::UnboundedSender<Message>,
95    /// Latched `now` from the most recent batched read, returned by
96    /// [`ClusterControllerCtx::now`] so a strategy and the kernel see a single
97    /// consistent time per phase.
98    now: Timestamp,
99}
100
101impl CoordCtx {
102    /// Send a request and await its reply. Returns `None` if the Coordinator has
103    /// gone away (shutdown), which the caller treats as "nothing to do".
104    async fn request<T>(
105        &self,
106        make: impl FnOnce(oneshot::Sender<T>) -> ClusterControllerRequest,
107    ) -> Option<T> {
108        let (tx, rx) = oneshot::channel();
109        if self
110            .internal_cmd_tx
111            .send(Message::ClusterControllerRequest(make(tx)))
112            .is_err()
113        {
114            return None;
115        }
116        rx.await.ok()
117    }
118}
119
120#[async_trait::async_trait]
121impl ClusterControllerCtx for CoordCtx {
122    fn now(&self) -> Timestamp {
123        self.now
124    }
125
126    async fn managed_cluster_ids(&mut self) -> Vec<ClusterId> {
127        self.request(|tx| ClusterControllerRequest::ManagedClusterIds { tx })
128            .await
129            .unwrap_or_default()
130    }
131
132    async fn cluster_states(&mut self, clusters: &[ClusterId]) -> Vec<ClusterState> {
133        let clusters = clusters.to_vec();
134        match self
135            .request(|tx| ClusterControllerRequest::ClusterStates { clusters, tx })
136            .await
137        {
138            Some((states, now)) => {
139                self.now = now;
140                states
141            }
142            None => Vec::new(),
143        }
144    }
145
146    async fn hydrated_replicas(
147        &mut self,
148        cluster_id: ClusterId,
149        replicas: &[ReplicaId],
150    ) -> BTreeSet<ReplicaId> {
151        let replicas = replicas.to_vec();
152        self.request(|tx| ClusterControllerRequest::HydratedReplicas {
153            cluster_id,
154            replicas,
155            tx,
156        })
157        .await
158        .unwrap_or_default()
159    }
160
161    async fn apply(&mut self, decisions: Vec<Decision>) -> ApplyOutcome {
162        self.request(|tx| ClusterControllerRequest::Apply { decisions, tx })
163            .await
164            // A lost reply means shutdown; treat as rejected so we make no
165            // further claims about the catalog state.
166            .unwrap_or(ApplyOutcome::Rejected)
167    }
168}
169
170impl Coordinator {
171    /// Spawn the cluster controller task.
172    ///
173    /// The task ticks at [`CLUSTER_CONTROLLER_TICK_INTERVAL`] and reconciles when
174    /// [`ENABLE_CLUSTER_CONTROLLER`] is on; while the gate is off it ticks but
175    /// each tick is an early no-op. Both the gate and the interval are re-read
176    /// each tick (the interval via a [`ClusterControllerRequest::TickInterval`]
177    /// round-trip), so a runtime change to either takes effect without a restart.
178    /// It owns the controller and a [`CoordCtx`] that marshals back to this
179    /// Coordinator.
180    ///
181    /// The interval is the fallback cadence: `reconcile_now` cuts the
182    /// sleep short after a catalog transaction changes durable cluster state.
183    /// The notification only wakes the task. The tick still pulls fresh state
184    /// through the coordinator loop, and the controller's own applies wake it
185    /// again at the cost of one no-op tick.
186    pub(crate) fn spawn_cluster_controller_task(&self) {
187        let internal_cmd_tx = self.internal_cmd_tx.clone();
188        let reconcile_now = Arc::clone(&self.reconcile_now);
189
190        spawn(|| "cluster_controller", async move {
191            let controller = ClusterController::new();
192            let mut ctx = CoordCtx {
193                internal_cmd_tx,
194                now: Timestamp::MIN,
195            };
196
197            loop {
198                // Re-read the cadence each tick so a runtime change takes effect.
199                // A lost reply means the Coordinator is gone; stop ticking.
200                let Some(interval) = ctx
201                    .request(|tx| ClusterControllerRequest::TickInterval { tx })
202                    .await
203                else {
204                    break;
205                };
206                tokio::select! {
207                    _ = tokio::time::sleep(interval.max(Duration::from_millis(1))) => {}
208                    _ = reconcile_now.notified() => {}
209                }
210
211                if ctx.internal_cmd_tx.is_closed() {
212                    // Coordinator gone; stop ticking.
213                    break;
214                }
215                controller.reconcile(&mut ctx).await;
216            }
217        });
218    }
219
220    /// Handle one [`ClusterControllerRequest`] on the coordinator loop.
221    ///
222    /// The controller is inactive when the gate is off, or while the deployment
223    /// is in read-only mode (a 0dt upgrade, where it must not write the catalog).
224    /// When inactive, reads report no managed clusters (so the controller finds
225    /// nothing to reconcile) and applies are rejected: the task still wakes each
226    /// tick and sends one `ManagedClusterIds` request, but that request
227    /// early-returns here and no catalog state is read or written, so the legacy
228    /// paths remain the sole writers of the replica set. The task keeps ticking,
229    /// so the controller reactivates on its own once the deployment promotes out
230    /// of read-only mode.
231    #[mz_ore::instrument(level = "debug")]
232    pub(crate) async fn handle_cluster_controller_request(
233        &mut self,
234        request: ClusterControllerRequest,
235    ) {
236        let active = ENABLE_CLUSTER_CONTROLLER.get(self.catalog().system_config().dyncfgs())
237            && !self.controller.read_only();
238
239        match request {
240            ClusterControllerRequest::ManagedClusterIds { tx } => {
241                let ids = if active {
242                    self.catalog()
243                        .clusters()
244                        // Only *user* managed clusters. System/builtin clusters
245                        // (mz_system, mz_catalog_server, …) are also managed, but
246                        // their replica set is owned by the bootstrap migration
247                        // (`add_new_remove_old_builtin_cluster_replicas_migration`),
248                        // which holds exactly the `BUILTIN_CLUSTER_REPLICAS`-defined
249                        // replicas regardless of the cluster's `replication_factor`.
250                        // Letting the controller own them too would make two writers
251                        // of one replica set: the baseline would, for example, add a
252                        // replica to reach a builtin cluster's `replication_factor`,
253                        // which the bootstrap migration then tears down on the next
254                        // open. The legacy scheduler likewise only ever acted on user
255                        // clusters.
256                        .filter(|c| c.is_managed() && c.id.is_user())
257                        .map(|c| c.id)
258                        .collect()
259                } else {
260                    Vec::new()
261                };
262                let _ = tx.send(ids);
263            }
264            ClusterControllerRequest::ClusterStates { clusters, tx } => {
265                let now = Timestamp::from(self.now());
266                // Only ever asked about clusters the controller is reconciling
267                // this tick, which the inactive `ManagedClusterIds` gate above
268                // makes empty, so no guard is needed here.
269                let states: Vec<_> = clusters
270                    .into_iter()
271                    .filter_map(|id| self.observe_cluster_state(id))
272                    .collect();
273                let _ = tx.send((states, now));
274            }
275            ClusterControllerRequest::HydratedReplicas {
276                cluster_id,
277                replicas,
278                tx,
279            } => {
280                let checks = self.start_hydration_checks(cluster_id, replicas);
281                // Start the controller calls on the coordinator loop, then wait
282                // for compute's replies off-loop. The compute check can wait on
283                // the compute instance task.
284                spawn(|| "cluster_controller_hydration_probe", async move {
285                    let mut hydrated = BTreeSet::new();
286                    for check in checks {
287                        if check.compute_hydrated.await.unwrap_or(false) {
288                            hydrated.insert(check.replica_id);
289                        }
290                    }
291                    let _ = tx.send(hydrated);
292                });
293            }
294            ClusterControllerRequest::Apply { decisions, tx } => {
295                let outcome = if active {
296                    self.apply_cluster_decisions(decisions).await
297                } else {
298                    ApplyOutcome::Rejected
299                };
300                let _ = tx.send(outcome);
301            }
302            ClusterControllerRequest::TickInterval { tx } => {
303                let interval =
304                    CLUSTER_CONTROLLER_TICK_INTERVAL.get(self.catalog().system_config().dyncfgs());
305                let _ = tx.send(interval);
306            }
307        }
308    }
309
310    /// Build the controller's view of one managed cluster from the catalog.
311    /// Returns `None` for a missing or unmanaged cluster.
312    fn observe_cluster_state(&self, cluster_id: ClusterId) -> Option<ClusterState> {
313        let cluster = self.catalog().try_get_cluster(cluster_id)?;
314        let ClusterVariant::Managed(managed) = &cluster.config.variant else {
315            return None;
316        };
317        // The witness fields come from the same projection the compare-and-append
318        // check uses, so the state a decision is derived from and the state the
319        // apply path checks against cannot drift.
320        let expected = crate::catalog::cluster_state::project_expected(managed);
321
322        let replicas = cluster
323            .replicas()
324            .filter_map(|replica| {
325                // INTERNAL / BILLED AS replicas are manually managed and live
326                // outside the controller's replication-factor domain: a user
327                // can attach one to any managed cluster, and the legacy
328                // scheduler and reconfiguration paths never create or drop
329                // them. A `-pending` replica is the in-flight target of a
330                // graceful reconfiguration, created at the new shape while the
331                // durable config still reads the old one until finalize. It is
332                // owned by the reconfiguration path, not the controller.
333                // Exclude all of these from the observed set so the controller
334                // neither counts one toward a desired shape (letting it stand
335                // in for a managed replica) nor drops it as excess. Retiring a
336                // pending replica here would defeat the zero-downtime resize
337                // that created it.
338                if replica.config.location.internal()
339                    || replica.config.location.billed_as().is_some()
340                    || replica.config.location.pending()
341                {
342                    return None;
343                }
344                let shape = replica_shape(&replica.config)?;
345                Some(ObservedReplica {
346                    replica_id: replica.replica_id,
347                    name: replica.name.clone(),
348                    shape,
349                })
350            })
351            .collect();
352
353        Some(ClusterState {
354            cluster_id,
355            size: expected.size,
356            replication_factor: expected.replication_factor,
357            availability_zones: expected.availability_zones.0,
358            logging: expected.logging,
359            reconfiguration: expected.reconfiguration,
360            burst: expected.burst,
361            replicas,
362        })
363    }
364
365    /// Starts per-replica hydration checks for `cluster_id`.
366    ///
367    /// Returns only checks that are already storage-hydrated and known to the
368    /// compute controller. The compute receiver completes off the coordinator
369    /// loop.
370    fn start_hydration_checks(
371        &self,
372        cluster_id: ClusterId,
373        replicas: Vec<ReplicaId>,
374    ) -> Vec<ReplicaHydrationCheck> {
375        use mz_catalog::memory::objects::CatalogItem;
376
377        // Materialized views pinned to a replica (via `IN CLUSTER ... REPLICA`)
378        // are only ever installed on that replica, so any other replica can
379        // never report them hydrated. Collect the cluster's pinned MVs once,
380        // then exclude the ones pinned elsewhere from each replica's hydration
381        // check. Otherwise a graceful reconfiguration's cut-over to a fresh
382        // replica set would wait forever for the new replicas to hydrate an MV
383        // bound to a replica being replaced, then roll back at the deadline
384        // (leaving the old replica, and the targeted MV, in place). Indexes
385        // cannot be replica-pinned, so MVs are the only case.
386        let pinned_mvs: Vec<(ReplicaId, mz_repr::GlobalId)> = self
387            .catalog()
388            .try_get_cluster(cluster_id)
389            .into_iter()
390            .flat_map(|cluster| cluster.bound_objects.iter())
391            .filter_map(|id| match self.catalog().get_entry(id).item() {
392                CatalogItem::MaterializedView(mv) => mv
393                    .target_replica
394                    .map(|target| (target, mv.global_id_writes())),
395                _ => None,
396            })
397            .collect();
398
399        let mut checks = Vec::new();
400        for replica_id in replicas {
401            let exclude: BTreeSet<mz_repr::GlobalId> = pinned_mvs
402                .iter()
403                .filter(|(target, _)| *target != replica_id)
404                .map(|(_, id)| *id)
405                .collect();
406            let compute_fut = match self.controller.compute.collections_hydrated_for_replicas(
407                cluster_id,
408                vec![replica_id],
409                exclude.clone(),
410            ) {
411                Ok(fut) => fut,
412                // The replica is not known to the compute controller. Treat it
413                // as not hydrated.
414                Err(_) => continue,
415            };
416            let storage_hydrated = match self.controller.storage.collections_hydrated_on_replicas(
417                Some(vec![replica_id]),
418                &cluster_id,
419                &exclude,
420            ) {
421                Ok(hydrated) => hydrated,
422                Err(_) => continue,
423            };
424            if storage_hydrated {
425                checks.push(ReplicaHydrationCheck {
426                    replica_id,
427                    compute_hydrated: compute_fut,
428                });
429            }
430        }
431        checks
432    }
433
434    /// Apply one batch of decisions under their compare-and-append guards.
435    ///
436    /// The kernel calls this once per tick phase: a phase-1 batch is all
437    /// `UpdateClusterState`, a phase-2 batch is all create/drop. Either batch may
438    /// in principle be mixed; this handles both. The work is staged across four
439    /// steps: collect the per-cluster guards, pre-allocate the ids the creates
440    /// need, build the mutation ops, then commit ops and guards in one
441    /// transaction (see [`Self::commit_with_checks`] for why the guard holds).
442    /// Any step that finds the batch incoherent rejects it, and the controller
443    /// recomputes next tick.
444    async fn apply_cluster_decisions(&mut self, decisions: Vec<Decision>) -> ApplyOutcome {
445        let checks = Self::partition_checks(&decisions);
446
447        // Pre-allocate replica ids before the apply transaction (each allocation
448        // is its own durable commit, so it cannot happen inside the transaction).
449        let Some(replica_ids) = self.allocate_replica_ids_for_creates(&decisions).await else {
450            return ApplyOutcome::Rejected;
451        };
452
453        let Some(mutations) = self.build_mutation_ops(decisions, replica_ids) else {
454            return ApplyOutcome::Rejected;
455        };
456        if mutations.is_empty() {
457            // Nothing to apply, so the checks guard nothing. Skip the transaction
458            // rather than commit a check-only batch, which would still cost a
459            // durable round-trip.
460            return ApplyOutcome::Applied;
461        }
462
463        self.commit_with_checks(checks, mutations).await
464    }
465
466    /// The compare-and-append guards for a decision batch: one
467    /// `(cluster_id, expected)` per distinct cluster, in first-seen order. All
468    /// of a cluster's decisions in a tick come from one snapshot, so they share
469    /// one `expected` witness and one guard covers them all.
470    fn partition_checks(decisions: &[Decision]) -> Vec<(ClusterId, ExpectedClusterState)> {
471        let mut checks: Vec<(ClusterId, ExpectedClusterState)> = Vec::new();
472        let mut seen_clusters = BTreeSet::new();
473        for decision in decisions {
474            let (cluster_id, expected) = match decision {
475                Decision::CreateReplica {
476                    cluster_id,
477                    expected,
478                    ..
479                }
480                | Decision::DropReplica {
481                    cluster_id,
482                    expected,
483                    ..
484                }
485                | Decision::UpdateClusterState {
486                    cluster_id,
487                    expected,
488                    ..
489                } => (*cluster_id, expected),
490            };
491            if seen_clusters.insert(cluster_id) {
492                checks.push((cluster_id, expected.clone()));
493            } else {
494                debug_assert!(
495                    checks
496                        .iter()
497                        .any(|(c, e)| *c == cluster_id && e == expected),
498                    "decisions for a cluster in one tick must share one expected witness",
499                );
500            }
501        }
502        checks
503    }
504
505    /// Pre-allocate one replica id per `CreateReplica` decision, in the order the
506    /// creates appear (which is the order [`Self::build_mutation_ops`] consumes
507    /// them). Returns `None` if any allocation fails, which rejects the batch.
508    ///
509    /// `Op::CreateClusterReplica` carries a pre-allocated id, so we allocate
510    /// out-of-band here, before the apply transaction. Each allocation commits
511    /// durably, so we take a fresh write ts per allocation: two commits must not
512    /// share a timestamp.
513    async fn allocate_replica_ids_for_creates(
514        &mut self,
515        decisions: &[Decision],
516    ) -> Option<Vec<ReplicaId>> {
517        let mut replica_ids = Vec::new();
518        for decision in decisions {
519            let Decision::CreateReplica { cluster_id, .. } = decision else {
520                continue;
521            };
522            let id_ts = self.get_catalog_write_ts().await;
523            match self
524                .catalog()
525                .allocate_replica_ids(*cluster_id, 1, id_ts)
526                .await
527            {
528                Ok(ids) => {
529                    replica_ids.push(ids.into_iter().next().expect("allocated one replica id"))
530                }
531                Err(err) => {
532                    warn!(%cluster_id, "cluster controller could not allocate replica id: {err}");
533                    return None;
534                }
535            }
536        }
537        Some(replica_ids)
538    }
539
540    /// Turn a decision batch into the catalog mutation ops to transact, consuming
541    /// the `replica_ids` pre-allocated for the creates (one per `CreateReplica`,
542    /// in order). Returns `None` if a target cluster has vanished or gone
543    /// unmanaged, which makes the batch incoherent and rejects it.
544    fn build_mutation_ops(
545        &self,
546        decisions: Vec<Decision>,
547        replica_ids: Vec<ReplicaId>,
548    ) -> Option<Vec<Op>> {
549        let mut replica_ids = replica_ids.into_iter();
550        let mut mutations = Vec::new();
551        let mut drops = Vec::new();
552        for decision in decisions {
553            match decision {
554                Decision::UpdateClusterState {
555                    cluster_id, write, ..
556                } => match self.build_update_cluster_config_op(cluster_id, &write) {
557                    Some(op) => mutations.push(op),
558                    // The cluster vanished. The batch is no longer coherent.
559                    None => return None,
560                },
561                Decision::CreateReplica {
562                    cluster_id,
563                    name,
564                    shape,
565                    reasons,
566                    ..
567                } => {
568                    let replica_id = replica_ids.next().expect("one pre-allocated id per create");
569                    let reason = reason_from_strategies(&reasons);
570                    match self.build_create_replica_op(cluster_id, replica_id, name, &shape, reason)
571                    {
572                        Ok(Some(op)) => mutations.push(op),
573                        Ok(None) => return None,
574                        Err(err) => {
575                            warn!(%cluster_id, "cluster controller could not build replica create: {err}");
576                            return None;
577                        }
578                    }
579                }
580                Decision::DropReplica {
581                    cluster_id,
582                    replica_id,
583                    ..
584                } => {
585                    // The replica may have vanished since the decisions were
586                    // derived (a user DDL landed between the tick's read and
587                    // this apply). The in-transaction witness check would
588                    // reject such a stale batch, but resource-limit validation
589                    // runs before the transaction and panics on a missing
590                    // replica, so reject the batch here instead.
591                    if self
592                        .catalog()
593                        .try_get_cluster_replica(cluster_id, replica_id)
594                        .is_none()
595                    {
596                        return None;
597                    }
598                    drops.push(DropObjectInfo::ClusterReplica((
599                        cluster_id,
600                        replica_id,
601                        ReplicaCreateDropReason::Retired,
602                    )));
603                }
604            }
605        }
606        if !drops.is_empty() {
607            mutations.push(Op::DropObjects(drops));
608        }
609        Some(mutations)
610    }
611
612    /// Prepend the per-cluster compare-and-append `checks` to `mutations` and
613    /// transact them together.
614    ///
615    /// The checks run inside the transaction, before any mutation, so they cannot
616    /// be separated from the commit they guard. A cluster whose durable state has
617    /// diverged from what the decisions were derived from (e.g. a user `ALTER`
618    /// landed mid-tick) aborts the whole batch, so a stale create or drop can
619    /// never reshape the replica set against the config the `ALTER` has since
620    /// established (in particular, a stale drop cannot retire a replica the
621    /// `ALTER` has just made desired). On rejection nothing is applied.
622    async fn commit_with_checks(
623        &mut self,
624        checks: Vec<(ClusterId, ExpectedClusterState)>,
625        mutations: Vec<Op>,
626    ) -> ApplyOutcome {
627        let mut ops: Vec<Op> = checks
628            .into_iter()
629            .map(|(cluster_id, expected)| Op::CheckClusterState {
630                cluster_id,
631                expected,
632            })
633            .collect();
634        ops.extend(mutations);
635
636        match self.catalog_transact(None, ops).await {
637            Ok(()) => ApplyOutcome::Applied,
638            Err(AdapterError::ClusterStateChanged { .. }) => {
639                // A concurrent `ALTER` moved a cluster's durable state out from
640                // under the decisions. Expected, so the controller recomputes
641                // next tick.
642                ApplyOutcome::Rejected
643            }
644            Err(AdapterError::ReadOnly) => {
645                // The controller is quiesced while read-only (see
646                // `handle_cluster_controller_request`), so this is normally
647                // unreachable; if reached it's expected and not actionable, not
648                // a failure to surface.
649                debug!("cluster controller apply skipped in read-only mode");
650                ApplyOutcome::Rejected
651            }
652            Err(AdapterError::ResourceExhaustion { .. }) => {
653                // The batch cannot fit the resource budget. Report the fact and
654                // leave the reaction (what, if anything, to shed) to the kernel.
655                debug!("cluster controller apply exceeded the resource budget");
656                ApplyOutcome::ResourceExhausted
657            }
658            Err(err) => {
659                warn!("cluster controller apply failed: {err}");
660                ApplyOutcome::Rejected
661            }
662        }
663    }
664
665    /// Build an [`Op::UpdateClusterConfig`] that applies `write`'s deltas to the
666    /// cluster's current in-memory config, or `None` if the cluster is gone or
667    /// unmanaged. The write was guard-checked against the same state, so this is
668    /// the realized cut-over / record write.
669    fn build_update_cluster_config_op(
670        &self,
671        cluster_id: ClusterId,
672        write: &StateWrite,
673    ) -> Option<Op> {
674        let cluster = self.catalog().try_get_cluster(cluster_id)?;
675        let mut config = cluster.config.clone();
676        let ClusterConfig {
677            variant: ClusterVariant::Managed(managed),
678            ..
679        } = &mut config
680        else {
681            return None;
682        };
683        if let Some(size) = &write.new_size {
684            managed.size = size.clone();
685        }
686        if let Some(rf) = write.new_replication_factor {
687            managed.replication_factor = rf;
688        }
689        if let Some(azs) = &write.new_availability_zones {
690            managed.availability_zones = azs.clone();
691        }
692        if let Some(logging) = &write.new_logging {
693            managed.logging = logging.clone();
694        }
695        if let Some(reconfiguration) = &write.reconfiguration {
696            managed.reconfiguration = reconfiguration.record.as_ref().map(memory_reconfiguration);
697        }
698        if let Some(burst) = &write.burst {
699            managed.burst = burst.record.as_ref().map(memory_burst);
700        }
701        // The audit intents travel with the write, declared by the strategy at
702        // the decision point. We pass them through untouched so the events are
703        // emitted in the same catalog transaction as the state they describe.
704        let reconfiguration_audit = write.reconfiguration.as_ref().and_then(|w| w.audit);
705        let burst_audit = write.burst.as_ref().and_then(|w| w.audit);
706        Some(Op::UpdateClusterConfig {
707            id: cluster_id,
708            name: cluster.name.clone(),
709            config,
710            reconfiguration_audit,
711            burst_audit,
712        })
713    }
714
715    /// Build an [`Op::CreateClusterReplica`] for a desired replica `shape` on
716    /// `cluster_id` with the pre-allocated `replica_id`, attributed to `reason`.
717    /// Returns `Ok(None)` if the cluster is gone or unmanaged.
718    fn build_create_replica_op(
719        &self,
720        cluster_id: ClusterId,
721        replica_id: ReplicaId,
722        name: String,
723        shape: &ReplicaShape,
724        reason: ReplicaCreateDropReason,
725    ) -> Result<Option<Op>, mz_catalog::memory::error::Error> {
726        let Some(cluster) = self.catalog().try_get_cluster(cluster_id) else {
727            return Ok(None);
728        };
729        if !cluster.is_managed() {
730            return Ok(None);
731        }
732        let owner_id = cluster.owner_id;
733
734        let location = mz_catalog::durable::ReplicaLocation::Managed {
735            // Concretized from the cluster config below; left empty here.
736            availability_zones: Vec::new(),
737            billed_as: None,
738            internal: false,
739            size: shape.size.clone(),
740            pending: false,
741        };
742        let azs: Option<&[String]> = if shape.availability_zones.0.is_empty() {
743            None
744        } else {
745            Some(&shape.availability_zones.0)
746        };
747        let location = self.catalog().concretize_replica_location(
748            location,
749            &self
750                .catalog()
751                .get_role_allowed_cluster_sizes(&Some(owner_id)),
752            azs,
753            false,
754        )?;
755
756        let config = mz_controller::clusters::ReplicaConfig {
757            location,
758            compute: ComputeReplicaConfig {
759                logging: shape.logging.clone(),
760            },
761        };
762
763        Ok(Some(Op::CreateClusterReplica {
764            cluster_id,
765            replica_id,
766            name,
767            config,
768            owner_id,
769            reason,
770        }))
771    }
772}
773
774/// Map a create decision's strategy-attribution to the audit reason carried on
775/// the create event.
776///
777/// A create the graceful strategy desired is recorded as a graceful
778/// reconfiguration. Everything else, baseline-held replicas, is `Manual`, the
779/// tag for replicas the cluster's own config calls for.
780///
781/// Drops never come through here: a drop happens exactly when no strategy
782/// desires the replica, so it carries no attribution and is uniformly audited
783/// [`ReplicaCreateDropReason::Retired`].
784fn reason_from_strategies(reasons: &[&'static str]) -> ReplicaCreateDropReason {
785    if reasons.contains(&GRACEFUL_RECONFIGURATION_STRATEGY_NAME) {
786        ReplicaCreateDropReason::GracefulReconfiguration
787    } else {
788        ReplicaCreateDropReason::Manual
789    }
790}
791
792/// Map an in-memory replica config to a [`ReplicaShape`], or `None` for an
793/// unmanaged replica (which the controller does not own).
794fn replica_shape(config: &mz_controller::clusters::ReplicaConfig) -> Option<ReplicaShape> {
795    use mz_controller::clusters::ReplicaLocation;
796    let ReplicaLocation::Managed(managed) = &config.location else {
797        return None;
798    };
799    Some(ReplicaShape {
800        size: managed.size.clone(),
801        availability_zones: AvailabilityZones(managed.availability_zones.clone()),
802        logging: config.compute.logging.clone(),
803    })
804}
805
806fn on_timeout_from_controller(action: OnTimeout) -> mz_sql::plan::OnTimeoutAction {
807    match action {
808        OnTimeout::Commit => mz_sql::plan::OnTimeoutAction::Commit,
809        OnTimeout::Rollback => mz_sql::plan::OnTimeoutAction::Rollback,
810    }
811}
812
813fn memory_reconfiguration(
814    record: &ReconfigurationRecord,
815) -> mz_catalog::memory::objects::ReconfigurationState {
816    mz_catalog::memory::objects::ReconfigurationState {
817        target: mz_catalog::memory::objects::ReconfigurationTarget {
818            size: record.target.size.clone(),
819            replication_factor: record.target.replication_factor,
820            availability_zones: record.target.availability_zones.0.clone(),
821            logging: record.target.logging.clone(),
822        },
823        deadline: record.deadline,
824        on_timeout: on_timeout_from_controller(record.on_timeout),
825        status: status_from_controller(record.status),
826    }
827}
828
829fn status_from_controller(
830    status: ReconfigurationStatus,
831) -> mz_catalog::memory::objects::ReconfigurationStatus {
832    match status {
833        ReconfigurationStatus::InProgress => {
834            mz_catalog::memory::objects::ReconfigurationStatus::InProgress
835        }
836        ReconfigurationStatus::Finalized => {
837            mz_catalog::memory::objects::ReconfigurationStatus::Finalized
838        }
839        ReconfigurationStatus::TimedOut => {
840            mz_catalog::memory::objects::ReconfigurationStatus::TimedOut
841        }
842        ReconfigurationStatus::Cancelled => {
843            mz_catalog::memory::objects::ReconfigurationStatus::Cancelled
844        }
845        ReconfigurationStatus::ResourceExhausted => {
846            mz_catalog::memory::objects::ReconfigurationStatus::ResourceExhausted
847        }
848    }
849}
850
851fn memory_burst(
852    record: &mz_cluster_controller::ctx::BurstRecord,
853) -> mz_catalog::memory::objects::BurstState {
854    mz_catalog::memory::objects::BurstState {
855        burst_size: record.burst_size.clone(),
856        linger_duration: record.linger_duration,
857        steady_hydrated_at: record.steady_hydrated_at,
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use mz_cluster_controller::strategy::BASELINE_STRATEGY_NAME;
864
865    use super::*;
866
867    #[mz_ore::test]
868    fn test_reason_from_strategies() {
869        use ReplicaCreateDropReason as Reason;
870
871        // The graceful strategy maps to its own reason; the baseline (or no
872        // attribution) is `Manual`.
873        assert!(matches!(
874            reason_from_strategies(&[BASELINE_STRATEGY_NAME]),
875            Reason::Manual
876        ));
877        assert!(matches!(reason_from_strategies(&[]), Reason::Manual));
878        assert!(matches!(
879            reason_from_strategies(&[GRACEFUL_RECONFIGURATION_STRATEGY_NAME]),
880            Reason::GracefulReconfiguration
881        ));
882
883        // A strategy attribution beats the baseline's `Manual`.
884        assert!(matches!(
885            reason_from_strategies(&[
886                BASELINE_STRATEGY_NAME,
887                GRACEFUL_RECONFIGURATION_STRATEGY_NAME,
888            ]),
889            Reason::GracefulReconfiguration
890        ));
891    }
892}