Skip to main content

mz_cluster_controller/
lib.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 cluster controller: the single decision-maker for the replica set of
11//! every managed cluster.
12//!
13//! It is a **reconciler**. Each tick it reads desired cluster state and live
14//! signals through the [`ClusterControllerCtx`] boundary, runs a set of pure
15//! [`Strategy`]s, unions their desired contributions, diffs that against the
16//! actual replica set, and emits the create/drop and durable-state-write
17//! [`Decision`]s that close the gap. It holds no in-memory state: the source of
18//! truth is always the catalog plus live signals, pulled fresh each tick.
19//!
20//! The crate is **pure**. It depends only on primitive id/shape types and the
21//! [`ClusterControllerCtx`] trait, never on the adapter or catalog. That
22//! boundary is what makes the controller testable against a fake
23//! implementation and extractable later without touching controller code.
24//!
25//! A tick runs two phases per cluster, `update_state` then `desired_replicas`
26//! (see [`ClusterController::reconcile`]). Every [`Decision`] carries the
27//! durable state it was derived from, and the apply path transacts it only if
28//! that state still holds (compare-and-append). So a create or drop derived
29//! from a pre-`ALTER` snapshot can never reshape the replica set against the
30//! config the `ALTER` has since established. Applies are per cluster, so one
31//! cluster's rejection does not block the others, and commands name explicit
32//! replicas, so re-emitting one across a lagging view or a restart is a no-op.
33//!
34//! [`ClusterControllerCtx`]: crate::ctx::ClusterControllerCtx
35
36pub mod ctx;
37pub mod strategy;
38
39use std::collections::BTreeSet;
40
41use mz_ore::soft_panic_or_log;
42
43use crate::ctx::{
44    ApplyOutcome, ClusterControllerCtx, ClusterState, Decision, ObservedReplica, ReplicaShape,
45    StateWrite,
46};
47use crate::strategy::{BaselineStrategy, DesiredReplica, Strategy};
48
49/// The cluster controller. Holds the (stateless) set of strategies and drives a
50/// reconcile tick against a [`ClusterControllerCtx`].
51pub struct ClusterController {
52    strategies: Vec<Box<dyn Strategy>>,
53}
54
55impl Default for ClusterController {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl ClusterController {
62    /// A controller with only the implicit baseline strategy. This reconciles a
63    /// steady-state managed cluster to no decisions.
64    pub fn new() -> Self {
65        Self {
66            strategies: vec![Box::new(BaselineStrategy)],
67        }
68    }
69
70    /// Run one reconcile tick over every managed cluster the ctx reports.
71    ///
72    /// See the module docs for the two-phase structure. Both phases apply per
73    /// cluster, so a compare-and-append rejection on one cluster never blocks
74    /// progress on the others.
75    pub async fn reconcile(&self, ctx: &mut dyn ClusterControllerCtx) {
76        let cluster_ids = ctx.managed_cluster_ids().await;
77        if cluster_ids.is_empty() {
78            return;
79        }
80
81        // Phase 1: update_state. We merge every strategy's write for a cluster
82        // into one compare-and-append, applied per cluster and independently of
83        // other clusters. Two separate decisions live here.
84        //
85        // Per cluster, not one batch per tick: a write rejected because a
86        // concurrent `ALTER` moved the cluster off its `expected` rejects only
87        // that cluster and leaves the rest free to progress. One batched apply
88        // would let a single mid-`ALTER` cluster sink the whole tick, the failure
89        // mode at large cluster counts where some cluster is almost always
90        // mid-`ALTER`.
91        //
92        // Merged across strategies, not one apply per strategy: every strategy
93        // for a cluster shares the same start-of-tick `expected`, so applying
94        // them one at a time would let the first write move the cluster off that
95        // `expected` and reject all the rest, serializing a cluster's disjoint
96        // writes one-per-tick. Merging lands them together under one guard. We
97        // still rely on the compare-and-append, not the merge, for `ALTER`
98        // safety, which is why the merged write carries the cluster's `expected`.
99        // See `merge_state_writes` for the join and its conflict handling.
100        let states = ctx.cluster_states(&cluster_ids).await;
101        let now = ctx.now();
102        // Set when we issue any phase-1 apply, applied or rejected. Either way
103        // the durable state may have moved (our write, or the concurrent `ALTER`
104        // that rejected it), so phase 2 re-reads.
105        let mut phase_1_wrote = false;
106        // Clusters whose phase-1 write was rejected. We skip their phase 2 this
107        // tick. Proceeding would be safe (we re-read below and every create/drop
108        // is guard-checked), but a cluster that just lost a race is likely still
109        // settling, so we let it recompute next tick instead of emitting work
110        // that is probably about to go stale.
111        let mut rejected = BTreeSet::new();
112        for state in &states {
113            let write = self.merge_state_writes(state, now);
114            if write.is_empty() {
115                continue;
116            }
117            phase_1_wrote = true;
118            let decision = Decision::UpdateClusterState {
119                cluster_id: state.cluster_id,
120                expected: state.expected(),
121                write,
122            };
123            if ctx.apply(vec![decision]).await == ApplyOutcome::Rejected {
124                rejected.insert(state.cluster_id);
125            }
126        }
127
128        // Phase 2: desired_replicas. The barrier exists so that a cut-over a
129        // phase-1 write performed is visible before we diff the replica set
130        // against the realized config. We re-read only if phase 1 wrote. The
131        // first read is otherwise still current. A stale diff is harmless: every
132        // create/drop carries its `expected` and is guard-rejected if the durable
133        // state has since diverged.
134        let states = if phase_1_wrote {
135            ctx.cluster_states(&cluster_ids).await
136        } else {
137            states
138        };
139        let now = ctx.now();
140        for state in &states {
141            if rejected.contains(&state.cluster_id) {
142                continue;
143            }
144            let decisions = self.collect_replica_decisions(state, now);
145            if decisions.is_empty() {
146                continue;
147            }
148            // Per-cluster apply: a guard failure here is isolated to this cluster,
149            // and benign anyway since every command names an explicit replica and
150            // is reconciled away next tick. We do not retry within the tick.
151            let _ = ctx.apply(decisions).await;
152        }
153    }
154
155    /// Merge every strategy's [`Strategy::update_state`] for one cluster into the
156    /// single [`StateWrite`] the tick applies under one compare-and-append.
157    ///
158    /// The merge is a per-field join, independent of the order strategies run
159    /// in: a field set by exactly one strategy is taken as-is, a field no
160    /// strategy sets is left unchanged, and a field set to the same value by
161    /// several is that value.
162    ///
163    /// Two strategies setting one field to *different* values is a conflict.
164    /// Every field is owned by exactly one strategy, so by design it cannot
165    /// happen and the merge is really a disjoint union. We treat a conflict as
166    /// an invariant violation rather than a condition to resolve: there is no
167    /// safety-meaningful winner to pick for a contended `size` or record, so we
168    /// trip [`soft_panic_or_log!`] (a panic under test/CI soft assertions, a
169    /// logged error in production) and leave the field unchanged, the only
170    /// outcome that cannot make things worse. A persistent conflict then freezes
171    /// that field and keeps tripping the alarm, which is the point: surface the
172    /// design bug loudly instead of silently picking an arbitrary value.
173    fn merge_state_writes(&self, state: &ClusterState, now: mz_repr::Timestamp) -> StateWrite {
174        let writes: Vec<StateWrite> = self
175            .strategies
176            .iter()
177            .map(|strategy| strategy.update_state(state, now))
178            .filter(|write| !write.is_empty())
179            .collect();
180
181        let mut conflicts: Vec<&'static str> = Vec::new();
182        // Exhaustive construction (every field named, no `..`): a field added to
183        // `StateWrite` is a compile error here until its join is spelled out.
184        let merged = StateWrite {
185            new_size: join(
186                "size",
187                writes.iter().map(|w| w.new_size.clone()),
188                &mut conflicts,
189            ),
190            new_replication_factor: join(
191                "replication_factor",
192                writes.iter().map(|w| w.new_replication_factor),
193                &mut conflicts,
194            ),
195            new_availability_zones: join(
196                "availability_zones",
197                writes.iter().map(|w| w.new_availability_zones.clone()),
198                &mut conflicts,
199            ),
200            new_logging: join(
201                "logging",
202                writes.iter().map(|w| w.new_logging.clone()),
203                &mut conflicts,
204            ),
205            reconfiguration: join(
206                "reconfiguration",
207                writes.iter().map(|w| w.reconfiguration.clone()),
208                &mut conflicts,
209            ),
210            burst: join(
211                "burst",
212                writes.iter().map(|w| w.burst.clone()),
213                &mut conflicts,
214            ),
215        };
216
217        if !conflicts.is_empty() {
218            soft_panic_or_log!(
219                "cluster {:?}: strategies produced conflicting state writes for \
220                 field(s) {}; leaving those fields unchanged. Strategies must own \
221                 disjoint `StateWrite` fields.",
222                state.cluster_id,
223                conflicts.join(", "),
224            );
225        }
226
227        merged
228    }
229
230    /// Diff the unioned desired set against the actual replicas of one cluster
231    /// and emit the create/drop decisions that close the gap.
232    fn collect_replica_decisions(
233        &self,
234        state: &ClusterState,
235        now: mz_repr::Timestamp,
236    ) -> Vec<Decision> {
237        // Each strategy's contribution, tagged with the strategy name for
238        // attribution.
239        let contributions: Vec<(&'static str, Vec<DesiredReplica>)> = self
240            .strategies
241            .iter()
242            .map(|strategy| (strategy.name(), strategy.desired_replicas(state, now)))
243            .collect();
244
245        reconcile_replicas(state, &contributions)
246    }
247}
248
249/// Join one `StateWrite` field across the strategies that set it: `None` if
250/// none did, the common value if one or more set it to the same value, and
251/// `None` with `field` pushed onto `conflicts` if two set it to different
252/// values. The result and the conflict signal depend only on the set of values,
253/// not the order they arrive in.
254fn join<T: PartialEq>(
255    field: &'static str,
256    values: impl IntoIterator<Item = Option<T>>,
257    conflicts: &mut Vec<&'static str>,
258) -> Option<T> {
259    let mut merged: Option<T> = None;
260    for value in values.into_iter().flatten() {
261        match &merged {
262            None => merged = Some(value),
263            Some(existing) if *existing == value => {}
264            // Two strategies disagree on this field. Record it and leave the
265            // field unchanged; merge_state_writes raises the alarm.
266            Some(_) => {
267                conflicts.push(field);
268                return None;
269            }
270        }
271    }
272    merged
273}
274
275/// The pure multiset union/diff kernel for one cluster: given each strategy's
276/// desired replica slots and the actual replicas, match slots to replicas by
277/// shape and emit the creates and drops that close the gap.
278///
279/// Semantics:
280/// - The desired set is the multiset **union** of every strategy's slots: a
281///   given shape is desired `max` over strategies (not the sum), since a replica
282///   of that shape satisfies every strategy that wants one. This is what makes a
283///   replica survive iff *some* strategy desires its shape.
284/// - For each shape, if actual count < desired count we create the difference;
285///   if actual count > desired count we drop the difference, picking specific
286///   excess replicas. A replica of a shape no strategy desires is dropped.
287/// - Creates carry the names of the strategies that desired the shape. Drops
288///   carry no attribution, because a drop happens exactly when no strategy
289///   desires the replica.
290fn reconcile_replicas(
291    state: &ClusterState,
292    contributions: &[(&'static str, Vec<DesiredReplica>)],
293) -> Vec<Decision> {
294    // Desired count per shape = max over strategies of how many that strategy
295    // wants of the shape, and the union of which strategies want it.
296    let mut desired: Vec<DesiredShape> = Vec::new();
297    for (name, slots) in contributions {
298        // How many of each shape this strategy wants.
299        let mut per_shape: Vec<(ReplicaShape, usize)> = Vec::new();
300        for slot in slots {
301            match per_shape.iter_mut().find(|(s, _)| s.matches(&slot.shape)) {
302                Some((_, count)) => *count += 1,
303                None => per_shape.push((slot.shape.clone(), 1)),
304            }
305        }
306        for (shape, count) in per_shape {
307            match desired.iter_mut().find(|d| d.shape.matches(&shape)) {
308                Some(existing) => {
309                    existing.count = existing.count.max(count);
310                    if !existing.reasons.contains(name) {
311                        existing.reasons.push(*name);
312                    }
313                }
314                None => desired.push(DesiredShape {
315                    shape,
316                    count,
317                    reasons: vec![*name],
318                }),
319            }
320        }
321    }
322
323    // Bucket the actual replicas by shape.
324    let mut actual_by_shape: Vec<(ReplicaShape, Vec<&ObservedReplica>)> = Vec::new();
325    for replica in &state.replicas {
326        match actual_by_shape
327            .iter_mut()
328            .find(|(s, _)| s.matches(&replica.shape))
329        {
330            Some((_, replicas)) => replicas.push(replica),
331            None => actual_by_shape.push((replica.shape.clone(), vec![replica])),
332        }
333    }
334
335    let mut decisions = Vec::new();
336
337    // Track existing names so freshly-created replicas avoid collisions.
338    let used_names: Vec<&str> = state.replicas.iter().map(|r| r.name.as_str()).collect();
339    let mut name_gen = ReplicaNameGen::new(&used_names);
340
341    // The compare-and-append witness for every create/drop this tick emits for
342    // the cluster: the apply path rejects the batch if the cluster's durable
343    // state has diverged from what we diffed against (e.g. a concurrent `ALTER`),
344    // so a stale create/drop can never reshape the replica set against the new
345    // config.
346    let expected = state.expected();
347
348    // Creates: for each desired shape, fill the gap below its desired count.
349    for d in &desired {
350        let actual_count = actual_by_shape
351            .iter()
352            .find(|(s, _)| s.matches(&d.shape))
353            .map(|(_, replicas)| replicas.len())
354            .unwrap_or(0);
355        for _ in actual_count..d.count {
356            decisions.push(Decision::CreateReplica {
357                cluster_id: state.cluster_id,
358                name: name_gen.next_name(),
359                shape: d.shape.clone(),
360                reasons: d.reasons.clone(),
361                expected: expected.clone(),
362            });
363        }
364    }
365
366    // Drops: any actual replica beyond the desired count for its shape, plus
367    // every replica of a shape no strategy desires.
368    for (shape, replicas) in &actual_by_shape {
369        let desired_count = desired
370            .iter()
371            .find(|d| d.shape.matches(shape))
372            .map(|d| d.count)
373            .unwrap_or(0);
374        for replica in replicas.iter().skip(desired_count) {
375            decisions.push(Decision::DropReplica {
376                cluster_id: state.cluster_id,
377                replica_id: replica.replica_id,
378                expected: expected.clone(),
379            });
380        }
381    }
382
383    decisions
384}
385
386/// A shape the union desires, how many, and which strategies wanted it.
387struct DesiredShape {
388    shape: ReplicaShape,
389    count: usize,
390    reasons: Vec<&'static str>,
391}
392
393/// Generates deterministic fresh replica names that avoid a set of in-use names.
394///
395/// The controller derives names from the observed actual set rather than
396/// renaming existing replicas, which keeps re-emission harmless. The concrete
397/// naming convention (the `rNN` managed-replica scheme) is the environment's; the
398/// kernel only needs distinct, stable-per-tick names, so it uses a simple
399/// monotonic scheme starting past the highest observed `rNN` index, and never
400/// below `r1` since managed-replica names are 1-based.
401struct ReplicaNameGen {
402    next: u32,
403    used: BTreeSet<String>,
404}
405
406impl ReplicaNameGen {
407    fn new(used: &[&str]) -> Self {
408        let mut highest = 1;
409        for name in used {
410            if let Some(idx) = name.strip_prefix('r').and_then(|n| n.parse::<u32>().ok()) {
411                highest = highest.max(idx + 1);
412            }
413        }
414        Self {
415            next: highest,
416            used: used.iter().map(|n| n.to_string()).collect(),
417        }
418    }
419
420    fn next_name(&mut self) -> String {
421        loop {
422            let name = format!("r{}", self.next);
423            self.next += 1;
424            if !self.used.contains(&name) {
425                self.used.insert(name.clone());
426                return name;
427            }
428        }
429    }
430}
431
432#[cfg(test)]
433mod tests;