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}