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