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}