Skip to main content

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    pub arrangement_compression: bool,
67}
68
69impl ReplicaShape {
70    /// Whether two shapes are interchangeable. Availability zones are compared as
71    /// unordered [pools](AvailabilityZones::pool): a replica already placed
72    /// satisfies a desired shape with the same zones in a different order, so a
73    /// mere reorder does not force a reprovision.
74    pub fn matches(&self, other: &ReplicaShape) -> bool {
75        self.size == other.size
76            && self.logging == other.logging
77            && self.arrangement_compression == other.arrangement_compression
78            && self.availability_zones.pool() == other.availability_zones.pool()
79    }
80}
81
82/// Compare-and-append witness over a managed cluster's durable config: the
83/// fields a conditional write is conditioned on. The applier applies the write
84/// only if the cluster's current config still projects to an equal witness.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct ExpectedClusterState {
87    pub size: String,
88    pub replication_factor: u32,
89    pub availability_zones: AvailabilityZones,
90    pub logging: ComputeReplicaLogging,
91    pub arrangement_compression: bool,
92    /// The scheduling policy. Part of the witness because it determines which
93    /// policy owns the replica set, so a write conditioned on one schedule must
94    /// not apply under another.
95    pub schedule: ClusterSchedule,
96    /// The autoscaling policy. Part of the witness because it determines whether,
97    /// and at what size, a burst is warranted.
98    pub auto_scaling_policy: Option<AutoScalingPolicy>,
99    pub reconfiguration: Option<ReconfigurationRecord>,
100    pub burst: Option<BurstRecord>,
101}
102
103/// The status of the latest graceful reconfiguration record.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum ReconfigurationStatus {
106    /// The controller is converging the cluster onto the target shape.
107    InProgress,
108    /// The realized config reached the target shape.
109    Finalized,
110    /// The deadline fired under rollback and the realized config stayed put.
111    TimedOut,
112    /// The user retargeted the reconfiguration back to the realized shape.
113    Cancelled,
114    /// The controller could not create the target replicas within the budget.
115    ResourceExhausted,
116}
117
118/// The latest graceful reconfiguration record, mirrored from durable state.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct ReconfigurationRecord {
121    pub target: ReconfigurationTarget,
122    pub deadline: Timestamp,
123    /// What to do once the deadline passes with the target not yet hydrated.
124    pub on_timeout: OnTimeout,
125    pub status: ReconfigurationStatus,
126}
127
128impl ReconfigurationRecord {
129    /// Whether this record should still drive target-replica convergence.
130    pub fn is_in_progress(&self) -> bool {
131        matches!(self.status, ReconfigurationStatus::InProgress)
132    }
133}
134
135/// The lifecycle transition a write to the `reconfiguration` record represents,
136/// declared by the writer at the decision point and carried alongside the state
137/// so the audit event is emitted in the same catalog transaction as the write.
138/// A plain-data mirror of the audit-log lifecycle vocabulary, free of a
139/// dependency on `mz-audit-log`.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum ReconfigurationAudit {
142    /// A record was written or re-targeted: converging onto a new target.
143    Started,
144    /// The user re-targeted back to the still-realized shape.
145    Cancelled,
146    /// The realized config cut over to the target. `forced` records whether
147    /// the cut-over was forced by `ON TIMEOUT COMMIT` at the deadline rather
148    /// than reached by hydration, information only the writer has.
149    Finalized { forced: bool },
150    /// The deadline fired under rollback and the realized config stayed put.
151    TimedOut,
152    /// The controller could not create the target replicas within the budget.
153    ResourceExhausted,
154}
155
156/// The lifecycle transition a write to the `burst` record represents, declared
157/// by the writer like [`ReconfigurationAudit`]. A burst record is also
158/// rewritten in place for bookkeeping (stamping or resetting the linger
159/// clock), which is not a lifecycle transition and declares no audit.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum BurstAudit {
162    /// A burst record was written: a burst replica is being provisioned.
163    Started,
164    /// The burst record was cleared and the burst replica torn down.
165    Finished { cause: BurstFinishCause },
166}
167
168/// Why a hydration burst finished, known only to the strategy arm that
169/// decided the teardown.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum BurstFinishCause {
172    /// The steady replica set hydrated and the linger duration elapsed.
173    LingerElapsed,
174    /// The burst is no longer warranted by current config: the auto-scaling
175    /// policy was removed or its hydration size changed, the cluster was
176    /// turned off, or burst was disabled environment-wide.
177    NoLongerWarranted,
178}
179
180/// The action a graceful reconfiguration applies once its `deadline` passes with
181/// the target replicas not yet hydrated. Success always takes precedence: a
182/// hydrated target cuts over regardless of this. A plain-data mirror of
183/// `mz_sql::plan::OnTimeoutAction`, free of a dependency on the SQL layer.
184#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub enum OnTimeout {
186    /// Cut over to the (not-yet-hydrated) target anyway.
187    Commit,
188    /// Drop the target replica set, reverting to the pre-reconfiguration shape.
189    Rollback,
190}
191
192/// The full config shape a reconfiguration is moving the cluster to. Distinct
193/// from a replica shape because it additionally carries `replication_factor`, a
194/// cluster-level rather than replica-level dimension.
195#[derive(Clone, Debug, PartialEq, Eq)]
196pub struct ReconfigurationTarget {
197    pub size: String,
198    pub replication_factor: u32,
199    pub availability_zones: AvailabilityZones,
200    pub logging: ComputeReplicaLogging,
201    pub arrangement_compression: bool,
202}
203
204impl ReconfigurationTarget {
205    /// The per-replica shape of the target: everything but `replication_factor`.
206    pub fn shape(&self) -> ReplicaShape {
207        ReplicaShape {
208            size: self.size.clone(),
209            availability_zones: self.availability_zones.clone(),
210            logging: self.logging.clone(),
211            arrangement_compression: self.arrangement_compression,
212        }
213    }
214}
215
216/// An active hydration-burst record, mirrored from durable state.
217#[derive(Clone, Debug, PartialEq, Eq)]
218pub struct BurstRecord {
219    pub burst_size: String,
220    pub linger_duration: Duration,
221    pub steady_hydrated_at: Option<Timestamp>,
222}
223
224/// The single definition of when an in-flight burst record of `record_size` is
225/// warranted: the cluster is on and the `ON HYDRATION` policy in force (if any)
226/// bursts at the record's size. `hydration_size` is `None` when no policy is in
227/// force. The catalog and the cluster controller wrap this over their own
228/// config representations, so the two sides of the burst lifecycle cannot
229/// drift.
230pub fn burst_record_warranted(
231    record_size: &str,
232    replication_factor: u32,
233    hydration_size: Option<&str>,
234) -> bool {
235    replication_factor != 0 && hydration_size == Some(record_size)
236}
237
238/// A managed cluster's scheduling policy, mirrored from durable state. A
239/// plain-data mirror of `mz_sql::plan::ClusterSchedule`, free of a dependency on
240/// the SQL layer.
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
242pub enum ClusterSchedule {
243    /// The cluster is user-managed: `replication_factor` is the capacity knob
244    /// that sizes the replica set.
245    Manual,
246    /// The cluster is scheduled `ON REFRESH`: `replication_factor` is held at `0`
247    /// and replicas run only around refresh times. `hydration_time_estimate` is
248    /// how far ahead of a refresh the cluster should turn on so it can rehydrate
249    /// before the refresh time.
250    Refresh { hydration_time_estimate: Duration },
251}
252
253/// The user-configured autoscaling policy of a managed cluster, mirrored from
254/// durable state. A plain-data mirror of `mz_sql::plan::AutoScalingStrategy`,
255/// free of a dependency on the SQL layer.
256///
257/// Extensible: future strategies are additional optional sub-policies. v1 carries
258/// only the `ON HYDRATION` burst policy.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct AutoScalingPolicy {
261    pub on_hydration: Option<OnHydrationPolicy>,
262}
263
264/// The `ON HYDRATION` burst sub-policy: while some object on the cluster is not
265/// hydrated on a steady replica, run one extra replica at `hydration_size` to
266/// accelerate hydration, lingering for `linger_duration` after the steady set
267/// hydrates.
268#[derive(Clone, Debug, PartialEq, Eq)]
269pub struct OnHydrationPolicy {
270    pub hydration_size: String,
271    /// `None` falls back to the system default linger when the burst strategy
272    /// writes its record.
273    pub linger_duration: Option<Duration>,
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn azs(zones: &[&str]) -> AvailabilityZones {
281        AvailabilityZones(zones.iter().map(|z| z.to_string()).collect())
282    }
283
284    fn shape(zones: &[&str]) -> ReplicaShape {
285        ReplicaShape {
286            size: "small".to_string(),
287            availability_zones: azs(zones),
288            logging: ComputeReplicaLogging::default(),
289            arrangement_compression: false,
290        }
291    }
292
293    #[mz_ore::test]
294    fn availability_zones_identity_is_ordered_pool_is_not() {
295        // Provisioning order is part of the value's identity, so a reorder is a
296        // distinct configuration that the compare-and-append witness can see.
297        assert_ne!(azs(&["a", "b"]), azs(&["b", "a"]));
298        // The pool drops the order: the same zones in any order are one pool, but
299        // a different set of zones is a different pool.
300        assert_eq!(azs(&["a", "b"]).pool(), azs(&["b", "a"]).pool());
301        assert_ne!(azs(&["a", "b"]).pool(), azs(&["a", "c"]).pool());
302    }
303
304    #[mz_ore::test]
305    fn replica_shape_matches_ignores_zone_order() {
306        // Interchangeability ignores provisioning order: an already-placed
307        // replica satisfies a desired shape with the same zones reordered, so a
308        // reorder alone never forces a reprovision.
309        assert!(shape(&["a", "b"]).matches(&shape(&["b", "a"])));
310        // A different zone set is a different shape.
311        assert!(!shape(&["a", "b"]).matches(&shape(&["a", "c"])));
312    }
313}