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