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 std::collections::BTreeSet;
26
27use async_trait::async_trait;
28use mz_compute_types::config::ComputeReplicaLogging;
29use mz_controller_types::{ClusterId, ReplicaId};
30use mz_repr::Timestamp;
31
32// The compare-and-append witness types, and the replica shape they pair with,
33// live in `mz-adapter-types` so the catalog transaction that applies a
34// decision can share them without depending on this crate. They are part of the
35// ctx vocabulary, so re-export them here.
36pub use mz_adapter_types::cluster_state::{
37 AvailabilityZones, BurstAudit, BurstFinishCause, BurstRecord, ExpectedClusterState, OnTimeout,
38 ReconfigurationAudit, ReconfigurationRecord, ReconfigurationStatus, ReconfigurationTarget,
39 ReplicaShape,
40};
41
42/// A replica that actually exists on a cluster, as observed through the ctx.
43#[derive(Clone, Debug)]
44pub struct ObservedReplica {
45 pub replica_id: ReplicaId,
46 pub name: String,
47 pub shape: ReplicaShape,
48}
49
50/// The durable state of a single managed cluster plus its observed replicas, as
51/// pulled through the ctx for one reconcile tick.
52///
53/// This is the input every strategy reads. Unmanaged clusters are not
54/// controller-owned and are not represented here.
55///
56/// The `size`, `replication_factor`, `availability_zones`, and `logging` fields
57/// together are the realized config the cluster is currently serving. The
58/// implicit baseline desires `replication_factor` replicas at that shape.
59#[derive(Clone, Debug)]
60pub struct ClusterState {
61 pub cluster_id: ClusterId,
62 pub size: String,
63 pub replication_factor: u32,
64 pub availability_zones: Vec<String>,
65 pub logging: ComputeReplicaLogging,
66 /// Latest graceful reconfiguration record, if one has been written.
67 pub reconfiguration: Option<ReconfigurationRecord>,
68 /// In-flight hydration burst, if any.
69 pub burst: Option<BurstRecord>,
70 /// The replicas that actually exist on the cluster.
71 pub replicas: Vec<ObservedReplica>,
72}
73
74impl ClusterState {
75 /// The shape the implicit baseline desires: the realized config.
76 pub fn realized_shape(&self) -> ReplicaShape {
77 ReplicaShape {
78 size: self.size.clone(),
79 availability_zones: AvailabilityZones(self.availability_zones.clone()),
80 logging: self.logging.clone(),
81 }
82 }
83
84 /// The compare-and-append witness for decisions derived from this state: the
85 /// durable fields a concurrent `ALTER` could change out from under a tick.
86 pub fn expected(&self) -> ExpectedClusterState {
87 ExpectedClusterState {
88 size: self.size.clone(),
89 replication_factor: self.replication_factor,
90 availability_zones: AvailabilityZones(self.availability_zones.clone()),
91 logging: self.logging.clone(),
92 reconfiguration: self.reconfiguration.clone(),
93 burst: self.burst.clone(),
94 }
95 }
96}
97
98/// A durable state mutation a strategy's `update_state` asks for: cut over the
99/// realized config to a target and/or write or clear the reconfiguration/burst
100/// records. The reconcile kernel pairs it with the [`ExpectedClusterState`] it
101/// was derived from for the compare-and-append guard.
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub struct StateWrite {
104 /// New realized config to cut over to. `None` leaves it unchanged.
105 pub new_size: Option<String>,
106 pub new_replication_factor: Option<u32>,
107 pub new_availability_zones: Option<Vec<String>>,
108 pub new_logging: Option<ComputeReplicaLogging>,
109 /// Write or clear the reconfiguration record, together with its audit
110 /// intent. `None` leaves the record unchanged.
111 pub reconfiguration: Option<ReconfigurationWrite>,
112 /// Write or clear the burst record, together with its audit intent.
113 /// `None` leaves the record unchanged.
114 pub burst: Option<BurstWrite>,
115}
116
117/// A write to the `reconfiguration` record, bundled with the audit intent
118/// declaring which lifecycle transition the write represents.
119///
120/// Bundling means a writer cannot move the record without deciding, at the same
121/// decision point, what the papertrail should say. The two travel together
122/// through the apply path and are transacted atomically with the state.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct ReconfigurationWrite {
125 /// The record to write, or `None` to clear it.
126 pub record: Option<ReconfigurationRecord>,
127 /// The lifecycle transition to audit. `None` declares that this write is
128 /// not a lifecycle transition and must not emit an event.
129 pub audit: Option<ReconfigurationAudit>,
130}
131
132/// A write to the `burst` record, bundled with its audit intent. See
133/// [`ReconfigurationWrite`]. A bookkeeping rewrite of an existing record (the
134/// linger stamp and its reset) declares `audit: None`.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct BurstWrite {
137 /// The record to write, or `None` to clear it.
138 pub record: Option<BurstRecord>,
139 /// The lifecycle transition to audit, or `None` for a bookkeeping rewrite.
140 pub audit: Option<BurstAudit>,
141}
142
143impl StateWrite {
144 /// Whether this write would actually mutate any durable field.
145 pub fn is_empty(&self) -> bool {
146 // Exhaustive destructure (no `..`): a field added to `StateWrite` is a
147 // compile error here until it's accounted for.
148 let StateWrite {
149 new_size,
150 new_replication_factor,
151 new_availability_zones,
152 new_logging,
153 reconfiguration,
154 burst,
155 } = self;
156 new_size.is_none()
157 && new_replication_factor.is_none()
158 && new_availability_zones.is_none()
159 && new_logging.is_none()
160 && reconfiguration.is_none()
161 && burst.is_none()
162 }
163}
164
165/// A single command the controller emits for the environment to transact. The
166/// apply path interprets these and turns them into catalog operations.
167///
168/// Every variant carries the [`ExpectedClusterState`] the decision was derived
169/// from. The apply path re-reads each target cluster and rejects the whole batch
170/// if any state has since diverged (compare-and-append), so a user `ALTER` that
171/// lands mid-tick cannot let a stale create or drop reshape the replica set
172/// against the new config; the controller recomputes from the new state next
173/// tick.
174#[derive(Clone, Debug)]
175pub enum Decision {
176 /// Create a replica of the given shape under a deterministic fresh name.
177 /// `reasons` records which strategies desired it (for audit attribution).
178 CreateReplica {
179 cluster_id: ClusterId,
180 name: String,
181 shape: ReplicaShape,
182 reasons: Vec<&'static str>,
183 expected: ExpectedClusterState,
184 },
185 /// Drop a specific existing replica. A drop happens exactly when no
186 /// strategy desires the replica, so it carries no strategy attribution;
187 /// the apply path audits every controller drop with the uniform `retired`
188 /// reason.
189 DropReplica {
190 cluster_id: ClusterId,
191 replica_id: ReplicaId,
192 expected: ExpectedClusterState,
193 },
194 /// Apply a durable state write under a compare-and-append guard against
195 /// `expected`.
196 UpdateClusterState {
197 cluster_id: ClusterId,
198 expected: ExpectedClusterState,
199 write: StateWrite,
200 },
201}
202
203/// The outcome of applying one tick's batch of [`Decision`]s.
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub enum ApplyOutcome {
206 /// Every decision in the batch was transacted.
207 Applied,
208 /// At least one decision failed its compare-and-append guard. The whole
209 /// batch is rejected; the controller recomputes next tick.
210 Rejected,
211 /// The batch was rejected because it exceeded the environment's resource
212 /// budget. Nothing was transacted. Unlike a guard rejection, retrying the
213 /// same batch cannot succeed on its own: the controller decides what to
214 /// shed to make room.
215 ResourceExhausted,
216}
217
218/// The strategy-agnostic pull/apply interface between the controller and its
219/// environment.
220///
221/// The controller depends on exactly this trait. Reads are batched and pulled
222/// on demand; the single write applies a tick's batch under a compare-and-append
223/// guard. Implementations marshal these to wherever the live signals live (for
224/// v1, the Coordinator's catalog and compute/storage controllers, reached over a
225/// channel from the controller's own task, hence the `Send` bound).
226#[async_trait]
227pub trait ClusterControllerCtx: Send {
228 /// Current wall-clock time, as the controller's strategies should see it.
229 fn now(&self) -> Timestamp;
230
231 /// A consistent durable view of the given managed clusters and their
232 /// replicas. Clusters that do not exist or are unmanaged are omitted from
233 /// the result.
234 async fn cluster_states(&mut self, clusters: &[ClusterId]) -> Vec<ClusterState>;
235
236 /// The ids of all managed clusters the controller owns this tick.
237 async fn managed_cluster_ids(&mut self) -> Vec<ClusterId>;
238
239 /// Of `replicas` on `cluster`, which have *all* current (non-transient)
240 /// collections on the cluster hydrated. The returned set is a subset of
241 /// `replicas`.
242 ///
243 /// Callers should request only replicas their strategy currently needs. This
244 /// keeps live-signal dependencies local to the strategies that consume them.
245 async fn hydrated_replicas(
246 &mut self,
247 cluster_id: ClusterId,
248 replicas: &[ReplicaId],
249 ) -> BTreeSet<ReplicaId>;
250
251 /// Apply a tick's batch of decisions under their compare-and-append guards.
252 /// Each decision carries the [`ExpectedClusterState`] it was derived from;
253 /// the implementation re-reads every target cluster and, if any has since
254 /// diverged, returns [`ApplyOutcome::Rejected`] without transacting anything.
255 /// Otherwise the batch's catalog operations are transacted together.
256 async fn apply(&mut self, decisions: Vec<Decision>) -> ApplyOutcome;
257}