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. Whole-tick reads are batched. Refresh-window catalog inputs
18//! are pulled one cluster at a time and completed with one shared oracle read.
19//! The remaining per-cluster live signals are pulled on demand, so steady
20//! clusters do not pay for signals they do not use.
21//!
22//! The controller owns the replica set of every managed cluster, user and
23//! system alike. A builtin cluster's config-implied replicas are additionally
24//! materialized by `reconcile_builtin_cluster_replicas` at catalog open, which
25//! derives the same target from the same config, so the two converge rather
26//! than compete.
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::sync::Arc;
30use std::time::Duration;
31
32use mz_adapter_types::dyncfgs::CLUSTER_CONTROLLER_TICK_INTERVAL;
33use mz_catalog::memory::objects::{ClusterConfig, ClusterVariant};
34use mz_cluster_controller::ClusterController;
35use mz_cluster_controller::ctx::{
36    ApplyOutcome, AvailabilityZones, ClusterControllerCtx, ClusterState, CreateReason, Decision,
37    ExpectedClusterState, ObservedReplica, OnTimeout, ReconfigurationRecord, ReconfigurationStatus,
38    ReconfigurationTarget, RefreshMvInfo, RefreshWindowClusterInputs, RefreshWindowInputsBatch,
39    ReplicaShape, StateWrite,
40};
41use mz_compute_types::config::ComputeReplicaConfig;
42use mz_controller::clusters::ClusterStatus;
43use mz_controller_types::{ClusterId, ReplicaId};
44use mz_ore::task::spawn;
45use mz_repr::Timestamp;
46use tokio::sync::{mpsc, oneshot};
47use tracing::{debug, warn};
48
49use crate::catalog::{DropObjectInfo, Op, ReplicaCreateDropReason};
50use crate::coord::{ClusterReplicaStatuses, Coordinator, Message};
51use crate::error::AdapterError;
52
53/// A request the controller task marshals to the Coordinator to satisfy one
54/// [`ClusterControllerCtx`] call. Each variant carries a oneshot for the reply.
55///
56/// `ManagedClusterIds` and `ClusterStates` are the per-tick batched reads. The
57/// `ClusterStates` reply also carries `now`. Refresh-window catalog inputs are
58/// pulled one cluster at a time, followed by one shared oracle read.
59/// `HydratedReplicas` is a per-cluster live signal a strategy pulls on demand.
60#[derive(Debug)]
61pub enum ClusterControllerRequest {
62    /// The ids of all managed clusters the controller owns this tick.
63    ManagedClusterIds { tx: oneshot::Sender<Vec<ClusterId>> },
64    /// A consistent durable view of the given clusters and their replicas, plus
65    /// the current time.
66    ClusterStates {
67        clusters: Vec<ClusterId>,
68        tx: oneshot::Sender<(Vec<ClusterState>, Timestamp)>,
69    },
70    /// Of `replicas` on `cluster`, which are online and have all current
71    /// collections hydrated.
72    HydratedReplicas {
73        cluster_id: ClusterId,
74        replicas: Vec<ReplicaId>,
75        tx: oneshot::Sender<BTreeSet<ReplicaId>>,
76    },
77    /// Whether the cluster has any hydratable (dataflow-backed) objects bound to
78    /// it.
79    HasHydratableObjects {
80        cluster_id: ClusterId,
81        tx: oneshot::Sender<bool>,
82    },
83    /// The catalog and storage refresh-window inputs for one scheduled cluster.
84    /// `None` if the cluster no longer qualifies at pull time.
85    RefreshWindowClusterInputs {
86        cluster_id: ClusterId,
87        tx: oneshot::Sender<Option<RefreshWindowClusterInputs>>,
88    },
89    /// One timestamp-oracle read for a completed refresh-window input batch.
90    RefreshWindowReadTs { tx: oneshot::Sender<Timestamp> },
91    /// Apply a tick's batch of decisions under their compare-and-append guards.
92    Apply {
93        decisions: Vec<Decision>,
94        tx: oneshot::Sender<ApplyOutcome>,
95    },
96    /// The current configured reconcile cadence. Read once per tick so a runtime
97    /// change to `cluster_controller_tick_interval` takes effect without a
98    /// restart.
99    TickInterval { tx: oneshot::Sender<Duration> },
100}
101
102struct ReplicaHydrationCheck {
103    replica_id: ReplicaId,
104    compute_hydrated: oneshot::Receiver<bool>,
105}
106
107/// The controller-task side of the boundary: a [`ClusterControllerCtx`] that
108/// marshals every call to the Coordinator over `internal_cmd_tx`.
109struct CoordCtx {
110    internal_cmd_tx: mpsc::UnboundedSender<Message>,
111    /// Latched `now` from the most recent batched read, returned by
112    /// [`ClusterControllerCtx::now`] so a strategy and the kernel see a single
113    /// consistent time per phase.
114    now: Timestamp,
115}
116
117impl CoordCtx {
118    /// Send a request and await its reply. Returns `None` if the Coordinator has
119    /// gone away (shutdown), which the caller treats as "nothing to do".
120    async fn request<T>(
121        &self,
122        make: impl FnOnce(oneshot::Sender<T>) -> ClusterControllerRequest,
123    ) -> Option<T> {
124        let (tx, rx) = oneshot::channel();
125        if self
126            .internal_cmd_tx
127            .send(Message::ClusterControllerRequest(make(tx)))
128            .is_err()
129        {
130            return None;
131        }
132        rx.await.ok()
133    }
134}
135
136#[async_trait::async_trait]
137impl ClusterControllerCtx for CoordCtx {
138    fn now(&self) -> Timestamp {
139        self.now
140    }
141
142    async fn managed_cluster_ids(&mut self) -> Vec<ClusterId> {
143        self.request(|tx| ClusterControllerRequest::ManagedClusterIds { tx })
144            .await
145            .unwrap_or_default()
146    }
147
148    async fn cluster_states(&mut self, clusters: &[ClusterId]) -> Vec<ClusterState> {
149        let clusters = clusters.to_vec();
150        match self
151            .request(|tx| ClusterControllerRequest::ClusterStates { clusters, tx })
152            .await
153        {
154            Some((states, now)) => {
155                self.now = now;
156                states
157            }
158            None => Vec::new(),
159        }
160    }
161
162    async fn hydrated_replicas(
163        &mut self,
164        cluster_id: ClusterId,
165        replicas: &[ReplicaId],
166    ) -> BTreeSet<ReplicaId> {
167        let replicas = replicas.to_vec();
168        self.request(|tx| ClusterControllerRequest::HydratedReplicas {
169            cluster_id,
170            replicas,
171            tx,
172        })
173        .await
174        .unwrap_or_default()
175    }
176
177    async fn has_hydratable_objects(&mut self, cluster_id: ClusterId) -> bool {
178        self.request(|tx| ClusterControllerRequest::HasHydratableObjects { cluster_id, tx })
179            .await
180            // A lost reply means shutdown; "no objects" arms nothing, which is
181            // the safe answer.
182            .unwrap_or(false)
183    }
184
185    async fn refresh_window_inputs(
186        &mut self,
187        cluster_ids: &[ClusterId],
188    ) -> Option<RefreshWindowInputsBatch> {
189        let mut cluster_inputs = BTreeMap::new();
190        for (index, &cluster_id) in cluster_ids.iter().enumerate() {
191            if index > 0 {
192                // The coordinator prioritizes its internal command channel. Give
193                // it a chance to service already-queued user commands instead of
194                // keeping that channel continuously ready for the whole batch.
195                tokio::task::yield_now().await;
196            }
197            let inputs = self
198                .request(|tx| ClusterControllerRequest::RefreshWindowClusterInputs {
199                    cluster_id,
200                    tx,
201                })
202                .await
203                .flatten();
204            if let Some(inputs) = inputs {
205                cluster_inputs.insert(cluster_id, inputs);
206            }
207        }
208        if cluster_inputs.is_empty() {
209            return None;
210        }
211
212        let read_ts = self
213            .request(|tx| ClusterControllerRequest::RefreshWindowReadTs { tx })
214            .await?;
215        Some(RefreshWindowInputsBatch {
216            read_ts,
217            cluster_inputs,
218        })
219    }
220
221    async fn apply(&mut self, decisions: Vec<Decision>) -> ApplyOutcome {
222        self.request(|tx| ClusterControllerRequest::Apply { decisions, tx })
223            .await
224            // A lost reply means shutdown; treat as rejected so we make no
225            // further claims about the catalog state.
226            .unwrap_or(ApplyOutcome::Rejected)
227    }
228}
229
230impl Coordinator {
231    /// Spawn the cluster controller task.
232    ///
233    /// The task ticks at [`CLUSTER_CONTROLLER_TICK_INTERVAL`], re-read each tick
234    /// via a [`ClusterControllerRequest::TickInterval`] round-trip so a runtime
235    /// change takes effect without a restart. It owns the controller and a
236    /// [`CoordCtx`] that marshals back to this Coordinator.
237    ///
238    /// The interval is the fallback cadence: `reconcile_now` cuts the
239    /// sleep short after a catalog transaction changes durable cluster state.
240    /// The notification only wakes the task. The tick still pulls fresh state
241    /// through the coordinator loop, and the controller's own applies wake it
242    /// again at the cost of one no-op tick.
243    pub(crate) fn spawn_cluster_controller_task(&self) {
244        let internal_cmd_tx = self.internal_cmd_tx.clone();
245        let reconcile_now = Arc::clone(&self.reconcile_now);
246        // A shared handle: dyncfg updates land in the same underlying values, so
247        // the controller task sees flag flips without any push.
248        let dyncfgs = self.catalog().system_config().dyncfgs().clone();
249
250        spawn(|| "cluster_controller", async move {
251            let controller = ClusterController::new(dyncfgs);
252            let mut ctx = CoordCtx {
253                internal_cmd_tx,
254                now: Timestamp::MIN,
255            };
256
257            loop {
258                // Re-read the cadence each tick so a runtime change takes effect.
259                // A lost reply means the Coordinator is gone; stop ticking.
260                let Some(interval) = ctx
261                    .request(|tx| ClusterControllerRequest::TickInterval { tx })
262                    .await
263                else {
264                    break;
265                };
266                tokio::select! {
267                    _ = tokio::time::sleep(interval.max(Duration::from_millis(1))) => {}
268                    _ = reconcile_now.notified() => {}
269                }
270
271                if ctx.internal_cmd_tx.is_closed() {
272                    // Coordinator gone; stop ticking.
273                    break;
274                }
275                controller.reconcile(&mut ctx).await;
276            }
277        });
278    }
279
280    /// Handle one [`ClusterControllerRequest`] on the coordinator loop.
281    ///
282    /// The controller is inactive while the deployment is in read-only mode (a
283    /// 0dt upgrade, where it must not write the catalog). When inactive, reads
284    /// report no managed clusters (so the controller finds nothing to
285    /// reconcile) and applies are rejected: the task still wakes each tick and
286    /// sends one `ManagedClusterIds` request, but that request early-returns
287    /// here and no catalog state is read or written. The task keeps ticking, so
288    /// the controller reactivates on its own once the deployment promotes out of
289    /// read-only mode.
290    #[mz_ore::instrument(level = "debug")]
291    pub(crate) async fn handle_cluster_controller_request(
292        &mut self,
293        request: ClusterControllerRequest,
294    ) {
295        let active = !self.controller.read_only();
296
297        match request {
298            ClusterControllerRequest::ManagedClusterIds { tx } => {
299                let ids = if active {
300                    self.catalog()
301                        .clusters()
302                        .filter(|c| c.is_managed())
303                        .map(|c| c.id)
304                        .collect()
305                } else {
306                    Vec::new()
307                };
308                let _ = tx.send(ids);
309            }
310            ClusterControllerRequest::ClusterStates { clusters, tx } => {
311                let now = Timestamp::from(self.now());
312                // Only ever asked about clusters the controller is reconciling
313                // this tick, which the inactive `ManagedClusterIds` gate above
314                // makes empty, so no guard is needed here.
315                let states: Vec<_> = clusters
316                    .into_iter()
317                    .filter_map(|id| self.observe_cluster_state(id))
318                    .collect();
319                let _ = tx.send((states, now));
320            }
321            ClusterControllerRequest::HydratedReplicas {
322                cluster_id,
323                replicas,
324                tx,
325            } => {
326                let checks = self.start_hydration_checks(cluster_id, replicas);
327                // Start the controller calls on the coordinator loop, then wait
328                // for compute's replies off-loop. The compute check can wait on
329                // the compute instance task.
330                spawn(|| "cluster_controller_hydration_probe", async move {
331                    let mut hydrated = BTreeSet::new();
332                    for check in checks {
333                        if check.compute_hydrated.await.unwrap_or(false) {
334                            hydrated.insert(check.replica_id);
335                        }
336                    }
337                    let _ = tx.send(hydrated);
338                });
339            }
340            ClusterControllerRequest::HasHydratableObjects { cluster_id, tx } => {
341                let _ = tx.send(self.cluster_has_hydratable_objects(cluster_id));
342            }
343            ClusterControllerRequest::RefreshWindowClusterInputs { cluster_id, tx } => {
344                let _ = tx.send(self.refresh_window_catalog_inputs(cluster_id));
345            }
346            ClusterControllerRequest::RefreshWindowReadTs { tx } => {
347                // The oracle read is a network round trip to the Postgres or
348                // CRDB-backed timestamp oracle. It must not run on the serial
349                // coordinator loop.
350                let oracle = self.get_local_timestamp_oracle();
351                spawn(|| "cluster_controller_refresh_window_read_ts", async move {
352                    let read_ts = oracle.read_ts().await;
353                    let _ = tx.send(read_ts);
354                });
355            }
356            ClusterControllerRequest::Apply { decisions, tx } => {
357                let outcome = if active {
358                    self.apply_cluster_decisions(decisions).await
359                } else {
360                    ApplyOutcome::Rejected
361                };
362                let _ = tx.send(outcome);
363            }
364            ClusterControllerRequest::TickInterval { tx } => {
365                let interval =
366                    CLUSTER_CONTROLLER_TICK_INTERVAL.get(self.catalog().system_config().dyncfgs());
367                let _ = tx.send(interval);
368            }
369        }
370    }
371
372    /// Build the controller's view of one managed cluster from the catalog.
373    /// Returns `None` for a missing or unmanaged cluster.
374    fn observe_cluster_state(&self, cluster_id: ClusterId) -> Option<ClusterState> {
375        let cluster = self.catalog().try_get_cluster(cluster_id)?;
376        let ClusterVariant::Managed(managed) = &cluster.config.variant else {
377            return None;
378        };
379        // The witness fields come from the same projection the compare-and-append
380        // check uses, so the state a decision is derived from and the state the
381        // apply path checks against cannot drift.
382        let expected = crate::catalog::cluster_state::project_expected(managed);
383
384        // All replicas, with the raw traits the controller's ownership test
385        // (`ObservedReplica::owned_shape`) classifies on.
386        let replicas = cluster
387            .replicas()
388            .map(|replica| ObservedReplica {
389                replica_id: replica.replica_id,
390                name: replica.name.clone(),
391                shape: replica_shape(&replica.config),
392                internal: replica.config.location.internal(),
393                billed_as: replica.config.location.billed_as().is_some(),
394                pending: replica.config.location.pending(),
395            })
396            .collect();
397
398        Some(ClusterState {
399            cluster_id,
400            size: expected.size,
401            replication_factor: expected.replication_factor,
402            availability_zones: expected.availability_zones.0,
403            logging: expected.logging,
404            arrangement_compression: expected.arrangement_compression,
405            schedule: expected.schedule,
406            auto_scaling_policy: expected.auto_scaling_policy,
407            reconfiguration: expected.reconfiguration,
408            burst: expected.burst,
409            replicas,
410        })
411    }
412
413    /// Whether the cluster has any hydratable objects bound to it, backing the
414    /// controller's [`ClusterControllerCtx::has_hydratable_objects`] pull (see
415    /// the trait method for the approximation contract and why mismatches with
416    /// the hydration check are self-healing).
417    fn cluster_has_hydratable_objects(&self, cluster_id: ClusterId) -> bool {
418        let Some(cluster) = self.catalog().try_get_cluster(cluster_id) else {
419            return false;
420        };
421        cluster
422            .bound_objects
423            .iter()
424            .any(|id| self.catalog().get_entry(id).item().is_hydratable())
425    }
426
427    /// Starts per-replica hydration checks for `cluster_id`.
428    ///
429    /// Returns only checks for replicas whose processes are all online, that
430    /// are already storage-hydrated, and that are known to the compute
431    /// controller. The compute receiver completes off the coordinator loop.
432    fn start_hydration_checks(
433        &self,
434        cluster_id: ClusterId,
435        replicas: Vec<ReplicaId>,
436    ) -> Vec<ReplicaHydrationCheck> {
437        use mz_catalog::memory::objects::CatalogItem;
438
439        // Materialized views pinned to a replica (via `IN CLUSTER ... REPLICA`)
440        // are only ever installed on that replica, so any other replica can
441        // never report them hydrated. Collect the cluster's pinned MVs once,
442        // then exclude the ones pinned elsewhere from each replica's hydration
443        // check. Otherwise a graceful reconfiguration's cut-over to a fresh
444        // replica set would wait forever for the new replicas to hydrate an MV
445        // bound to a replica being replaced, then roll back at the deadline
446        // (leaving the old replica, and the targeted MV, in place). Indexes
447        // cannot be replica-pinned, so MVs are the only case.
448        let pinned_mvs: Vec<(ReplicaId, mz_repr::GlobalId)> = self
449            .catalog()
450            .try_get_cluster(cluster_id)
451            .into_iter()
452            .flat_map(|cluster| cluster.bound_objects.iter())
453            .filter_map(|id| match self.catalog().get_entry(id).item() {
454                CatalogItem::MaterializedView(mv) => mv
455                    .target_replica
456                    .map(|target| (target, mv.global_id_writes())),
457                _ => None,
458            })
459            .collect();
460
461        let mut checks = Vec::new();
462        for replica_id in replicas {
463            // Skip replicas that are not online. We wait for a replica to be
464            // online even when it has no objects that need hydration on it
465            // (e.g. a single-replica source).
466            let status = self
467                .cluster_replica_statuses
468                .try_get_cluster_replica_statuses(cluster_id, replica_id)
469                .map(ClusterReplicaStatuses::cluster_replica_status);
470            if !matches!(status, Some(ClusterStatus::Online)) {
471                continue;
472            }
473            let exclude: BTreeSet<mz_repr::GlobalId> = pinned_mvs
474                .iter()
475                .filter(|(target, _)| *target != replica_id)
476                .map(|(_, id)| *id)
477                .collect();
478            let compute_fut = match self.controller.compute.collections_hydrated_for_replicas(
479                cluster_id,
480                vec![replica_id],
481                exclude.clone(),
482            ) {
483                Ok(fut) => fut,
484                // The replica is not known to the compute controller. Treat it
485                // as not hydrated.
486                Err(_) => continue,
487            };
488            let storage_hydrated = match self.controller.storage.collections_hydrated_on_replicas(
489                Some(vec![replica_id]),
490                &cluster_id,
491                &exclude,
492            ) {
493                Ok(hydrated) => hydrated,
494                Err(_) => continue,
495            };
496            if storage_hydrated {
497                checks.push(ReplicaHydrationCheck {
498                    replica_id,
499                    compute_hydrated: compute_fut,
500                });
501            }
502        }
503        checks
504    }
505
506    /// The catalog- and storage-derived refresh-window signals for one scheduled
507    /// cluster (the system compaction estimate and each bound REFRESH
508    /// materialized view's storage write frontier and refresh schedule), or
509    /// `None` if the cluster is missing, unmanaged, or not scheduled `ON
510    /// REFRESH`.
511    ///
512    /// The oracle read timestamp completing [`RefreshWindowInputsBatch`] is
513    /// deliberately not fetched here: this runs on the coordinator loop, and
514    /// the oracle read is a network round-trip the request handler performs on
515    /// a spawned task instead.
516    ///
517    /// The MV write frontier is carried through with full fidelity as the
518    /// `Antichain` the storage controller reports. The on-refresh strategy
519    /// compares against it directly.
520    fn refresh_window_catalog_inputs(
521        &self,
522        cluster_id: ClusterId,
523    ) -> Option<RefreshWindowClusterInputs> {
524        use mz_catalog::memory::objects::CatalogItem;
525
526        let cluster = self.catalog().try_get_cluster(cluster_id)?;
527        let ClusterVariant::Managed(managed) = &cluster.config.variant else {
528            return None;
529        };
530        if !matches!(
531            managed.schedule,
532            mz_sql::plan::ClusterSchedule::Refresh { .. }
533        ) {
534            return None;
535        }
536
537        let refresh_mvs = cluster
538            .bound_objects
539            .iter()
540            .filter_map(|id| {
541                let CatalogItem::MaterializedView(mv) = self.catalog().get_entry(id).item() else {
542                    return None;
543                };
544                let refresh_schedule = mv.refresh_schedule.clone()?;
545                // The storage controller knows about every MV in the catalog. The
546                // write frontier is passed through with full fidelity as the
547                // `Antichain` reported here.
548                let (_since, write_frontier) = self
549                    .controller
550                    .storage
551                    .collection_frontiers(mv.global_id_writes())
552                    .expect("storage controller knows about catalog MVs");
553                Some(RefreshMvInfo {
554                    id: mv.global_id_writes(),
555                    write_frontier,
556                    refresh_schedule,
557                })
558            })
559            .collect();
560
561        let compaction_estimate = self
562            .catalog()
563            .system_config()
564            .cluster_refresh_mv_compaction_estimate();
565
566        Some(RefreshWindowClusterInputs {
567            compaction_estimate,
568            refresh_mvs,
569        })
570    }
571
572    /// Apply one batch of decisions under their compare-and-append guards.
573    ///
574    /// The kernel calls this once per tick phase: a phase-1 batch is all
575    /// `UpdateClusterState`, a phase-2 batch is all create/drop. Either batch may
576    /// in principle be mixed; this handles both. The work is staged across four
577    /// steps: collect the per-cluster guards, pre-allocate the ids the creates
578    /// need, build the mutation ops, then commit ops and guards in one
579    /// transaction (see [`Self::commit_with_checks`] for why the guard holds).
580    /// Any step that finds the batch incoherent rejects it, and the controller
581    /// recomputes next tick.
582    async fn apply_cluster_decisions(&mut self, decisions: Vec<Decision>) -> ApplyOutcome {
583        let checks = Self::partition_checks(&decisions);
584
585        // Pre-allocate replica ids before the apply transaction (each allocation
586        // is its own durable commit, so it cannot happen inside the transaction).
587        let Some(replica_ids) = self.allocate_replica_ids_for_creates(&decisions).await else {
588            return ApplyOutcome::Rejected;
589        };
590
591        let Some(mutations) = self.build_mutation_ops(decisions, replica_ids) else {
592            return ApplyOutcome::Rejected;
593        };
594        if mutations.is_empty() {
595            // Nothing to apply, so the checks guard nothing. Skip the transaction
596            // rather than commit a check-only batch, which would still cost a
597            // durable round-trip.
598            return ApplyOutcome::Applied;
599        }
600
601        self.commit_with_checks(checks, mutations).await
602    }
603
604    /// The compare-and-append guards for a decision batch: one
605    /// `(cluster_id, expected)` per distinct cluster, in first-seen order. All
606    /// of a cluster's decisions in a tick come from one snapshot, so they share
607    /// one `expected` witness and one guard covers them all.
608    fn partition_checks(decisions: &[Decision]) -> Vec<(ClusterId, ExpectedClusterState)> {
609        let mut checks: Vec<(ClusterId, ExpectedClusterState)> = Vec::new();
610        let mut seen_clusters = BTreeSet::new();
611        for decision in decisions {
612            let (cluster_id, expected) = match decision {
613                Decision::CreateReplica {
614                    cluster_id,
615                    expected,
616                    ..
617                }
618                | Decision::DropReplica {
619                    cluster_id,
620                    expected,
621                    ..
622                }
623                | Decision::UpdateClusterState {
624                    cluster_id,
625                    expected,
626                    ..
627                } => (*cluster_id, expected),
628            };
629            if seen_clusters.insert(cluster_id) {
630                checks.push((cluster_id, expected.clone()));
631            } else {
632                debug_assert!(
633                    checks
634                        .iter()
635                        .any(|(c, e)| *c == cluster_id && e == expected),
636                    "decisions for a cluster in one tick must share one expected witness",
637                );
638            }
639        }
640        checks
641    }
642
643    /// Pre-allocate one replica id per `CreateReplica` decision, in the order the
644    /// creates appear (which is the order [`Self::build_mutation_ops`] consumes
645    /// them). Returns `None` if any allocation fails, which rejects the batch.
646    ///
647    /// `Op::CreateClusterReplica` carries a pre-allocated id, so we allocate
648    /// out-of-band here, before the apply transaction. Each allocation commits
649    /// durably, so we take a fresh write ts per allocation: two commits must not
650    /// share a timestamp.
651    async fn allocate_replica_ids_for_creates(
652        &mut self,
653        decisions: &[Decision],
654    ) -> Option<Vec<ReplicaId>> {
655        let mut replica_ids = Vec::new();
656        for decision in decisions {
657            let Decision::CreateReplica { cluster_id, .. } = decision else {
658                continue;
659            };
660            let id_ts = self.get_catalog_write_ts().await;
661            let result = self
662                .catalog()
663                .allocate_replica_ids(*cluster_id, 1, id_ts)
664                .await;
665            match result {
666                Ok(ids) => {
667                    replica_ids.push(ids.into_iter().next().expect("allocated one replica id"))
668                }
669                Err(err) => {
670                    warn!(%cluster_id, "cluster controller could not allocate replica id: {err}");
671                    return None;
672                }
673            }
674        }
675        Some(replica_ids)
676    }
677
678    /// Turn a decision batch into the catalog mutation ops to transact, consuming
679    /// the `replica_ids` pre-allocated for the creates (one per `CreateReplica`,
680    /// in order). Returns `None` if a target cluster has vanished or gone
681    /// unmanaged, which makes the batch incoherent and rejects it.
682    fn build_mutation_ops(
683        &self,
684        decisions: Vec<Decision>,
685        replica_ids: Vec<ReplicaId>,
686    ) -> Option<Vec<Op>> {
687        let mut replica_ids = replica_ids.into_iter();
688        let mut mutations = Vec::new();
689        let mut drops = Vec::new();
690        for decision in decisions {
691            match decision {
692                Decision::UpdateClusterState {
693                    cluster_id, write, ..
694                } => match self.build_update_cluster_config_op(cluster_id, &write) {
695                    Some(op) => mutations.push(op),
696                    // The cluster vanished. The batch is no longer coherent.
697                    None => return None,
698                },
699                Decision::CreateReplica {
700                    cluster_id,
701                    name,
702                    shape,
703                    reason,
704                    ..
705                } => {
706                    let replica_id = replica_ids.next().expect("one pre-allocated id per create");
707                    let reason = audit_reason_for_create(reason);
708                    match self.build_create_replica_op(cluster_id, replica_id, name, &shape, reason)
709                    {
710                        Ok(Some(op)) => mutations.push(op),
711                        Ok(None) => return None,
712                        Err(err) => {
713                            warn!(%cluster_id, "cluster controller could not build replica create: {err}");
714                            return None;
715                        }
716                    }
717                }
718                Decision::DropReplica {
719                    cluster_id,
720                    replica_id,
721                    ..
722                } => {
723                    // The replica may have vanished since the decisions were
724                    // derived (a user DDL landed between the tick's read and
725                    // this apply). The in-transaction witness check would
726                    // reject such a stale batch, but resource-limit validation
727                    // runs before the transaction and panics on a missing
728                    // replica, so reject the batch here instead.
729                    if self
730                        .catalog()
731                        .try_get_cluster_replica(cluster_id, replica_id)
732                        .is_none()
733                    {
734                        return None;
735                    }
736                    drops.push(DropObjectInfo::ClusterReplica((
737                        cluster_id,
738                        replica_id,
739                        ReplicaCreateDropReason::Retired,
740                    )));
741                }
742            }
743        }
744        if !drops.is_empty() {
745            mutations.push(Op::DropObjects(drops));
746        }
747        Some(mutations)
748    }
749
750    /// Prepend the per-cluster compare-and-append `checks` to `mutations` and
751    /// transact them together.
752    ///
753    /// The checks run inside the transaction, before any mutation, so they cannot
754    /// be separated from the commit they guard. A cluster whose durable state has
755    /// diverged from what the decisions were derived from (e.g. a user `ALTER`
756    /// landed mid-tick) aborts the whole batch, so a stale create or drop can
757    /// never reshape the replica set against the config the `ALTER` has since
758    /// established (in particular, a stale drop cannot retire a replica the
759    /// `ALTER` has just made desired). On rejection nothing is applied.
760    async fn commit_with_checks(
761        &mut self,
762        checks: Vec<(ClusterId, ExpectedClusterState)>,
763        mutations: Vec<Op>,
764    ) -> ApplyOutcome {
765        let mut ops: Vec<Op> = checks
766            .into_iter()
767            .map(|(cluster_id, expected)| Op::CheckClusterState {
768                cluster_id,
769                expected,
770            })
771            .collect();
772        ops.extend(mutations);
773
774        match self.catalog_transact(None, ops).await {
775            Ok(()) => ApplyOutcome::Applied,
776            Err(AdapterError::ClusterStateChanged { .. }) => {
777                // A concurrent `ALTER` moved a cluster's durable state out from
778                // under the decisions. Expected, so the controller recomputes
779                // next tick.
780                ApplyOutcome::Rejected
781            }
782            Err(AdapterError::ReadOnly) => {
783                // The controller is quiesced while read-only (see
784                // `handle_cluster_controller_request`), so this is normally
785                // unreachable; if reached it's expected and not actionable, not
786                // a failure to surface.
787                debug!("cluster controller apply skipped in read-only mode");
788                ApplyOutcome::Rejected
789            }
790            Err(AdapterError::ResourceExhaustion { .. }) => {
791                // The batch cannot fit the resource budget. Report the fact and
792                // leave the reaction (what, if anything, to shed) to the kernel.
793                debug!("cluster controller apply exceeded the resource budget");
794                ApplyOutcome::ResourceExhausted
795            }
796            Err(err) => {
797                warn!("cluster controller apply failed: {err}");
798                ApplyOutcome::Rejected
799            }
800        }
801    }
802
803    /// Build an [`Op::UpdateClusterConfig`] that applies `write`'s deltas to the
804    /// cluster's current in-memory config, or `None` if the cluster is gone or
805    /// unmanaged. The write was guard-checked against the same state, so this is
806    /// the realized cut-over / record write.
807    fn build_update_cluster_config_op(
808        &self,
809        cluster_id: ClusterId,
810        write: &StateWrite,
811    ) -> Option<Op> {
812        let cluster = self.catalog().try_get_cluster(cluster_id)?;
813        let mut config = cluster.config.clone();
814        let ClusterConfig {
815            variant: ClusterVariant::Managed(managed),
816            ..
817        } = &mut config
818        else {
819            return None;
820        };
821        // Exhaustive destructure of the source (no `..`): a field added to
822        // `StateWrite` is a compile error here until it's overlaid onto the
823        // managed config. We cannot destructure `managed` itself. It carries
824        // fields the controller does not model (`workload_class`,
825        // `optimizer_feature_overrides`) that this overlay must leave untouched.
826        let StateWrite {
827            new_size,
828            new_replication_factor,
829            new_availability_zones,
830            new_logging,
831            new_arrangement_compression,
832            reconfiguration,
833            burst,
834        } = write;
835        if let Some(size) = new_size {
836            managed.size = size.clone();
837        }
838        if let Some(rf) = new_replication_factor {
839            managed.replication_factor = *rf;
840        }
841        if let Some(azs) = new_availability_zones {
842            managed.availability_zones = azs.clone();
843        }
844        if let Some(logging) = new_logging {
845            managed.logging = logging.clone();
846        }
847        if let Some(arrangement_compression) = new_arrangement_compression {
848            managed.arrangement_compression = *arrangement_compression;
849        }
850        if let Some(reconfiguration) = reconfiguration {
851            managed.reconfiguration = reconfiguration.record.as_ref().map(memory_reconfiguration);
852        }
853        if let Some(burst) = burst {
854            managed.burst = burst.record.as_ref().map(memory_burst);
855        }
856        // The audit intents travel with the write, declared by the strategy at
857        // the decision point. We pass them through untouched so the events are
858        // emitted in the same catalog transaction as the state they describe.
859        let reconfiguration_audit = write.reconfiguration.as_ref().and_then(|w| w.audit);
860        let burst_audit = write.burst.as_ref().and_then(|w| w.audit);
861        Some(Op::UpdateClusterConfig {
862            id: cluster_id,
863            name: cluster.name.clone(),
864            config,
865            reconfiguration_audit,
866            burst_audit,
867        })
868    }
869
870    /// Build an [`Op::CreateClusterReplica`] for a desired replica `shape` on
871    /// `cluster_id` with the pre-allocated `replica_id`, attributed to `reason`.
872    /// Returns `Ok(None)` if the cluster is gone or unmanaged.
873    fn build_create_replica_op(
874        &self,
875        cluster_id: ClusterId,
876        replica_id: ReplicaId,
877        name: String,
878        shape: &ReplicaShape,
879        reason: ReplicaCreateDropReason,
880    ) -> Result<Option<Op>, mz_catalog::memory::error::Error> {
881        let Some(cluster) = self.catalog().try_get_cluster(cluster_id) else {
882            return Ok(None);
883        };
884        if !cluster.is_managed() {
885            return Ok(None);
886        }
887        let owner_id = cluster.owner_id;
888
889        let location = mz_catalog::durable::ReplicaLocation::Managed {
890            // Concretized from the cluster config below; left empty here.
891            availability_zones: Vec::new(),
892            billed_as: None,
893            internal: false,
894            size: shape.size.clone(),
895            pending: false,
896        };
897        let azs: Option<&[String]> = if shape.availability_zones.0.is_empty() {
898            None
899        } else {
900            Some(&shape.availability_zones.0)
901        };
902        let location = self.catalog().concretize_replica_location(
903            location,
904            &self
905                .catalog()
906                .get_role_allowed_cluster_sizes(&Some(owner_id)),
907            azs,
908            false,
909        )?;
910
911        let config = mz_controller::clusters::ReplicaConfig {
912            location,
913            compute: ComputeReplicaConfig {
914                logging: shape.logging.clone(),
915                arrangement_compression: shape.arrangement_compression,
916            },
917        };
918
919        Ok(Some(Op::CreateClusterReplica {
920            cluster_id,
921            replica_id,
922            name,
923            config,
924            owner_id,
925            reason,
926        }))
927    }
928}
929
930/// Map a create decision's [`CreateReason`] to the audit reason carried on the
931/// create event. `Baseline` audits [`ReplicaCreateDropReason::Manual`], the tag
932/// for replicas the user's own cluster config calls for. The match is
933/// exhaustive, so a new `CreateReason` variant is a compile error here instead
934/// of a silent `Manual`.
935///
936/// Drops never come through here: a drop happens exactly when no strategy
937/// desires the replica, so it carries no attribution and is uniformly audited
938/// [`ReplicaCreateDropReason::Retired`].
939fn audit_reason_for_create(reason: CreateReason) -> ReplicaCreateDropReason {
940    match reason {
941        CreateReason::Baseline => ReplicaCreateDropReason::Manual,
942        CreateReason::GracefulReconfiguration => ReplicaCreateDropReason::GracefulReconfiguration,
943        CreateReason::HydrationBurst => ReplicaCreateDropReason::HydrationBurst,
944        CreateReason::OnRefresh(decision) => ReplicaCreateDropReason::OnRefresh(decision),
945    }
946}
947
948/// Map an in-memory replica config to a [`ReplicaShape`], or `None` for an
949/// unmanaged replica (which the controller does not own).
950fn replica_shape(config: &mz_controller::clusters::ReplicaConfig) -> Option<ReplicaShape> {
951    use mz_controller::clusters::ReplicaLocation;
952    let ReplicaLocation::Managed(managed) = &config.location else {
953        return None;
954    };
955    Some(ReplicaShape {
956        size: managed.size.clone(),
957        availability_zones: AvailabilityZones(managed.availability_zones.clone()),
958        logging: config.compute.logging.clone(),
959        arrangement_compression: config.compute.arrangement_compression,
960    })
961}
962
963fn on_timeout_from_controller(action: OnTimeout) -> mz_sql::plan::OnTimeoutAction {
964    match action {
965        OnTimeout::Commit => mz_sql::plan::OnTimeoutAction::Commit,
966        OnTimeout::Rollback => mz_sql::plan::OnTimeoutAction::Rollback,
967    }
968}
969
970fn memory_reconfiguration(
971    record: &ReconfigurationRecord,
972) -> mz_catalog::memory::objects::ReconfigurationState {
973    // Destructure the source (no `..`): a field added to the controller type is a
974    // compile error here until it's carried across. The target is the same.
975    let ReconfigurationRecord {
976        target,
977        deadline,
978        on_timeout,
979        status,
980    } = record;
981    let ReconfigurationTarget {
982        size,
983        replication_factor,
984        availability_zones,
985        logging,
986        arrangement_compression,
987    } = target;
988    mz_catalog::memory::objects::ReconfigurationState {
989        target: mz_catalog::memory::objects::ReconfigurationTarget {
990            size: size.clone(),
991            replication_factor: *replication_factor,
992            availability_zones: availability_zones.0.clone(),
993            logging: logging.clone(),
994            arrangement_compression: *arrangement_compression,
995        },
996        deadline: *deadline,
997        on_timeout: on_timeout_from_controller(*on_timeout),
998        status: status_from_controller(*status),
999    }
1000}
1001
1002fn status_from_controller(
1003    status: ReconfigurationStatus,
1004) -> mz_catalog::memory::objects::ReconfigurationStatus {
1005    match status {
1006        ReconfigurationStatus::InProgress => {
1007            mz_catalog::memory::objects::ReconfigurationStatus::InProgress
1008        }
1009        ReconfigurationStatus::Finalized => {
1010            mz_catalog::memory::objects::ReconfigurationStatus::Finalized
1011        }
1012        ReconfigurationStatus::TimedOut => {
1013            mz_catalog::memory::objects::ReconfigurationStatus::TimedOut
1014        }
1015        ReconfigurationStatus::Cancelled => {
1016            mz_catalog::memory::objects::ReconfigurationStatus::Cancelled
1017        }
1018        ReconfigurationStatus::ResourceExhausted => {
1019            mz_catalog::memory::objects::ReconfigurationStatus::ResourceExhausted
1020        }
1021    }
1022}
1023
1024fn memory_burst(
1025    record: &mz_cluster_controller::ctx::BurstRecord,
1026) -> mz_catalog::memory::objects::BurstState {
1027    // Destructure the source (no `..`): a field added to the controller type is a
1028    // compile error here until it's carried across.
1029    let mz_cluster_controller::ctx::BurstRecord {
1030        burst_size,
1031        linger_duration,
1032        steady_hydrated_at,
1033    } = record;
1034    mz_catalog::memory::objects::BurstState {
1035        burst_size: burst_size.clone(),
1036        linger_duration: *linger_duration,
1037        steady_hydrated_at: *steady_hydrated_at,
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[mz_ore::test]
1046    fn test_audit_reason_for_create() {
1047        use ReplicaCreateDropReason as Reason;
1048        use mz_cluster_controller::ctx::RefreshWindowDecision;
1049        use mz_repr::GlobalId;
1050
1051        // Each variant maps to its own audit reason, with the baseline
1052        // auditing `Manual`.
1053        assert!(matches!(
1054            audit_reason_for_create(CreateReason::Baseline),
1055            Reason::Manual
1056        ));
1057        assert!(matches!(
1058            audit_reason_for_create(CreateReason::GracefulReconfiguration),
1059            Reason::GracefulReconfiguration
1060        ));
1061        assert!(matches!(
1062            audit_reason_for_create(CreateReason::HydrationBurst),
1063            Reason::HydrationBurst
1064        ));
1065
1066        // The on-refresh reason carries the create's window decision through
1067        // to the audit detail intact.
1068        let decision = RefreshWindowDecision {
1069            objects_needing_refresh: vec![GlobalId::User(1)],
1070            objects_needing_compaction: vec![GlobalId::User(2)],
1071            hydration_time_estimate: Duration::from_secs(7),
1072        };
1073        match audit_reason_for_create(CreateReason::OnRefresh(decision.clone())) {
1074            Reason::OnRefresh(carried) => assert_eq!(carried, decision),
1075            other => panic!("expected an on-refresh reason, got {other:?}"),
1076        }
1077    }
1078}