Skip to main content

mz_cluster_controller/
strategy.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 pure strategy interface and the strategy implementations.
11//!
12//! A strategy is two pure functions over `(observed cluster state, live
13//! signals, now)`:
14//!
15//! - [`Strategy::update_state`] returns the durable writes the strategy wants
16//!   (cut-overs, record writes/clears). The controller transacts these in the
17//!   tick's first phase.
18//! - [`Strategy::desired_replicas`] returns the replica slots the strategy
19//!   contributes to the cluster's desired set. The controller unions every
20//!   strategy's contribution in the tick's second phase.
21//!
22//! Both are pure: same inputs, same output, no I/O. The controller is the sole
23//! mutator. Strategies never touch the [`ClusterControllerCtx`] directly. They
24//! declare the live signals they need via [`Strategy::signal_request`] and the
25//! controller fetches those before evaluating them.
26//!
27//! [`ClusterControllerCtx`]: crate::ctx::ClusterControllerCtx
28
29use std::collections::BTreeSet;
30
31use mz_controller_types::ReplicaId;
32use mz_repr::Timestamp;
33
34use crate::ctx::{
35    ClusterState, OnTimeout, ReconfigurationAudit, ReconfigurationRecord, ReconfigurationStatus,
36    ReconfigurationWrite, ReplicaShape, StateWrite,
37};
38
39/// A replica slot a strategy desires this tick. The reconcile kernel unions
40/// slots across strategies and matches them by [`ReplicaShape`] against the
41/// actual replica set.
42#[derive(Clone, Debug)]
43pub struct DesiredReplica {
44    pub shape: ReplicaShape,
45}
46
47/// One cluster-autoscaling strategy: a pair of pure functions the controller
48/// runs each tick. See the module docs.
49///
50/// `Send + Sync` so the controller (which holds a set of boxed strategies) can
51/// run on its own task.
52pub trait Strategy: Send + Sync {
53    /// A stable identifier used in audit attribution (which strategies desired a
54    /// create; drops carry no attribution).
55    fn name(&self) -> &'static str;
56
57    /// The live signals this strategy needs to evaluate `state` this tick,
58    /// declared as a pure function of the durable state. The kernel unions the
59    /// requests across strategies, fetches them through the ctx, and passes the
60    /// result to [`Strategy::update_state`] and [`Strategy::desired_replicas`].
61    /// The default requests nothing, which suits a strategy that works off
62    /// durable state alone (like the baseline).
63    fn signal_request(&self, _state: &ClusterState) -> SignalRequest {
64        SignalRequest::default()
65    }
66
67    /// The durable writes this strategy wants for `state` at time `now`. The
68    /// default is no write, which suits a strategy that only ever contributes
69    /// replicas (like the baseline). An empty [`StateWrite`] means "write
70    /// nothing": the kernel drops it without emitting a decision.
71    fn update_state(
72        &self,
73        _state: &ClusterState,
74        _signals: &LiveSignals,
75        _now: Timestamp,
76    ) -> StateWrite {
77        StateWrite::default()
78    }
79
80    /// The replica slots this strategy contributes to `state`'s desired set at
81    /// time `now`.
82    fn desired_replicas(
83        &self,
84        state: &ClusterState,
85        signals: &LiveSignals,
86        now: Timestamp,
87    ) -> Vec<DesiredReplica>;
88}
89
90/// The live signals a strategy asks the kernel to fetch before evaluating a
91/// cluster, declared through [`Strategy::signal_request`].
92///
93/// Live signals are observations (hydration and the like) that are not durable
94/// state, so they never participate in the compare-and-append witness. Keeping
95/// them out of [`ClusterState`] keeps that type exactly the witness material
96/// plus the observed replica set.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub struct SignalRequest {
99    /// Probe which of the cluster's replicas report all collections hydrated.
100    pub hydration: bool,
101}
102
103impl SignalRequest {
104    /// The union of two requests: a signal is fetched if any strategy asks.
105    pub fn union(self, other: SignalRequest) -> SignalRequest {
106        // Exhaustive destructure (no `..`): a signal added to the request is a
107        // compile error here until its union is spelled out.
108        let SignalRequest { hydration } = other;
109        SignalRequest {
110            hydration: self.hydration || hydration,
111        }
112    }
113}
114
115/// The fulfilled live signals for one cluster, fetched by the kernel per the
116/// unioned [`SignalRequest`] and passed alongside [`ClusterState`].
117///
118/// A signal nobody requested is left at its empty default, so a strategy must
119/// only read what it declared in [`Strategy::signal_request`].
120#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct LiveSignals {
122    /// The replicas observed this tick to have *all* current collections on the
123    /// cluster hydrated.
124    pub hydrated_replicas: BTreeSet<ReplicaId>,
125}
126
127/// The implicit baseline strategy, always present.
128///
129/// Desires `replication_factor` replicas at the cluster's realized shape
130/// (`cluster.size` plus its AZ pool and logging). It holds the steady-state set
131/// so that the policy strategies can be purely additive. They only ever add to
132/// the baseline. With only the baseline engaged, the desired set equals the
133/// realized set, so a steady-state managed cluster reconciles to no decisions.
134#[derive(Clone, Copy, Debug, Default)]
135pub struct BaselineStrategy;
136
137/// The audit-attribution name of the baseline strategy.
138pub const BASELINE_STRATEGY_NAME: &str = "baseline";
139
140impl Strategy for BaselineStrategy {
141    fn name(&self) -> &'static str {
142        BASELINE_STRATEGY_NAME
143    }
144
145    fn desired_replicas(
146        &self,
147        state: &ClusterState,
148        _signals: &LiveSignals,
149        _now: Timestamp,
150    ) -> Vec<DesiredReplica> {
151        let shape = state.realized_shape();
152        (0..state.replication_factor)
153            .map(|_| DesiredReplica {
154                shape: shape.clone(),
155            })
156            .collect()
157    }
158}
159
160/// The graceful (zero-downtime) reconfiguration strategy.
161///
162/// Engaged whenever the durable `reconfiguration` record is in progress. It
163/// desires `target.replication_factor` replicas at the target shape in addition
164/// to the baseline's realized-shape replicas, so both sets serve while the new
165/// one hydrates. Once rf-many target replicas are present and hydrated,
166/// `update_state` cuts over: the realized config advances to the target, the
167/// record is marked finalized, and the old replicas fall out of the union and
168/// are dropped. Success takes precedence over the deadline. On a timeout,
169/// `Commit` cuts over to the un-hydrated target anyway while `Rollback` (the
170/// default) marks the record timed out without touching the realized config and
171/// stops desiring the target replicas, reverting to the pre-reconfiguration set.
172///
173/// Both functions are pure over the observed [`ClusterState`] and the fetched
174/// [`LiveSignals`]. Hydration is requested via [`Strategy::signal_request`]
175/// exactly while an in-progress reconfiguration is present.
176#[derive(Clone, Copy, Debug, Default)]
177pub struct GracefulReconfigurationStrategy;
178
179/// The audit-attribution name of the graceful reconfiguration strategy.
180pub const GRACEFUL_RECONFIGURATION_STRATEGY_NAME: &str = "graceful-reconfiguration";
181
182impl GracefulReconfigurationStrategy {
183    /// Whether the cut-over precondition holds: at least
184    /// `target.replication_factor` replicas of the target shape report
185    /// hydrated.
186    ///
187    /// Requiring rf-many hydrated replicas (not just one) preserves the
188    /// high-availability guarantee of `replication_factor > 1` across the
189    /// cut-over. Extra target-shape replicas beyond the rf do not block: the
190    /// post-cut-over reconcile retires them anyway, so waiting for them to
191    /// hydrate would only delay the cut-over.
192    fn target_hydrated(
193        &self,
194        state: &ClusterState,
195        signals: &LiveSignals,
196        record: &ReconfigurationRecord,
197    ) -> bool {
198        let target_shape = record.target.shape();
199        let hydrated_target_replicas = state
200            .replicas
201            .iter()
202            .filter(|r| r.shape.matches(&target_shape))
203            .filter(|r| signals.hydrated_replicas.contains(&r.replica_id))
204            .count();
205        let target_rf = usize::try_from(record.target.replication_factor).unwrap_or(usize::MAX);
206        hydrated_target_replicas >= target_rf
207    }
208}
209
210impl Strategy for GracefulReconfigurationStrategy {
211    fn name(&self) -> &'static str {
212        GRACEFUL_RECONFIGURATION_STRATEGY_NAME
213    }
214
215    fn signal_request(&self, state: &ClusterState) -> SignalRequest {
216        SignalRequest {
217            hydration: state
218                .reconfiguration
219                .as_ref()
220                .is_some_and(|record| record.is_in_progress()),
221        }
222    }
223
224    fn update_state(
225        &self,
226        state: &ClusterState,
227        signals: &LiveSignals,
228        now: Timestamp,
229    ) -> StateWrite {
230        let Some(record) = &state.reconfiguration else {
231            return StateWrite::default();
232        };
233        if !record.is_in_progress() {
234            return StateWrite::default();
235        }
236
237        // Cut over by advancing the realized config to the target and marking
238        // the record finalized on either of two conditions:
239        //   1. rf-many target replicas are present and hydrated (success, which
240        //      takes precedence over the deadline regardless of `on_timeout`), or
241        //   2. the deadline has been reached un-hydrated and `on_timeout` is
242        //      `Commit` (cut over to the not-yet-hydrated target anyway).
243        //
244        // NOTE: the deadline is reached at `now >= deadline`, not `now > deadline`.
245        // A `WAIT FOR '0s'` writes `deadline = now` to request an immediate
246        // cut-over. With a strict `>`, a first tick landing at exactly that
247        // timestamp would miss the deadline, so phase 2 would provision the overlap
248        // target replicas and only a later tick would cut over. `>=` fires the
249        // deadline the instant it is reached, so the zero-timeout cut-over happens
250        // on the first tick, before any overlap replica is desired.
251        let hydrated = self.target_hydrated(state, signals, record);
252        let deadline_reached = now >= record.deadline;
253        let commit_on_timeout = deadline_reached && matches!(record.on_timeout, OnTimeout::Commit);
254        if hydrated || commit_on_timeout {
255            return StateWrite {
256                new_size: Some(record.target.size.clone()),
257                new_replication_factor: Some(record.target.replication_factor),
258                new_availability_zones: Some(record.target.availability_zones.0.clone()),
259                new_logging: Some(record.target.logging.clone()),
260                reconfiguration: Some(ReconfigurationWrite {
261                    record: Some(ReconfigurationRecord {
262                        status: ReconfigurationStatus::Finalized,
263                        ..record.clone()
264                    }),
265                    // A cut-over that only happens because the deadline passed
266                    // under `Commit` is forced: the target has not hydrated.
267                    // Declared here because only this decision point knows.
268                    // The durable status reads `Finalized` either way.
269                    audit: Some(ReconfigurationAudit::Finalized { forced: !hydrated }),
270                }),
271                ..Default::default()
272            };
273        }
274
275        // Past the deadline un-hydrated under `Rollback`: abandon the
276        // reconfiguration while leaving the realized config untouched. The
277        // terminal status is the durable transition the audit event records. With
278        // the record no longer in progress the strategy stops contributing the
279        // target set, so the baseline alone shapes the cluster.
280        if deadline_reached && matches!(record.on_timeout, OnTimeout::Rollback) {
281            return StateWrite {
282                reconfiguration: Some(ReconfigurationWrite {
283                    record: Some(ReconfigurationRecord {
284                        status: ReconfigurationStatus::TimedOut,
285                        ..record.clone()
286                    }),
287                    audit: Some(ReconfigurationAudit::TimedOut),
288                }),
289                ..Default::default()
290            };
291        }
292
293        // Before the deadline: keep waiting.
294        StateWrite::default()
295    }
296
297    fn desired_replicas(
298        &self,
299        state: &ClusterState,
300        signals: &LiveSignals,
301        now: Timestamp,
302    ) -> Vec<DesiredReplica> {
303        let Some(record) = &state.reconfiguration else {
304            return Vec::new();
305        };
306        if !record.is_in_progress() {
307            return Vec::new();
308        }
309
310        // Past the deadline with the target not hydrated under `Rollback`: stop
311        // contributing the target replicas. `update_state` marks the record
312        // timed out in this same tick's first phase, so this usually never fires
313        // against a re-read state. It matters when the deadline crosses between
314        // the two phases' `ctx.now()` reads within one tick: phase 1 saw the
315        // deadline unreached and wrote nothing, phase 2 sees it reached here and
316        // already stops desiring the target, keeping the rollback's replica
317        // drops prompt rather than waiting a tick for the status write.
318        // Everything else (before the deadline, awaiting a success cut-over
319        // past it, or a `Commit` cut-over `update_state` performs this tick)
320        // keeps desiring the target set.
321        // `now >= deadline` matches `update_state`'s boundary, so a zero-timeout
322        // rollback stops desiring the target on the same tick it marks the
323        // record timed out.
324        let timed_out = now >= record.deadline && !self.target_hydrated(state, signals, record);
325        if timed_out && matches!(record.on_timeout, OnTimeout::Rollback) {
326            return Vec::new();
327        }
328
329        let shape = record.target.shape();
330        (0..record.target.replication_factor)
331            .map(|_| DesiredReplica {
332                shape: shape.clone(),
333            })
334            .collect()
335    }
336}