mz_adapter_types/cluster_state.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//! Plain-data mirror of a managed cluster's durable configuration.
11//!
12//! These types carry the slices of a managed cluster's config that a caller
13//! reasons over without touching the catalog or SQL layers. Keeping them free
14//! of catalog and SQL dependencies lets one component reason over the config
15//! and another project the live config onto the same types, without either
16//! depending on the other.
17//!
18//! [`ExpectedClusterState`] is a compare-and-append witness. A caller captures
19//! it from a config snapshot and pairs it with a conditional write. The applier
20//! re-projects the live config and applies the write only if it still equals
21//! the witness.
22
23use std::collections::BTreeSet;
24use std::time::Duration;
25
26use mz_compute_types::config::ComputeReplicaLogging;
27use mz_repr::Timestamp;
28
29/// The availability zones a managed cluster's replicas are provisioned across,
30/// in configured *provisioning order*.
31///
32/// The order is significant to provisioning: the orchestrator round-robins
33/// replica placement across the list, so the first configured zone is filled
34/// first. Equality is therefore structural and order-sensitive, and the order is
35/// part of the [`ExpectedClusterState`] compare-and-append witness. To compare
36/// two configurations as unordered *pools* of zones, ignoring placement order,
37/// convert with [`AvailabilityZones::pool`].
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct AvailabilityZones(pub Vec<String>);
40
41impl AvailabilityZones {
42 /// This configuration as an unordered [`AvailabilityZonePool`]: the same
43 /// zones compared by membership rather than provisioning order. Two replicas
44 /// drawing from the same pool are interchangeable however the lists were
45 /// ordered.
46 pub fn pool(&self) -> AvailabilityZonePool {
47 AvailabilityZonePool(self.0.iter().cloned().collect())
48 }
49}
50
51/// An unordered set of availability zones: an [`AvailabilityZones`] provisioning
52/// list reduced to membership. Produced by [`AvailabilityZones::pool`] for the
53/// one comparison that must ignore order, replica interchangeability.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct AvailabilityZonePool(pub BTreeSet<String>);
56
57/// The config dimensions that distinguish one replica from another. Two replicas
58/// with equal shape are interchangeable. An `AVAILABILITY ZONES` difference is a
59/// shape difference, but only as an unordered pool: reordering the same zones
60/// does not change a replica's shape (see [`ReplicaShape::matches`]).
61#[derive(Clone, Debug)]
62pub struct ReplicaShape {
63 pub size: String,
64 pub availability_zones: AvailabilityZones,
65 pub logging: ComputeReplicaLogging,
66}
67
68impl ReplicaShape {
69 /// Whether two shapes are interchangeable. Availability zones are compared as
70 /// unordered [pools](AvailabilityZones::pool): a replica already placed
71 /// satisfies a desired shape with the same zones in a different order, so a
72 /// mere reorder does not force a reprovision.
73 pub fn matches(&self, other: &ReplicaShape) -> bool {
74 self.size == other.size
75 && self.logging == other.logging
76 && self.availability_zones.pool() == other.availability_zones.pool()
77 }
78}
79
80/// Compare-and-append witness over a managed cluster's durable config: the
81/// fields a conditional write is conditioned on. The applier applies the write
82/// only if the cluster's current config still projects to an equal witness.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct ExpectedClusterState {
85 pub size: String,
86 pub replication_factor: u32,
87 pub availability_zones: AvailabilityZones,
88 pub logging: ComputeReplicaLogging,
89 pub reconfiguration: Option<ReconfigurationRecord>,
90 pub burst: Option<BurstRecord>,
91}
92
93/// The status of the latest graceful reconfiguration record.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum ReconfigurationStatus {
96 /// The controller is converging the cluster onto the target shape.
97 InProgress,
98 /// The realized config reached the target shape.
99 Finalized,
100 /// The deadline fired under rollback and the realized config stayed put.
101 TimedOut,
102 /// The user retargeted the reconfiguration back to the realized shape.
103 Cancelled,
104 /// The controller could not create the target replicas within the budget.
105 ResourceExhausted,
106}
107
108/// The latest graceful reconfiguration record, mirrored from durable state.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct ReconfigurationRecord {
111 pub target: ReconfigurationTarget,
112 pub deadline: Timestamp,
113 /// What to do once the deadline passes with the target not yet hydrated.
114 pub on_timeout: OnTimeout,
115 pub status: ReconfigurationStatus,
116}
117
118impl ReconfigurationRecord {
119 /// Whether this record should still drive target-replica convergence.
120 pub fn is_in_progress(&self) -> bool {
121 matches!(self.status, ReconfigurationStatus::InProgress)
122 }
123}
124
125/// The lifecycle transition a write to the `reconfiguration` record represents,
126/// declared by the writer at the decision point and carried alongside the state
127/// so the audit event is emitted in the same catalog transaction as the write.
128/// A plain-data mirror of the audit-log lifecycle vocabulary, free of a
129/// dependency on `mz-audit-log`.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum ReconfigurationAudit {
132 /// A record was written or re-targeted: converging onto a new target.
133 Started,
134 /// The user re-targeted back to the still-realized shape.
135 Cancelled,
136 /// The realized config cut over to the target. `forced` records whether
137 /// the cut-over was forced by `ON TIMEOUT COMMIT` at the deadline rather
138 /// than reached by hydration, information only the writer has.
139 Finalized { forced: bool },
140 /// The deadline fired under rollback and the realized config stayed put.
141 TimedOut,
142 /// The controller could not create the target replicas within the budget.
143 ResourceExhausted,
144}
145
146/// The lifecycle transition a write to the `burst` record represents, declared
147/// by the writer like [`ReconfigurationAudit`]. A burst record is also
148/// rewritten in place for bookkeeping (stamping or resetting the linger
149/// clock), which is not a lifecycle transition and declares no audit.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum BurstAudit {
152 /// A burst record was written: a burst replica is being provisioned.
153 Started,
154 /// The burst record was cleared and the burst replica torn down.
155 Finished { cause: BurstFinishCause },
156}
157
158/// Why a hydration burst finished, known only to the strategy arm that
159/// decided the teardown.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum BurstFinishCause {
162 /// The steady replica set hydrated and the linger duration elapsed.
163 LingerElapsed,
164 /// The burst is no longer warranted by current config: the auto-scaling
165 /// policy was removed or its hydration size changed, the cluster was
166 /// turned off, or burst was disabled environment-wide.
167 NoLongerWarranted,
168}
169
170/// The action a graceful reconfiguration applies once its `deadline` passes with
171/// the target replicas not yet hydrated. Success always takes precedence: a
172/// hydrated target cuts over regardless of this. A plain-data mirror of
173/// `mz_sql::plan::OnTimeoutAction`, free of a dependency on the SQL layer.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum OnTimeout {
176 /// Cut over to the (not-yet-hydrated) target anyway.
177 Commit,
178 /// Drop the target replica set, reverting to the pre-reconfiguration shape.
179 Rollback,
180}
181
182/// The full config shape a reconfiguration is moving the cluster to. Distinct
183/// from a replica shape because it additionally carries `replication_factor`, a
184/// cluster-level rather than replica-level dimension.
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct ReconfigurationTarget {
187 pub size: String,
188 pub replication_factor: u32,
189 pub availability_zones: AvailabilityZones,
190 pub logging: ComputeReplicaLogging,
191}
192
193impl ReconfigurationTarget {
194 /// The per-replica shape of the target: everything but `replication_factor`.
195 pub fn shape(&self) -> ReplicaShape {
196 ReplicaShape {
197 size: self.size.clone(),
198 availability_zones: self.availability_zones.clone(),
199 logging: self.logging.clone(),
200 }
201 }
202}
203
204/// An active hydration-burst record, mirrored from durable state.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub struct BurstRecord {
207 pub burst_size: String,
208 pub linger_duration: Duration,
209 pub steady_hydrated_at: Option<Timestamp>,
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn azs(zones: &[&str]) -> AvailabilityZones {
217 AvailabilityZones(zones.iter().map(|z| z.to_string()).collect())
218 }
219
220 fn shape(zones: &[&str]) -> ReplicaShape {
221 ReplicaShape {
222 size: "small".to_string(),
223 availability_zones: azs(zones),
224 logging: ComputeReplicaLogging::default(),
225 }
226 }
227
228 #[mz_ore::test]
229 fn availability_zones_identity_is_ordered_pool_is_not() {
230 // Provisioning order is part of the value's identity, so a reorder is a
231 // distinct configuration that the compare-and-append witness can see.
232 assert_ne!(azs(&["a", "b"]), azs(&["b", "a"]));
233 // The pool drops the order: the same zones in any order are one pool, but
234 // a different set of zones is a different pool.
235 assert_eq!(azs(&["a", "b"]).pool(), azs(&["b", "a"]).pool());
236 assert_ne!(azs(&["a", "b"]).pool(), azs(&["a", "c"]).pool());
237 }
238
239 #[mz_ore::test]
240 fn replica_shape_matches_ignores_zone_order() {
241 // Interchangeability ignores provisioning order: an already-placed
242 // replica satisfies a desired shape with the same zones reordered, so a
243 // reorder alone never forces a reprovision.
244 assert!(shape(&["a", "b"]).matches(&shape(&["b", "a"])));
245 // A different zone set is a different shape.
246 assert!(!shape(&["a", "b"]).matches(&shape(&["a", "c"])));
247 }
248}