Skip to main content

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}