Skip to main content

mz_cluster_controller/
ctx.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 boundary between the controller and its environment.
11//!
12//! [`ClusterControllerCtx`] is the single, strategy-agnostic boundary through which
13//! the controller pulls the signals a tick examines and applies the catalog
14//! mutations it derives. The signals in are primitive and carry no per-strategy
15//! state; the decisions out are primitive catalog mutations plus per-tick audit
16//! attribution. A create carries the [`CreateReason`] of the winning strategy
17//! behind it, which the environment turns into the audit event. The controller
18//! crate knows nothing about the Coordinator. The Coordinator implements this
19//! trait, which is what makes the controller testable against a fake
20//! implementation and extractable later without touching controller code.
21//!
22//! The interface is **pull-based**: a tick fetches only the signals it actually
23//! examines (no eager all-clusters-all-replicas snapshot is pushed in), and the
24//! controller drives what is fetched. Read methods are batched so a separate-task
25//! deployment can bound its round-trips to the Coordinator.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::time::Duration;
29
30use async_trait::async_trait;
31use mz_compute_types::config::ComputeReplicaLogging;
32use mz_controller_types::{ClusterId, ReplicaId};
33use mz_repr::refresh_schedule::RefreshSchedule;
34use mz_repr::{GlobalId, Timestamp};
35use timely::progress::Antichain;
36
37// The compare-and-append witness types, and the replica shape they pair with,
38// live in `mz-adapter-types` so the catalog transaction that applies a
39// decision can share them without depending on this crate. They are part of the
40// ctx vocabulary, so re-export them here.
41pub use mz_adapter_types::cluster_state::{
42    AutoScalingPolicy, AvailabilityZones, BurstAudit, BurstFinishCause, BurstRecord,
43    ClusterSchedule, ExpectedClusterState, OnHydrationPolicy, OnTimeout, ReconfigurationAudit,
44    ReconfigurationRecord, ReconfigurationStatus, ReconfigurationTarget, ReplicaShape,
45};
46
47/// A replica that actually exists on a cluster, as observed through the ctx.
48/// Every replica physically on the cluster appears here, whether or not the
49/// controller owns it; [`Self::owned_shape`] is the ownership test.
50#[derive(Clone, Debug)]
51pub struct ObservedReplica {
52    pub replica_id: ReplicaId,
53    pub name: String,
54    /// `None` for a replica with an unmanaged location, which has no managed
55    /// shape to reconcile against.
56    pub shape: Option<ReplicaShape>,
57    /// Created with `INTERNAL`.
58    pub internal: bool,
59    /// Carries a `BILLED AS` override.
60    pub billed_as: bool,
61    /// Durably marked `pending`. Vestigial: no path creates one anymore, but a
62    /// crash on an older version could have left one behind.
63    pub pending: bool,
64}
65
66impl ObservedReplica {
67    /// The replica's shape if the controller owns it, `None` otherwise.
68    ///
69    /// INTERNAL / BILLED AS replicas are manually managed: a user can attach
70    /// one to any managed cluster, outside the replication-factor domain. A
71    /// durably `pending` replica is stranded state from an older version, reaped
72    /// by the catalog-open migration rather than here. The controller must
73    /// neither count such a replica toward a desired shape nor drop it as
74    /// excess, but their names still block the name generator, since every
75    /// replica observed here occupies a name.
76    pub fn owned_shape(&self) -> Option<&ReplicaShape> {
77        if self.internal || self.billed_as || self.pending {
78            return None;
79        }
80        self.shape.as_ref()
81    }
82}
83
84/// One REFRESH materialized view bound to a scheduled cluster, as the on-refresh
85/// strategy needs to see it.
86///
87/// `write_frontier` is the MV's storage write frontier, carried with full
88/// fidelity as the `Antichain` the storage controller reports. The strategy
89/// compares it against the read timestamp (`less_than`) to decide whether the MV
90/// still needs a refresh. For the compaction window it reads the frontier's lone
91/// element via `as_option` to find the previous refresh time, falling back to the
92/// schedule's last refresh on the empty/sealed frontier `[]`. The frontier of a
93/// single-input total-order MV holds at most one element.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct RefreshMvInfo {
96    /// The MV's writes-`GlobalId`: the identity the window decision records in
97    /// [`RefreshWindowDecision`] so the audit log can say which MVs kept the
98    /// cluster on.
99    pub id: GlobalId,
100    pub write_frontier: Antichain<Timestamp>,
101    pub refresh_schedule: RefreshSchedule,
102}
103
104/// Why a strategy desires a replica slot: the audit attribution a create
105/// decision carries. When several strategies desire the same shape the
106/// winning reason is decided by [`CreateReason::outranks`], since the audit
107/// event carries exactly one reason.
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub enum CreateReason {
110    /// The implicit baseline: the user's own cluster config calls for the
111    /// replica. The environment audits it as a manual create.
112    Baseline,
113    /// The graceful-reconfiguration strategy converging an in-flight
114    /// background `ALTER CLUSTER`.
115    GracefulReconfiguration,
116    /// The hydration-burst strategy accelerating hydration.
117    HydrationBurst,
118    /// The on-refresh strategy holding a scheduled cluster on inside a
119    /// refresh window. Embeds the window decision behind the create, which
120    /// the environment renders into the audit event's detail. Embedding it
121    /// makes "the decision detail appears iff the create audits the schedule
122    /// reason" structural: when another reason wins the precedence, the
123    /// decision is discarded with it.
124    OnRefresh(RefreshWindowDecision),
125}
126
127impl CreateReason {
128    /// Whether this reason wins over `other` when several strategies desire
129    /// the same shape, so the create audits this reason.
130    ///
131    /// A pairwise check rather than `Ord`: an `Ord` ranking by variant would
132    /// have to call two `OnRefresh` reasons with different window decisions
133    /// equal while `Eq` says they differ, violating the `Ord` contract.
134    pub fn outranks(&self, other: &CreateReason) -> bool {
135        self.rank() > other.rank()
136    }
137
138    fn rank(&self) -> u8 {
139        match self {
140            // Graceful wins over burst when both desire a shape (their shapes
141            // differ in practice, so this is a stable tie-break), both win
142            // over on-refresh, and the baseline loses to everything: any
143            // strategy's reason beats the implicit "the config calls for it".
144            CreateReason::Baseline => 0,
145            CreateReason::OnRefresh(_) => 1,
146            CreateReason::HydrationBurst => 2,
147            CreateReason::GracefulReconfiguration => 3,
148        }
149    }
150}
151
152/// The on-refresh strategy's per-tick window decision: which bound REFRESH MVs
153/// keep a scheduled cluster on, and why. The window is open iff either list is
154/// non-empty, so an open window always has an explanation.
155///
156/// Carried inside [`CreateReason::OnRefresh`] on the create decisions the open
157/// window produces. The environment converts it to the audit log's
158/// `scheduling_policies` detail. Plain ids and durations so the controller crate
159/// stays free of audit-log vocabulary.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct RefreshWindowDecision {
162    /// MVs whose write frontier has not yet passed the (hydration-adjusted) read
163    /// timestamp: a refresh is due or imminent.
164    pub objects_needing_refresh: Vec<GlobalId>,
165    /// MVs estimated to still need Persist compaction after their last refresh.
166    pub objects_needing_compaction: Vec<GlobalId>,
167    /// The cluster's `HYDRATION TIME ESTIMATE` the refresh window was widened by.
168    pub hydration_time_estimate: Duration,
169}
170
171impl RefreshWindowDecision {
172    /// Whether the refresh window is open: some MV still needs a refresh or
173    /// compaction time.
174    pub fn window_open(&self) -> bool {
175        !self.objects_needing_refresh.is_empty() || !self.objects_needing_compaction.is_empty()
176    }
177}
178
179/// The catalog and storage inputs for one scheduled cluster's refresh window.
180#[derive(Clone, Debug, PartialEq, Eq)]
181pub struct RefreshWindowClusterInputs {
182    /// How long after a refresh an MV is estimated to still need Persist
183    /// compaction, which also keeps the cluster on.
184    pub compaction_estimate: Duration,
185    /// The REFRESH MVs bound to the cluster.
186    pub refresh_mvs: Vec<RefreshMvInfo>,
187}
188
189/// Refresh-window inputs gathered for one reconciliation phase.
190///
191/// The top-level timestamp makes sharing one oracle read across every included
192/// cluster structural. A cluster absent from `cluster_inputs` has unavailable
193/// inputs and must not be reconciled during the phase.
194#[derive(Clone, Debug, PartialEq, Eq)]
195pub struct RefreshWindowInputsBatch {
196    /// The local oracle read timestamp for every cluster in the batch.
197    pub read_ts: Timestamp,
198    /// The available catalog and storage inputs, keyed by cluster.
199    pub cluster_inputs: BTreeMap<ClusterId, RefreshWindowClusterInputs>,
200}
201
202/// The fulfilled live signal the on-refresh strategy uses for one cluster.
203///
204/// Pulled on demand only for scheduled clusters. A MANUAL cluster carries
205/// `None` and is never probed.
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct RefreshWindowInputs {
208    /// The shared local oracle read timestamp the window decision uses.
209    pub read_ts: Timestamp,
210    /// How long after a refresh an MV is estimated to still need Persist
211    /// compaction, which also keeps the cluster on.
212    pub compaction_estimate: Duration,
213    /// The REFRESH MVs bound to the cluster.
214    pub refresh_mvs: Vec<RefreshMvInfo>,
215}
216
217/// The durable state of a single managed cluster plus its observed replicas, as
218/// pulled through the ctx for one reconcile tick.
219///
220/// This is the input every strategy reads. Unmanaged clusters are not
221/// controller-owned and are not represented here.
222///
223/// The `size`, `replication_factor`, `availability_zones`, `logging`, and
224/// `arrangement_compression` fields together are the realized config the
225/// cluster is currently serving. The implicit baseline desires
226/// `replication_factor` replicas at that shape.
227#[derive(Clone, Debug)]
228pub struct ClusterState {
229    pub cluster_id: ClusterId,
230    pub size: String,
231    pub replication_factor: u32,
232    pub availability_zones: Vec<String>,
233    pub logging: ComputeReplicaLogging,
234    pub arrangement_compression: bool,
235    /// The cluster's scheduling policy. Drives whether the implicit baseline owns
236    /// the replica set (MANUAL) or the on-refresh strategy does (REFRESH).
237    pub schedule: ClusterSchedule,
238    pub auto_scaling_policy: Option<AutoScalingPolicy>,
239    /// Latest graceful reconfiguration record, if one has been written.
240    pub reconfiguration: Option<ReconfigurationRecord>,
241    /// In-flight hydration burst, if any.
242    pub burst: Option<BurstRecord>,
243    /// The replicas that actually exist on the cluster, owned or not.
244    pub replicas: Vec<ObservedReplica>,
245}
246
247impl ClusterState {
248    /// The shape the implicit baseline desires: the realized config.
249    pub fn realized_shape(&self) -> ReplicaShape {
250        ReplicaShape {
251            size: self.size.clone(),
252            availability_zones: AvailabilityZones(self.availability_zones.clone()),
253            logging: self.logging.clone(),
254            arrangement_compression: self.arrangement_compression,
255        }
256    }
257
258    /// The compare-and-append witness for decisions derived from this state: the
259    /// durable fields a concurrent `ALTER` could change out from under a tick.
260    pub fn expected(&self) -> ExpectedClusterState {
261        ExpectedClusterState {
262            size: self.size.clone(),
263            replication_factor: self.replication_factor,
264            availability_zones: AvailabilityZones(self.availability_zones.clone()),
265            logging: self.logging.clone(),
266            arrangement_compression: self.arrangement_compression,
267            schedule: self.schedule,
268            auto_scaling_policy: self.auto_scaling_policy.clone(),
269            reconfiguration: self.reconfiguration.clone(),
270            burst: self.burst.clone(),
271        }
272    }
273}
274
275/// A durable state mutation a strategy's `update_state` asks for: cut over the
276/// realized config to a target and/or write or clear the reconfiguration/burst
277/// records. The reconcile kernel pairs it with the [`ExpectedClusterState`] it
278/// was derived from for the compare-and-append guard.
279#[derive(Clone, Debug, Default, PartialEq, Eq)]
280pub struct StateWrite {
281    /// New realized config to cut over to. `None` leaves it unchanged.
282    pub new_size: Option<String>,
283    pub new_replication_factor: Option<u32>,
284    pub new_availability_zones: Option<Vec<String>>,
285    pub new_logging: Option<ComputeReplicaLogging>,
286    pub new_arrangement_compression: Option<bool>,
287    /// Write or clear the reconfiguration record, together with its audit
288    /// intent. `None` leaves the record unchanged.
289    pub reconfiguration: Option<ReconfigurationWrite>,
290    /// Write or clear the burst record, together with its audit intent.
291    /// `None` leaves the record unchanged.
292    pub burst: Option<BurstWrite>,
293}
294
295/// A write to the `reconfiguration` record, bundled with the audit intent
296/// declaring which lifecycle transition the write represents.
297///
298/// Bundling means a writer cannot move the record without deciding, at the same
299/// decision point, what the papertrail should say. The two travel together
300/// through the apply path and are transacted atomically with the state.
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct ReconfigurationWrite {
303    /// The record to write, or `None` to clear it.
304    pub record: Option<ReconfigurationRecord>,
305    /// The lifecycle transition to audit. `None` declares that this write is
306    /// not a lifecycle transition and must not emit an event.
307    pub audit: Option<ReconfigurationAudit>,
308}
309
310/// A write to the `burst` record, bundled with its audit intent. See
311/// [`ReconfigurationWrite`]. A bookkeeping rewrite of an existing record (the
312/// linger stamp and its reset) declares `audit: None`.
313#[derive(Clone, Debug, PartialEq, Eq)]
314pub struct BurstWrite {
315    /// The record to write, or `None` to clear it.
316    pub record: Option<BurstRecord>,
317    /// The lifecycle transition to audit, or `None` for a bookkeeping rewrite.
318    pub audit: Option<BurstAudit>,
319}
320
321impl StateWrite {
322    /// Whether this write would actually mutate any durable field.
323    pub fn is_empty(&self) -> bool {
324        // Exhaustive destructure (no `..`): a field added to `StateWrite` is a
325        // compile error here until it's accounted for.
326        let StateWrite {
327            new_size,
328            new_replication_factor,
329            new_availability_zones,
330            new_logging,
331            new_arrangement_compression,
332            reconfiguration,
333            burst,
334        } = self;
335        new_size.is_none()
336            && new_replication_factor.is_none()
337            && new_availability_zones.is_none()
338            && new_logging.is_none()
339            && new_arrangement_compression.is_none()
340            && reconfiguration.is_none()
341            && burst.is_none()
342    }
343}
344
345/// A single command the controller emits for the environment to transact. The
346/// apply path interprets these and turns them into catalog operations.
347///
348/// Every variant carries the [`ExpectedClusterState`] the decision was derived
349/// from. The apply path re-reads each target cluster and rejects the whole batch
350/// if any state has since diverged (compare-and-append), so a user `ALTER` that
351/// lands mid-tick cannot let a stale create or drop reshape the replica set
352/// against the new config; the controller recomputes from the new state next
353/// tick.
354#[derive(Clone, Debug)]
355pub enum Decision {
356    /// Create a replica of the given shape under a deterministic fresh name.
357    /// `reason` is the audit attribution: the winning [`CreateReason`] among
358    /// the strategies that desired the shape (see [`CreateReason::outranks`]).
359    CreateReplica {
360        cluster_id: ClusterId,
361        name: String,
362        shape: ReplicaShape,
363        reason: CreateReason,
364        expected: ExpectedClusterState,
365    },
366    /// Drop a specific existing replica. A drop happens exactly when no
367    /// strategy desires the replica, so it carries no strategy attribution;
368    /// the apply path audits every controller drop with the uniform `retired`
369    /// reason.
370    DropReplica {
371        cluster_id: ClusterId,
372        replica_id: ReplicaId,
373        expected: ExpectedClusterState,
374    },
375    /// Apply a durable state write under a compare-and-append guard against
376    /// `expected`.
377    UpdateClusterState {
378        cluster_id: ClusterId,
379        expected: ExpectedClusterState,
380        write: StateWrite,
381    },
382}
383
384/// The outcome of applying one tick's batch of [`Decision`]s.
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386pub enum ApplyOutcome {
387    /// Every decision in the batch was transacted.
388    Applied,
389    /// At least one decision failed its compare-and-append guard. The whole
390    /// batch is rejected; the controller recomputes next tick.
391    Rejected,
392    /// The batch was rejected because it exceeded the environment's resource
393    /// budget. No requested cluster mutation was transacted. The controller
394    /// sheds an active graceful reconfiguration when possible. Otherwise it
395    /// retries on later ticks, when desired state or available capacity may
396    /// have changed.
397    ResourceExhausted,
398}
399
400/// The strategy-agnostic pull/apply interface between the controller and its
401/// environment.
402///
403/// The controller depends on exactly this trait. Reads are batched and pulled
404/// on demand; the single write applies a tick's batch under a compare-and-append
405/// guard. Implementations marshal these to wherever the live signals live (for
406/// v1, the Coordinator's catalog and compute/storage controllers, reached over a
407/// channel from the controller's own task, hence the `Send` bound).
408#[async_trait]
409pub trait ClusterControllerCtx: Send {
410    /// Current wall-clock time, as the controller's strategies should see it.
411    fn now(&self) -> Timestamp;
412
413    /// A consistent durable view of the given managed clusters and their
414    /// replicas. Clusters that do not exist or are unmanaged are omitted from
415    /// the result.
416    async fn cluster_states(&mut self, clusters: &[ClusterId]) -> Vec<ClusterState>;
417
418    /// The ids of all managed clusters the controller owns this tick.
419    async fn managed_cluster_ids(&mut self) -> Vec<ClusterId>;
420
421    /// Of `replicas` on `cluster`, which are online and have *all* current
422    /// (non-transient) collections on the cluster hydrated. The returned set
423    /// is a subset of `replicas`.
424    ///
425    /// Callers should request only replicas their strategy currently needs. This
426    /// keeps live-signal dependencies local to the strategies that consume them.
427    async fn hydrated_replicas(
428        &mut self,
429        cluster_id: ClusterId,
430        replicas: &[ReplicaId],
431    ) -> BTreeSet<ReplicaId>;
432
433    /// Whether `cluster_id` has at least one hydratable (dataflow-backed) object
434    /// bound to it: an index, materialized view, ingestion source, or sink.
435    ///
436    /// A catalog-level approximation of "the hydration check has something to
437    /// count". Where the two disagree at the margin, the mismatch is
438    /// self-healing: a replica with nothing to hydrate reads hydrated, and the
439    /// burst winds down via its linger.
440    async fn has_hydratable_objects(&mut self, cluster_id: ClusterId) -> bool;
441
442    /// The refresh-window live signals for the given scheduled clusters.
443    /// Returns one shared read timestamp plus the available per-cluster catalog
444    /// and storage inputs. Omits a cluster when its inputs are unavailable,
445    /// including when it is missing, unmanaged, or no longer scheduled `ON
446    /// REFRESH` at pull time. Returns `None` when the batch fails or no requested
447    /// cluster has valid inputs.
448    ///
449    /// Pulled on demand the same way as [`Self::hydrated_replicas`]: the
450    /// controller includes a cluster only when the on-refresh strategy needs the
451    /// signal (i.e. the cluster is scheduled), so a steady MANUAL cluster never
452    /// pays for it. Implementations fetch the shared read timestamp once after
453    /// gathering the per-cluster inputs, so oracle latency does not scale with
454    /// the cluster count. The controller skips any omitted cluster for the
455    /// reconciliation phase.
456    async fn refresh_window_inputs(
457        &mut self,
458        cluster_ids: &[ClusterId],
459    ) -> Option<RefreshWindowInputsBatch>;
460
461    /// Apply a tick's batch of decisions under their compare-and-append guards.
462    /// Each decision carries the [`ExpectedClusterState`] it was derived from;
463    /// the implementation re-reads every target cluster and, if any has since
464    /// diverged, returns [`ApplyOutcome::Rejected`] without transacting anything.
465    /// Otherwise the batch's catalog operations are transacted together.
466    async fn apply(&mut self, decisions: Vec<Decision>) -> ApplyOutcome;
467}