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 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}