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