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::{BTreeMap, BTreeSet};
40
41use mz_controller_types::ClusterId;
42use mz_ore::soft_panic_or_log;
43
44use crate::ctx::{
45    ApplyOutcome, ClusterControllerCtx, ClusterState, Decision, ObservedReplica,
46    ReconfigurationAudit, ReconfigurationRecord, ReconfigurationStatus, ReconfigurationWrite,
47    ReplicaShape, StateWrite,
48};
49use crate::strategy::{
50    BaselineStrategy, DesiredReplica, GracefulReconfigurationStrategy, LiveSignals, SignalRequest,
51    Strategy,
52};
53
54/// The cluster controller. Holds the (stateless) set of strategies and drives a
55/// reconcile tick against a [`ClusterControllerCtx`].
56pub struct ClusterController {
57    strategies: Vec<Box<dyn Strategy>>,
58}
59
60impl Default for ClusterController {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl ClusterController {
67    /// A controller with the implicit baseline and the graceful-reconfiguration
68    /// strategy. The baseline holds the steady set. Graceful engages only while a
69    /// `reconfiguration` record is in flight.
70    pub fn new() -> Self {
71        Self {
72            strategies: vec![
73                Box::new(BaselineStrategy),
74                Box::new(GracefulReconfigurationStrategy),
75            ],
76        }
77    }
78
79    /// Run one reconcile tick over every managed cluster the ctx reports.
80    ///
81    /// See the module docs for the two-phase structure. Both phases apply per
82    /// cluster, so a compare-and-append rejection on one cluster never blocks
83    /// progress on the others.
84    pub async fn reconcile(&self, ctx: &mut dyn ClusterControllerCtx) {
85        let cluster_ids = ctx.managed_cluster_ids().await;
86        if cluster_ids.is_empty() {
87            return;
88        }
89
90        // Phase 1: update_state. We merge every strategy's write for a cluster
91        // into one compare-and-append, applied per cluster and independently of
92        // other clusters. Two separate decisions live here.
93        //
94        // Per cluster, not one batch per tick: a write rejected because a
95        // concurrent `ALTER` moved the cluster off its `expected` rejects only
96        // that cluster and leaves the rest free to progress. One batched apply
97        // would let a single mid-`ALTER` cluster sink the whole tick, the failure
98        // mode at large cluster counts where some cluster is almost always
99        // mid-`ALTER`.
100        //
101        // Merged across strategies, not one apply per strategy: every strategy
102        // for a cluster shares the same start-of-tick `expected`, so applying
103        // them one at a time would let the first write move the cluster off that
104        // `expected` and reject all the rest, serializing a cluster's disjoint
105        // writes one-per-tick. Merging lands them together under one guard. We
106        // still rely on the compare-and-append, not the merge, for `ALTER`
107        // safety, which is why the merged write carries the cluster's `expected`.
108        // See `merge_state_writes` for the join and its conflict handling.
109        let states = ctx.cluster_states(&cluster_ids).await;
110        let signals = self.fetch_signals(ctx, &states).await;
111        let now = ctx.now();
112        // Set when we issue any phase-1 apply, applied or rejected. Either way
113        // the durable state may have moved (our write, or the concurrent `ALTER`
114        // that rejected it), so phase 2 re-reads.
115        let mut phase_1_wrote = false;
116        // Clusters whose phase-1 write was rejected. We skip their phase 2 this
117        // tick. Proceeding would be safe (we re-read below and every create/drop
118        // is guard-checked), but a cluster that just lost a race is likely still
119        // settling, so we let it recompute next tick instead of emitting work
120        // that is probably about to go stale.
121        let mut rejected = BTreeSet::new();
122        for state in &states {
123            let write = self.merge_state_writes(state, &signals[&state.cluster_id], now);
124            if write.is_empty() {
125                continue;
126            }
127            phase_1_wrote = true;
128            let decision = Decision::UpdateClusterState {
129                cluster_id: state.cluster_id,
130                expected: state.expected(),
131                write,
132            };
133            // A phase-1 batch carries no creates, so it cannot exhaust the
134            // resource budget. Treat any non-applied outcome as a rejection.
135            if ctx.apply(vec![decision]).await != ApplyOutcome::Applied {
136                rejected.insert(state.cluster_id);
137            }
138        }
139
140        // Phase 2: desired_replicas. The barrier exists so that a cut-over a
141        // phase-1 write performed is visible before we diff the replica set
142        // against the realized config. We re-read (and re-enrich) only if phase 1
143        // wrote. The first read is otherwise still current. A stale diff is
144        // harmless: every create/drop carries its `expected` and is guard-rejected
145        // if the durable state has since diverged.
146        let (states, signals) = if phase_1_wrote {
147            let states = ctx.cluster_states(&cluster_ids).await;
148            let signals = self.fetch_signals(ctx, &states).await;
149            (states, signals)
150        } else {
151            (states, signals)
152        };
153        let now = ctx.now();
154        for state in &states {
155            if rejected.contains(&state.cluster_id) {
156                continue;
157            }
158            let decisions = self.collect_replica_decisions(state, &signals[&state.cluster_id], now);
159            if decisions.is_empty() {
160                continue;
161            }
162            // Per-cluster apply: a guard failure here is isolated to this cluster,
163            // and benign anyway since every command names an explicit replica and
164            // is reconciled away next tick. We do not retry within the tick.
165            match ctx.apply(decisions).await {
166                ApplyOutcome::Applied | ApplyOutcome::Rejected => {}
167                ApplyOutcome::ResourceExhausted => {
168                    // The batch exceeded the resource budget. Retrying cannot make
169                    // the transient peak smaller, so shed the cluster's most
170                    // expendable transient strategy and recompute next tick.
171                    //
172                    // The failed apply rolled back without changing durable state,
173                    // so this tick's `expected` witness is still current, unless a
174                    // concurrent user `ALTER` re-targeted the record, in which case
175                    // the guard rejects the shed and that new reconfiguration is
176                    // left to converge instead of being clobbered.
177                    if let Some(shed) = Self::shed_decision(state) {
178                        let _ = ctx.apply(vec![shed]).await;
179                    }
180                }
181            }
182        }
183    }
184
185    /// The decision that sheds this cluster's most expendable transient strategy
186    /// after a resource-exhausted apply, or `None` if nothing sheddable is
187    /// active.
188    ///
189    /// The strategy to shed is chosen by presence, ranked by expendability, not
190    /// by which create failed: validation is aggregate, and the strategy worth
191    /// giving up may be one whose replicas already materialized rather than one
192    /// in the failed batch. The graceful reconfiguration is the most expendable:
193    /// a discretionary user change that fails cleanly (audited, and the wait-shim
194    /// reports a timeout) and can be retried, while aborting it leaves the
195    /// cluster running at its realized shape. The baseline is never shed, it is
196    /// the committed floor.
197    ///
198    /// We shed one strategy per exhausted apply. If that was not enough, the
199    /// next tick recomputes and sheds the next one.
200    fn shed_decision(state: &ClusterState) -> Option<Decision> {
201        let record = state.reconfiguration.as_ref()?;
202        if !record.is_in_progress() {
203            return None;
204        }
205        Some(Decision::UpdateClusterState {
206            cluster_id: state.cluster_id,
207            expected: state.expected(),
208            write: StateWrite {
209                reconfiguration: Some(ReconfigurationWrite {
210                    record: Some(ReconfigurationRecord {
211                        status: ReconfigurationStatus::ResourceExhausted,
212                        ..record.clone()
213                    }),
214                    audit: Some(ReconfigurationAudit::ResourceExhausted),
215                }),
216                ..Default::default()
217            },
218        })
219    }
220
221    /// Merge every strategy's [`Strategy::update_state`] for one cluster into the
222    /// single [`StateWrite`] the tick applies under one compare-and-append.
223    ///
224    /// The merge is a per-field join, independent of the order strategies run
225    /// in: a field set by exactly one strategy is taken as-is, a field no
226    /// strategy sets is left unchanged, and a field set to the same value by
227    /// several is that value.
228    ///
229    /// Two strategies setting one field to *different* values is a conflict.
230    /// Every field is owned by exactly one strategy, so by design it cannot
231    /// happen and the merge is really a disjoint union. We treat a conflict as
232    /// an invariant violation rather than a condition to resolve: there is no
233    /// safety-meaningful winner to pick for a contended `size` or record, so we
234    /// trip [`soft_panic_or_log!`] (a panic under test/CI soft assertions, a
235    /// logged error in production) and leave the field unchanged, the only
236    /// outcome that cannot make things worse. A persistent conflict then freezes
237    /// that field and keeps tripping the alarm, which is the point: surface the
238    /// design bug loudly instead of silently picking an arbitrary value.
239    fn merge_state_writes(
240        &self,
241        state: &ClusterState,
242        signals: &LiveSignals,
243        now: mz_repr::Timestamp,
244    ) -> StateWrite {
245        let writes: Vec<StateWrite> = self
246            .strategies
247            .iter()
248            .map(|strategy| strategy.update_state(state, signals, now))
249            .filter(|write| !write.is_empty())
250            .collect();
251
252        let mut conflicts: Vec<&'static str> = Vec::new();
253        // Exhaustive construction (every field named, no `..`): a field added to
254        // `StateWrite` is a compile error here until its join is spelled out.
255        let merged = StateWrite {
256            new_size: join(
257                "size",
258                writes.iter().map(|w| w.new_size.clone()),
259                &mut conflicts,
260            ),
261            new_replication_factor: join(
262                "replication_factor",
263                writes.iter().map(|w| w.new_replication_factor),
264                &mut conflicts,
265            ),
266            new_availability_zones: join(
267                "availability_zones",
268                writes.iter().map(|w| w.new_availability_zones.clone()),
269                &mut conflicts,
270            ),
271            new_logging: join(
272                "logging",
273                writes.iter().map(|w| w.new_logging.clone()),
274                &mut conflicts,
275            ),
276            reconfiguration: join(
277                "reconfiguration",
278                writes.iter().map(|w| w.reconfiguration.clone()),
279                &mut conflicts,
280            ),
281            burst: join(
282                "burst",
283                writes.iter().map(|w| w.burst.clone()),
284                &mut conflicts,
285            ),
286        };
287
288        if !conflicts.is_empty() {
289            soft_panic_or_log!(
290                "cluster {:?}: strategies produced conflicting state writes for \
291                 field(s) {}; leaving those fields unchanged. Strategies must own \
292                 disjoint `StateWrite` fields.",
293                state.cluster_id,
294                conflicts.join(", "),
295            );
296        }
297
298        merged
299    }
300
301    /// Fetch the live signals the strategies declared they need for `states`.
302    ///
303    /// Each strategy names its needs as a pure function of the durable state
304    /// ([`Strategy::signal_request`]), so the kernel stays ignorant of when a
305    /// strategy engages. Signals are fetched per cluster and only where
306    /// requested: a steady cluster is never probed, keeping the ctx seam
307    /// pay-for-what-you-use. The returned map has an entry for every state.
308    async fn fetch_signals(
309        &self,
310        ctx: &mut dyn ClusterControllerCtx,
311        states: &[ClusterState],
312    ) -> BTreeMap<ClusterId, LiveSignals> {
313        let mut signals = BTreeMap::new();
314        for state in states {
315            let request = self
316                .strategies
317                .iter()
318                .fold(SignalRequest::default(), |acc, strategy| {
319                    acc.union(strategy.signal_request(state))
320                });
321            let mut live = LiveSignals::default();
322            if request.hydration {
323                let replica_ids: Vec<_> = state.replicas.iter().map(|r| r.replica_id).collect();
324                if !replica_ids.is_empty() {
325                    live.hydrated_replicas =
326                        ctx.hydrated_replicas(state.cluster_id, &replica_ids).await;
327                }
328            }
329            signals.insert(state.cluster_id, live);
330        }
331        signals
332    }
333
334    /// Diff the unioned desired set against the actual replicas of one cluster
335    /// and emit the create/drop decisions that close the gap.
336    fn collect_replica_decisions(
337        &self,
338        state: &ClusterState,
339        signals: &LiveSignals,
340        now: mz_repr::Timestamp,
341    ) -> Vec<Decision> {
342        // Each strategy's contribution, tagged with the strategy name for
343        // attribution.
344        let contributions: Vec<(&'static str, Vec<DesiredReplica>)> = self
345            .strategies
346            .iter()
347            .map(|strategy| {
348                (
349                    strategy.name(),
350                    strategy.desired_replicas(state, signals, now),
351                )
352            })
353            .collect();
354
355        reconcile_replicas(state, &contributions)
356    }
357}
358
359/// Join one `StateWrite` field across the strategies that set it: `None` if
360/// none did, the common value if one or more set it to the same value, and
361/// `None` with `field` pushed onto `conflicts` if two set it to different
362/// values. The result and the conflict signal depend only on the set of values,
363/// not the order they arrive in.
364fn join<T: PartialEq>(
365    field: &'static str,
366    values: impl IntoIterator<Item = Option<T>>,
367    conflicts: &mut Vec<&'static str>,
368) -> Option<T> {
369    let mut merged: Option<T> = None;
370    for value in values.into_iter().flatten() {
371        match &merged {
372            None => merged = Some(value),
373            Some(existing) if *existing == value => {}
374            // Two strategies disagree on this field. Record it and leave the
375            // field unchanged; merge_state_writes raises the alarm.
376            Some(_) => {
377                conflicts.push(field);
378                return None;
379            }
380        }
381    }
382    merged
383}
384
385/// The pure multiset union/diff kernel for one cluster: given each strategy's
386/// desired replica slots and the actual replicas, match slots to replicas by
387/// shape and emit the creates and drops that close the gap.
388///
389/// Semantics:
390/// - The desired set is the multiset **union** of every strategy's slots: a
391///   given shape is desired `max` over strategies (not the sum), since a replica
392///   of that shape satisfies every strategy that wants one. This is what makes a
393///   replica survive iff *some* strategy desires its shape.
394/// - For each shape, if actual count < desired count we create the difference;
395///   if actual count > desired count we drop the difference, picking specific
396///   excess replicas. A replica of a shape no strategy desires is dropped.
397/// - Creates carry the names of the strategies that desired the shape. Drops
398///   carry no attribution, because a drop happens exactly when no strategy
399///   desires the replica.
400fn reconcile_replicas(
401    state: &ClusterState,
402    contributions: &[(&'static str, Vec<DesiredReplica>)],
403) -> Vec<Decision> {
404    // Desired count per shape = max over strategies of how many that strategy
405    // wants of the shape, and the union of which strategies want it.
406    let mut desired: Vec<DesiredShape> = Vec::new();
407    for (name, slots) in contributions {
408        // How many of each shape this strategy wants.
409        let mut per_shape: Vec<(ReplicaShape, usize)> = Vec::new();
410        for slot in slots {
411            match per_shape.iter_mut().find(|(s, _)| s.matches(&slot.shape)) {
412                Some((_, count)) => *count += 1,
413                None => per_shape.push((slot.shape.clone(), 1)),
414            }
415        }
416        for (shape, count) in per_shape {
417            match desired.iter_mut().find(|d| d.shape.matches(&shape)) {
418                Some(existing) => {
419                    existing.count = existing.count.max(count);
420                    if !existing.reasons.contains(name) {
421                        existing.reasons.push(*name);
422                    }
423                }
424                None => desired.push(DesiredShape {
425                    shape,
426                    count,
427                    reasons: vec![*name],
428                }),
429            }
430        }
431    }
432
433    // Bucket the actual replicas by shape.
434    let mut actual_by_shape: Vec<(ReplicaShape, Vec<&ObservedReplica>)> = Vec::new();
435    for replica in &state.replicas {
436        match actual_by_shape
437            .iter_mut()
438            .find(|(s, _)| s.matches(&replica.shape))
439        {
440            Some((_, replicas)) => replicas.push(replica),
441            None => actual_by_shape.push((replica.shape.clone(), vec![replica])),
442        }
443    }
444
445    let mut decisions = Vec::new();
446
447    // Track existing names so freshly-created replicas avoid collisions.
448    let used_names: Vec<&str> = state.replicas.iter().map(|r| r.name.as_str()).collect();
449    let mut name_gen = ReplicaNameGen::new(&used_names);
450
451    // The compare-and-append witness for every create/drop this tick emits for
452    // the cluster: the apply path rejects the batch if the cluster's durable
453    // state has diverged from what we diffed against (e.g. a concurrent `ALTER`),
454    // so a stale create/drop can never reshape the replica set against the new
455    // config.
456    let expected = state.expected();
457
458    // Creates: for each desired shape, fill the gap below its desired count.
459    for d in &desired {
460        let actual_count = actual_by_shape
461            .iter()
462            .find(|(s, _)| s.matches(&d.shape))
463            .map(|(_, replicas)| replicas.len())
464            .unwrap_or(0);
465        for _ in actual_count..d.count {
466            decisions.push(Decision::CreateReplica {
467                cluster_id: state.cluster_id,
468                name: name_gen.next_name(),
469                shape: d.shape.clone(),
470                reasons: d.reasons.clone(),
471                expected: expected.clone(),
472            });
473        }
474    }
475
476    // Drops: any actual replica beyond the desired count for its shape, plus
477    // every replica of a shape no strategy desires.
478    for (shape, replicas) in &actual_by_shape {
479        let desired_count = desired
480            .iter()
481            .find(|d| d.shape.matches(shape))
482            .map(|d| d.count)
483            .unwrap_or(0);
484        for replica in replicas.iter().skip(desired_count) {
485            decisions.push(Decision::DropReplica {
486                cluster_id: state.cluster_id,
487                replica_id: replica.replica_id,
488                expected: expected.clone(),
489            });
490        }
491    }
492
493    decisions
494}
495
496/// A shape the union desires, how many, and which strategies wanted it.
497struct DesiredShape {
498    shape: ReplicaShape,
499    count: usize,
500    reasons: Vec<&'static str>,
501}
502
503/// Generates deterministic fresh replica names that avoid a set of in-use names.
504///
505/// The controller derives names from the observed actual set rather than
506/// renaming existing replicas, which keeps re-emission harmless. The concrete
507/// naming convention (the `rNN` managed-replica scheme) is the environment's; the
508/// kernel only needs distinct, stable-per-tick names, so it uses a simple
509/// monotonic scheme starting past the highest observed `rNN` index, and never
510/// below `r1` since managed-replica names are 1-based.
511struct ReplicaNameGen {
512    next: u32,
513    used: BTreeSet<String>,
514}
515
516impl ReplicaNameGen {
517    fn new(used: &[&str]) -> Self {
518        let mut highest = 1;
519        for name in used {
520            if let Some(idx) = name.strip_prefix('r').and_then(|n| n.parse::<u32>().ok()) {
521                highest = highest.max(idx + 1);
522            }
523        }
524        Self {
525            next: highest,
526            used: used.iter().map(|n| n.to_string()).collect(),
527        }
528    }
529
530    fn next_name(&mut self) -> String {
531        loop {
532            let name = format!("r{}", self.next);
533            self.next += 1;
534            if !self.used.contains(&name) {
535                self.used.insert(name.clone());
536                return name;
537            }
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests;