mz_cluster_controller/ctx.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//! The boundary between the controller and its environment.
11//!
12//! [`ClusterControllerCtx`] is the single, strategy-agnostic boundary through which
13//! the controller pulls the signals a tick examines and applies the catalog
14//! mutations it derives. It carries primitive signals in and primitive catalog
15//! mutations out; it has no per-strategy state or vocabulary. The controller
16//! crate knows nothing about the Coordinator. The Coordinator implements this
17//! trait, which is what makes the controller testable against a fake
18//! implementation and extractable later without touching controller code.
19//!
20//! The interface is **pull-based**: a tick fetches only the signals it actually
21//! examines (no eager all-clusters-all-replicas snapshot is pushed in), and the
22//! controller drives what is fetched. Read methods are batched so a separate-task
23//! deployment can bound its round-trips to the Coordinator.
24
25use async_trait::async_trait;
26use mz_compute_types::config::ComputeReplicaLogging;
27use mz_controller_types::{ClusterId, ReplicaId};
28use mz_repr::Timestamp;
29
30// The compare-and-append witness types, and the replica shape they pair with,
31// live in `mz-adapter-types` so the catalog transaction that applies a
32// decision can share them without depending on this crate. They are part of the
33// ctx vocabulary, so re-export them here.
34pub use mz_adapter_types::cluster_state::{
35 AvailabilityZones, BurstRecord, ExpectedClusterState, ReconfigurationRecord,
36 ReconfigurationTarget, ReplicaShape,
37};
38
39/// A replica that actually exists on a cluster, as observed through the ctx.
40#[derive(Clone, Debug)]
41pub struct ObservedReplica {
42 pub replica_id: ReplicaId,
43 pub name: String,
44 pub shape: ReplicaShape,
45}
46
47/// The durable state of a single managed cluster plus its observed replicas, as
48/// pulled through the ctx for one reconcile tick.
49///
50/// This is the input every strategy reads. Unmanaged clusters are not
51/// controller-owned and are not represented here.
52///
53/// The `size`, `replication_factor`, `availability_zones`, and `logging` fields
54/// together are the realized config the cluster is currently serving. The
55/// implicit baseline desires `replication_factor` replicas at that shape.
56#[derive(Clone, Debug)]
57pub struct ClusterState {
58 pub cluster_id: ClusterId,
59 pub size: String,
60 pub replication_factor: u32,
61 pub availability_zones: Vec<String>,
62 pub logging: ComputeReplicaLogging,
63 /// In-flight graceful reconfiguration, if any.
64 pub reconfiguration: Option<ReconfigurationRecord>,
65 /// In-flight hydration burst, if any.
66 pub burst: Option<BurstRecord>,
67 /// The replicas that actually exist on the cluster.
68 pub replicas: Vec<ObservedReplica>,
69}
70
71impl ClusterState {
72 /// The shape the implicit baseline desires: the realized config.
73 pub fn realized_shape(&self) -> ReplicaShape {
74 ReplicaShape {
75 size: self.size.clone(),
76 availability_zones: AvailabilityZones(self.availability_zones.clone()),
77 logging: self.logging.clone(),
78 }
79 }
80
81 /// The compare-and-append witness for decisions derived from this state: the
82 /// durable fields a concurrent `ALTER` could change out from under a tick.
83 pub fn expected(&self) -> ExpectedClusterState {
84 ExpectedClusterState {
85 size: self.size.clone(),
86 replication_factor: self.replication_factor,
87 availability_zones: AvailabilityZones(self.availability_zones.clone()),
88 logging: self.logging.clone(),
89 reconfiguration: self.reconfiguration.clone(),
90 burst: self.burst.clone(),
91 }
92 }
93}
94
95/// A durable state mutation a strategy's `update_state` asks for: cut over the
96/// realized config to a target and/or write or clear the reconfiguration/burst
97/// records. The reconcile kernel pairs it with the [`ExpectedClusterState`] it
98/// was derived from for the compare-and-append guard.
99#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct StateWrite {
101 /// New realized config to cut over to. `None` leaves it unchanged.
102 pub new_size: Option<String>,
103 pub new_replication_factor: Option<u32>,
104 pub new_availability_zones: Option<Vec<String>>,
105 pub new_logging: Option<ComputeReplicaLogging>,
106 /// Write (`Some(Some(_))`), clear (`Some(None)`), or leave unchanged
107 /// (`None`) the reconfiguration record.
108 pub reconfiguration: Option<Option<ReconfigurationRecord>>,
109 /// Write, clear, or leave unchanged the burst record, as above.
110 pub burst: Option<Option<BurstRecord>>,
111}
112
113impl StateWrite {
114 /// Whether this write would actually mutate any durable field.
115 pub fn is_empty(&self) -> bool {
116 // Exhaustive destructure (no `..`): a field added to `StateWrite` is a
117 // compile error here until it's accounted for.
118 let StateWrite {
119 new_size,
120 new_replication_factor,
121 new_availability_zones,
122 new_logging,
123 reconfiguration,
124 burst,
125 } = self;
126 new_size.is_none()
127 && new_replication_factor.is_none()
128 && new_availability_zones.is_none()
129 && new_logging.is_none()
130 && reconfiguration.is_none()
131 && burst.is_none()
132 }
133}
134
135/// A single command the controller emits for the environment to transact. The
136/// apply path interprets these and turns them into catalog operations.
137///
138/// Every variant carries the [`ExpectedClusterState`] the decision was derived
139/// from. The apply path re-reads each target cluster and rejects the whole batch
140/// if any state has since diverged (compare-and-append), so a user `ALTER` that
141/// lands mid-tick cannot let a stale create or drop reshape the replica set
142/// against the new config; the controller recomputes from the new state next
143/// tick.
144#[derive(Clone, Debug)]
145pub enum Decision {
146 /// Create a replica of the given shape under a deterministic fresh name.
147 /// `reasons` records which strategies desired it (for audit attribution).
148 CreateReplica {
149 cluster_id: ClusterId,
150 name: String,
151 shape: ReplicaShape,
152 reasons: Vec<&'static str>,
153 expected: ExpectedClusterState,
154 },
155 /// Drop a specific existing replica. A drop happens exactly when no
156 /// strategy desires the replica, so it carries no strategy attribution;
157 /// the apply path audits every controller drop with the uniform `retired`
158 /// reason.
159 DropReplica {
160 cluster_id: ClusterId,
161 replica_id: ReplicaId,
162 expected: ExpectedClusterState,
163 },
164 /// Apply a durable state write under a compare-and-append guard against
165 /// `expected`.
166 UpdateClusterState {
167 cluster_id: ClusterId,
168 expected: ExpectedClusterState,
169 write: StateWrite,
170 },
171}
172
173/// The outcome of applying one tick's batch of [`Decision`]s.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum ApplyOutcome {
176 /// Every decision in the batch was transacted.
177 Applied,
178 /// At least one decision failed its compare-and-append guard. The whole
179 /// batch is rejected; the controller recomputes next tick.
180 Rejected,
181}
182
183/// The strategy-agnostic pull/apply interface between the controller and its
184/// environment.
185///
186/// The controller depends on exactly this trait. Reads are batched and pulled
187/// on demand; the single write applies a tick's batch under a compare-and-append
188/// guard. Implementations marshal these to wherever the live signals live (for
189/// v1, the Coordinator's catalog and compute/storage controllers, reached over a
190/// channel from the controller's own task, hence the `Send` bound).
191#[async_trait]
192pub trait ClusterControllerCtx: Send {
193 /// Current wall-clock time, as the controller's strategies should see it.
194 fn now(&self) -> Timestamp;
195
196 /// A consistent durable view of the given managed clusters and their
197 /// replicas. Clusters that do not exist or are unmanaged are omitted from
198 /// the result.
199 async fn cluster_states(&mut self, clusters: &[ClusterId]) -> Vec<ClusterState>;
200
201 /// The ids of all managed clusters the controller owns this tick.
202 async fn managed_cluster_ids(&mut self) -> Vec<ClusterId>;
203
204 /// Apply a tick's batch of decisions under their compare-and-append guards.
205 /// Each decision carries the [`ExpectedClusterState`] it was derived from;
206 /// the implementation re-reads every target cluster and, if any has since
207 /// diverged, returns [`ApplyOutcome::Rejected`] without transacting anything.
208 /// Otherwise the batch's catalog operations are transacted together.
209 async fn apply(&mut self, decisions: Vec<Decision>) -> ApplyOutcome;
210}