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;
30use std::time::Duration;
31
32use mz_controller_types::ReplicaId;
33use mz_repr::{Timestamp, TimestampManipulation};
34
35use crate::ctx::{
36 AvailabilityZones, BurstAudit, BurstFinishCause, BurstRecord, BurstWrite, ClusterSchedule,
37 ClusterState, CreateReason, OnTimeout, ReconfigurationAudit, ReconfigurationRecord,
38 ReconfigurationStatus, ReconfigurationWrite, RefreshWindowDecision, RefreshWindowInputs,
39 ReplicaShape, StateWrite,
40};
41
42/// A replica slot a strategy desires this tick. The reconcile kernel unions
43/// slots across strategies and matches them by [`ReplicaShape`] against the
44/// actual replica set.
45#[derive(Clone, Debug)]
46pub struct DesiredReplica {
47 pub shape: ReplicaShape,
48 /// Why the strategy desires the slot. Carried through the kernel onto the
49 /// create decision a slot may produce (per shape, the highest-precedence
50 /// reason among the contributing slots wins).
51 pub reason: CreateReason,
52}
53
54/// One cluster-autoscaling strategy: a pair of pure functions the controller
55/// runs each tick. See the module docs.
56///
57/// `Send + Sync` so the controller (which holds a set of boxed strategies) can
58/// run on its own task.
59pub trait Strategy: Send + Sync {
60 /// The live signals this strategy needs to evaluate `state` this tick,
61 /// declared as a pure function of the durable state and the tick's config
62 /// signals. The kernel unions the requests across strategies, fetches them
63 /// through the ctx, and passes the result to [`Strategy::update_state`] and
64 /// [`Strategy::desired_replicas`]. The default requests nothing, which suits
65 /// a strategy that works off durable state alone (like the baseline).
66 fn signal_request(&self, _state: &ClusterState, _config: &ConfigSignals) -> SignalRequest {
67 SignalRequest::default()
68 }
69
70 /// The durable writes this strategy wants for `state` at time `now`. The
71 /// default is no write, which suits a strategy that only ever contributes
72 /// replicas (like the baseline). An empty [`StateWrite`] means "write
73 /// nothing": the kernel drops it without emitting a decision.
74 fn update_state(
75 &self,
76 _state: &ClusterState,
77 _signals: &LiveSignals,
78 _config: &ConfigSignals,
79 _now: Timestamp,
80 ) -> StateWrite {
81 StateWrite::default()
82 }
83
84 /// The replica slots this strategy contributes to `state`'s desired set at
85 /// time `now`.
86 fn desired_replicas(
87 &self,
88 state: &ClusterState,
89 signals: &LiveSignals,
90 config: &ConfigSignals,
91 now: Timestamp,
92 ) -> Vec<DesiredReplica>;
93}
94
95/// The live signals a strategy asks the kernel to fetch before evaluating a
96/// cluster, declared through [`Strategy::signal_request`].
97///
98/// Live signals are observations (hydration and the like) that are not durable
99/// state, so they never participate in the compare-and-append witness. Keeping
100/// them out of [`ClusterState`] keeps that type exactly the witness material
101/// plus the observed replica set.
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
103pub struct SignalRequest {
104 /// Probe which of the cluster's replicas report all collections hydrated.
105 pub hydration: bool,
106 /// Check whether the cluster has at least one hydratable object bound to
107 /// it. See `ClusterControllerCtx::has_hydratable_objects` for what counts.
108 pub hydratable_objects: bool,
109 /// Pull the refresh-window inputs (bound REFRESH MV frontiers, schedules,
110 /// the current read timestamp).
111 pub refresh_window: bool,
112}
113
114impl SignalRequest {
115 /// The union of two requests: a signal is fetched if any strategy asks.
116 pub fn union(self, other: SignalRequest) -> SignalRequest {
117 // Exhaustive destructure (no `..`): a signal added to the request is a
118 // compile error here until its union is spelled out.
119 let SignalRequest {
120 hydration,
121 hydratable_objects,
122 refresh_window,
123 } = other;
124 SignalRequest {
125 hydration: self.hydration || hydration,
126 hydratable_objects: self.hydratable_objects || hydratable_objects,
127 refresh_window: self.refresh_window || refresh_window,
128 }
129 }
130}
131
132/// Environment-wide configuration the strategies consult, latched by the kernel
133/// once per tick from the controller's dyncfgs so every strategy decides against
134/// one consistent config. Not durable cluster state, so never witness material.
135#[derive(Clone, Debug, Default, PartialEq, Eq)]
136pub struct ConfigSignals {
137 /// Whether the hydration-burst strategy is enabled environment-wide (the
138 /// break-glass flag).
139 pub burst_enabled: bool,
140 /// The system-default burst linger duration, written into a new `burst`
141 /// record when the policy's `linger_duration` is omitted.
142 pub default_burst_linger: Duration,
143}
144
145/// The fulfilled live signals for one cluster, fetched by the kernel per the
146/// unioned [`SignalRequest`] and passed alongside [`ClusterState`].
147///
148/// A signal nobody requested is left at its empty default, so a strategy must
149/// only read what it declared in [`Strategy::signal_request`].
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
151pub struct LiveSignals {
152 /// The replicas observed this tick to be online and to have *all* current
153 /// collections on the cluster hydrated.
154 pub hydrated_replicas: BTreeSet<ReplicaId>,
155 /// Whether the cluster has at least one hydratable object. `false` when not
156 /// requested.
157 pub has_hydratable_objects: bool,
158 /// The refresh-window inputs. `None` when not requested, or when the
159 /// cluster was gone, unmanaged, or no longer scheduled `ON REFRESH` when
160 /// the ctx pulled (see [`ClusterControllerCtx::refresh_window_inputs`]).
161 ///
162 /// [`ClusterControllerCtx::refresh_window_inputs`]:
163 /// crate::ctx::ClusterControllerCtx::refresh_window_inputs
164 pub refresh_window: Option<RefreshWindowInputs>,
165}
166
167/// The implicit baseline strategy, always present.
168///
169/// Desires `replication_factor` replicas at the cluster's realized shape
170/// (`cluster.size` plus its AZ pool, logging, and arrangement compression). It
171/// holds the steady-state set so that policy strategies normally only add to
172/// it. With only the baseline engaged, the desired set equals the realized set,
173/// so a steady-state managed cluster reconciles to no decisions.
174///
175/// The baseline holds the set only for MANUAL clusters. On a scheduled cluster
176/// the controller (not the user's `replication_factor`) owns the replica set,
177/// so the baseline desires nothing there and the on-refresh strategy is the sole
178/// contributor. (The on-refresh strategy also normalizes a scheduled cluster's
179/// `replication_factor` to `0` via `update_state`, so the two views agree after
180/// the first tick regardless.)
181///
182/// The one case where the baseline steps aside is a forced cut-over, see
183/// `forced_cutover_pending`.
184#[derive(Clone, Copy, Debug, Default)]
185pub struct BaselineStrategy;
186
187/// Whether a forced cut-over is imminent: an in-progress reconfiguration is
188/// past its deadline under `ON TIMEOUT COMMIT`, so the next cut-over commits
189/// the target whether or not it hydrated.
190///
191/// In that window the baseline yields its realized-shape replicas. Overlapping
192/// the two sets only buys availability while the target hydrates, and a forced
193/// cut-over has given up on hydration. Yielding turns the reshape into one
194/// transaction that retires the realized replicas and creates the target's, so
195/// it has to fit the larger of the two shapes rather than their sum. That is
196/// what lets a resize succeed on a budget that has no room for overlap, and it
197/// is the only way to shrink a cluster that is already near its limit.
198///
199/// If that single transaction still does not fit, it is rejected whole and the
200/// record is left in progress for `ClusterController::shed_decision` to shed,
201/// so an unaffordable target stays observable rather than half-applied.
202fn forced_cutover_pending(state: &ClusterState, now: Timestamp) -> bool {
203 state.reconfiguration.as_ref().is_some_and(|record| {
204 record.is_in_progress()
205 && now >= record.deadline
206 && matches!(record.on_timeout, OnTimeout::Commit)
207 })
208}
209
210impl Strategy for BaselineStrategy {
211 fn desired_replicas(
212 &self,
213 state: &ClusterState,
214 _signals: &LiveSignals,
215 _config: &ConfigSignals,
216 now: Timestamp,
217 ) -> Vec<DesiredReplica> {
218 if !matches!(state.schedule, ClusterSchedule::Manual) {
219 return Vec::new();
220 }
221 if forced_cutover_pending(state, now) {
222 return Vec::new();
223 }
224 let shape = state.realized_shape();
225 (0..state.replication_factor)
226 .map(|_| DesiredReplica {
227 shape: shape.clone(),
228 reason: CreateReason::Baseline,
229 })
230 .collect()
231 }
232}
233
234/// The graceful (zero-downtime) reconfiguration strategy.
235///
236/// Engaged whenever the durable `reconfiguration` record is in progress. It
237/// desires `target.replication_factor` replicas at the target shape in addition
238/// to the baseline's realized-shape replicas, so both sets serve while the new
239/// one hydrates. Once rf-many target replicas are present and hydrated,
240/// `update_state` cuts over: the realized config advances to the target, the
241/// record is marked finalized, and the old replicas fall out of the union and
242/// are dropped. Success takes precedence over the deadline. On a timeout,
243/// `Commit` cuts over once the complete target set exists without waiting for
244/// hydration, and the baseline stops contributing in that window so the two
245/// sets swap in one transaction rather than overlapping (see
246/// `forced_cutover_pending`). `Rollback` (the default) marks the record timed
247/// out without touching the realized config and stops desiring the target
248/// replicas, reverting to the pre-reconfiguration set.
249///
250/// Both functions are pure over the observed [`ClusterState`] and the fetched
251/// [`LiveSignals`]. Hydration is requested via [`Strategy::signal_request`]
252/// exactly while an in-progress reconfiguration is present.
253#[derive(Clone, Copy, Debug, Default)]
254pub struct GracefulReconfigurationStrategy;
255
256impl GracefulReconfigurationStrategy {
257 /// Whether the cut-over precondition holds: at least
258 /// `target.replication_factor` replicas of the target shape report
259 /// hydrated.
260 ///
261 /// Requiring rf-many hydrated replicas (not just one) preserves the
262 /// high-availability guarantee of `replication_factor > 1` across the
263 /// cut-over. Extra target-shape replicas beyond the rf do not block: the
264 /// post-cut-over reconcile retires them anyway, so waiting for them to
265 /// hydrate would only delay the cut-over.
266 fn target_hydrated(
267 &self,
268 state: &ClusterState,
269 signals: &LiveSignals,
270 record: &ReconfigurationRecord,
271 ) -> bool {
272 let target_shape = record.target.shape();
273 let hydrated_target_replicas = state
274 .replicas
275 .iter()
276 .filter(|r| r.owned_shape().is_some_and(|s| s.matches(&target_shape)))
277 .filter(|r| signals.hydrated_replicas.contains(&r.replica_id))
278 .count();
279 let target_rf = usize::try_from(record.target.replication_factor).unwrap_or(usize::MAX);
280 hydrated_target_replicas >= target_rf
281 }
282
283 /// Whether the complete target set exists, without requiring hydration.
284 fn target_materialized(&self, state: &ClusterState, record: &ReconfigurationRecord) -> bool {
285 let target_shape = record.target.shape();
286 let target_replicas = state
287 .replicas
288 .iter()
289 .filter(|r| r.owned_shape().is_some_and(|s| s.matches(&target_shape)))
290 .count();
291 let target_rf = usize::try_from(record.target.replication_factor).unwrap_or(usize::MAX);
292 target_replicas >= target_rf
293 }
294}
295
296impl Strategy for GracefulReconfigurationStrategy {
297 fn signal_request(&self, state: &ClusterState, _config: &ConfigSignals) -> SignalRequest {
298 SignalRequest {
299 hydration: state
300 .reconfiguration
301 .as_ref()
302 .is_some_and(|record| record.is_in_progress()),
303 ..Default::default()
304 }
305 }
306
307 fn update_state(
308 &self,
309 state: &ClusterState,
310 signals: &LiveSignals,
311 _config: &ConfigSignals,
312 now: Timestamp,
313 ) -> StateWrite {
314 let Some(record) = &state.reconfiguration else {
315 return StateWrite::default();
316 };
317 if !record.is_in_progress() {
318 return StateWrite::default();
319 }
320
321 // Cut over by advancing the realized config to the target and marking
322 // the record finalized on either of two conditions:
323 // 1. rf-many target replicas are present and hydrated (success, which
324 // takes precedence over the deadline regardless of `on_timeout`), or
325 // 2. the deadline has been reached, `on_timeout` is `Commit`, and the
326 // complete target set exists (cut over without waiting for hydration).
327 //
328 // NOTE: the deadline is reached at `now >= deadline`, not `now > deadline`.
329 // An `ON TIMEOUT COMMIT` with a zero timeout writes `deadline = now` to
330 // request an immediate cut-over. With a strict `>`, a first tick landing at
331 // exactly that timestamp would miss the deadline, so phase 2 would provision
332 // the overlap target replicas and only a later tick would cut over. `>=`
333 // fires the deadline the instant it is reached, so the zero-timeout cut-over
334 // happens on the first tick, before any overlap replica is desired.
335 // We require the target set to exist before a forced cut-over so its
336 // concrete create transaction can enforce resource limits. Otherwise a
337 // zero-timeout commit could finalize first, fail to create the new
338 // baseline, and leave no in-progress strategy for the controller to shed.
339 // The baseline yields while we wait (see `forced_cutover_pending`), so
340 // that create arrives in the same transaction that retires the realized
341 // replicas and does not have to fit alongside them.
342 let hydrated = self.target_hydrated(state, signals, record);
343 let deadline_reached = now >= record.deadline;
344 let commit_on_timeout = deadline_reached && matches!(record.on_timeout, OnTimeout::Commit);
345 let target_materialized = self.target_materialized(state, record);
346 if hydrated || (commit_on_timeout && target_materialized) {
347 return StateWrite {
348 new_size: Some(record.target.size.clone()),
349 new_replication_factor: Some(record.target.replication_factor),
350 new_availability_zones: Some(record.target.availability_zones.0.clone()),
351 new_logging: Some(record.target.logging.clone()),
352 new_arrangement_compression: Some(record.target.arrangement_compression),
353 reconfiguration: Some(ReconfigurationWrite {
354 record: Some(ReconfigurationRecord {
355 status: ReconfigurationStatus::Finalized,
356 ..record.clone()
357 }),
358 // A cut-over that only happens because the deadline passed
359 // under `Commit` is forced: the target has not hydrated.
360 // Declared here because only this decision point knows.
361 // The durable status reads `Finalized` either way.
362 audit: Some(ReconfigurationAudit::Finalized { forced: !hydrated }),
363 }),
364 ..Default::default()
365 };
366 }
367
368 // Past the deadline un-hydrated under `Rollback`: abandon the
369 // reconfiguration while leaving the realized config untouched. The
370 // terminal status is the durable transition the audit event records. With
371 // the record no longer in progress the strategy stops contributing the
372 // target set, so the baseline alone shapes the cluster.
373 if deadline_reached && matches!(record.on_timeout, OnTimeout::Rollback) {
374 return StateWrite {
375 reconfiguration: Some(ReconfigurationWrite {
376 record: Some(ReconfigurationRecord {
377 status: ReconfigurationStatus::TimedOut,
378 ..record.clone()
379 }),
380 audit: Some(ReconfigurationAudit::TimedOut),
381 }),
382 ..Default::default()
383 };
384 }
385
386 // Before the deadline: keep waiting.
387 StateWrite::default()
388 }
389
390 fn desired_replicas(
391 &self,
392 state: &ClusterState,
393 signals: &LiveSignals,
394 _config: &ConfigSignals,
395 now: Timestamp,
396 ) -> Vec<DesiredReplica> {
397 let Some(record) = &state.reconfiguration else {
398 return Vec::new();
399 };
400 if !record.is_in_progress() {
401 return Vec::new();
402 }
403
404 // Past the deadline with the target not hydrated under `Rollback`: stop
405 // contributing the target replicas. `update_state` marks the record
406 // timed out in this same tick's first phase, so this usually never fires
407 // against a re-read state. It matters when the deadline crosses between
408 // the two phases' `ctx.now()` reads within one tick: phase 1 saw the
409 // deadline unreached and wrote nothing, phase 2 sees it reached here and
410 // already stops desiring the target, keeping the rollback's replica
411 // drops prompt rather than waiting a tick for the status write.
412 // Everything else (before the deadline, awaiting a success cut-over
413 // past it, or a `Commit` cut-over `update_state` performs this tick)
414 // keeps desiring the target set.
415 // `now >= deadline` matches `update_state`'s boundary, so a zero-timeout
416 // rollback stops desiring the target on the same tick it marks the
417 // record timed out.
418 let timed_out = now >= record.deadline && !self.target_hydrated(state, signals, record);
419 if timed_out && matches!(record.on_timeout, OnTimeout::Rollback) {
420 return Vec::new();
421 }
422
423 let shape = record.target.shape();
424 (0..record.target.replication_factor)
425 .map(|_| DesiredReplica {
426 shape: shape.clone(),
427 reason: CreateReason::GracefulReconfiguration,
428 })
429 .collect()
430 }
431}
432
433/// The `ON REFRESH` scheduling strategy.
434///
435/// Engaged for clusters with a non-MANUAL [`ClusterSchedule`]. It contributes one
436/// replica at the cluster's realized shape while the cluster is inside a refresh
437/// window, and nothing otherwise. The window decision keys on the bound REFRESH
438/// materialized views' write frontiers, their refresh schedules, the configured
439/// hydration-time estimate, and the current read timestamp, all carried in
440/// [`RefreshWindowInputs`].
441///
442/// The controller (not the user's `replication_factor`) owns a scheduled
443/// cluster's replica set, so [`Strategy::update_state`] normalizes the realized
444/// `replication_factor` to `0`. This is self-healing (no migration needed to
445/// enable the controller) and makes `mz_clusters.replication_factor` read `0` for
446/// a scheduled cluster, with `mz_cluster_replicas` authoritative for what is
447/// actually running.
448///
449/// NB: the decision is re-derived purely from the live signals each tick, with
450/// no cross-tick latch. We pull a complete decision from durable and storage
451/// state on every tick, so the first tick after a restart already decides from
452/// the same inputs as a steady tick.
453#[derive(Clone, Copy, Debug, Default)]
454pub struct OnRefreshStrategy;
455
456impl OnRefreshStrategy {
457 /// The window decision for the cluster: which bound REFRESH MVs either still
458 /// need a refresh (their write frontier has not advanced past the read
459 /// timestamp adjusted by the hydration-time estimate) or are estimated to
460 /// still need Persist compaction after their last refresh. The cluster
461 /// should be On iff either list is non-empty
462 /// ([`RefreshWindowDecision::window_open`]), so an open window always names
463 /// the MVs that explain it.
464 ///
465 /// `hydration_time_estimate` comes from the schedule; the remaining signals
466 /// come from `inputs`. With no bound REFRESH MVs both lists are empty and
467 /// the cluster is Off.
468 fn window_decision(
469 &self,
470 hydration_time_estimate: std::time::Duration,
471 inputs: &RefreshWindowInputs,
472 ) -> RefreshWindowDecision {
473 // 1. Needs refresh: write_frontier < read_ts + hydration_time_estimate.
474 // The cluster is turned on `hydration_time_estimate` ahead of a refresh
475 // so it can rehydrate before the refresh time.
476 let read_ts_adjusted = inputs
477 .read_ts
478 .step_forward_by(&duration_to_ts(hydration_time_estimate));
479 let objects_needing_refresh = inputs
480 .refresh_mvs
481 .iter()
482 .filter(|mv| mv.write_frontier.less_than(&read_ts_adjusted))
483 .map(|mv| mv.id)
484 .collect();
485
486 // 2. Needs compaction: prev_refresh + compaction_estimate > read_ts. We
487 // keep the cluster on for a while after a refresh so Persist can compact.
488 let compaction_estimate = duration_to_ts(inputs.compaction_estimate);
489 let objects_needing_compaction = inputs
490 .refresh_mvs
491 .iter()
492 .filter(|mv| {
493 // `prev_refresh` is None in two cases, both meaning "schedule no
494 // compaction time now": no refresh has happened yet (no frontier to
495 // round down and no past `AT`), or a `REFRESH EVERY` MV with an empty
496 // write frontier (we have no wall-clock handle on its last refresh).
497 let prev_refresh = match mv.write_frontier.as_option() {
498 Some(frontier) => frontier.round_down_minus_1(&mv.refresh_schedule),
499 None => mv.refresh_schedule.last_refresh(),
500 };
501 prev_refresh.is_some_and(|prev| {
502 // An estimate that overflows the timestamp space means
503 // `prev + estimate` exceeds every possible read ts, so the
504 // window reads as open.
505 match prev.try_step_forward_by(&compaction_estimate) {
506 Some(compacting_until) => compacting_until > inputs.read_ts,
507 None => true,
508 }
509 })
510 })
511 .map(|mv| mv.id)
512 .collect();
513
514 RefreshWindowDecision {
515 objects_needing_refresh,
516 objects_needing_compaction,
517 hydration_time_estimate,
518 }
519 }
520}
521
522impl Strategy for OnRefreshStrategy {
523 fn signal_request(&self, state: &ClusterState, _config: &ConfigSignals) -> SignalRequest {
524 SignalRequest {
525 refresh_window: !matches!(state.schedule, ClusterSchedule::Manual),
526 ..Default::default()
527 }
528 }
529
530 fn update_state(
531 &self,
532 state: &ClusterState,
533 _signals: &LiveSignals,
534 _config: &ConfigSignals,
535 _now: Timestamp,
536 ) -> StateWrite {
537 // The controller owns a scheduled cluster's replica set, so hold the
538 // realized `replication_factor` at `0`. A stale non-zero value (e.g.
539 // carried over from a cluster that was just given a schedule) would
540 // otherwise have the implicit baseline desire a replica the on-refresh
541 // strategy does not, a flap.
542 // Only write when it is actually non-zero, to keep steady ticks no-ops.
543 if matches!(state.schedule, ClusterSchedule::Manual) || state.replication_factor == 0 {
544 return StateWrite::default();
545 }
546 // While a reconfiguration record is in progress, the graceful strategy
547 // owns `new_replication_factor` (its cut-over sets it from the record's
548 // target), so skip the normalization to keep the field single-writer
549 // within a tick. The sequencer never writes a record for a scheduled
550 // cluster, so this state is reachable only for a record written before
551 // the cluster acquired its schedule (pre-upgrade catalog state). A
552 // cut-over there can briefly set a non-zero rf on the scheduled
553 // cluster. The next tick sees the record settled and normalizes it.
554 if state
555 .reconfiguration
556 .as_ref()
557 .is_some_and(|record| record.is_in_progress())
558 {
559 return StateWrite::default();
560 }
561 StateWrite {
562 new_replication_factor: Some(0),
563 ..Default::default()
564 }
565 }
566
567 fn desired_replicas(
568 &self,
569 state: &ClusterState,
570 signals: &LiveSignals,
571 _config: &ConfigSignals,
572 _now: Timestamp,
573 ) -> Vec<DesiredReplica> {
574 let ClusterSchedule::Refresh {
575 hydration_time_estimate,
576 } = state.schedule
577 else {
578 return Vec::new();
579 };
580 // The refresh-window signals are pulled for every scheduled cluster.
581 // The ctx returns `None` only when the cluster was gone, unmanaged, or
582 // no longer scheduled at pull time (a concurrent DDL moved it under the
583 // tick), so contributing nothing is the correct answer. The schedule is
584 // part of the compare-and-append witness, so a stale in-flight decision
585 // derived before such a change is rejected at apply anyway.
586 let Some(inputs) = &signals.refresh_window else {
587 return Vec::new();
588 };
589 let decision = self.window_decision(hydration_time_estimate, inputs);
590 if !decision.window_open() {
591 return Vec::new();
592 }
593 // One replica at the realized shape (`cluster.size` plus the cluster's AZ
594 // pool, logging, and arrangement compression). The window decision rides
595 // inside the reason so the create it may produce can carry the audit
596 // detail.
597 vec![DesiredReplica {
598 shape: state.realized_shape(),
599 reason: CreateReason::OnRefresh(decision),
600 }]
601 }
602}
603
604/// A millisecond [`Duration`] as a [`Timestamp`], saturating at [`Timestamp::MAX`]
605/// on overflow rather than panicking the controller on a bad input.
606///
607/// [`Duration`]: std::time::Duration
608fn duration_to_ts(duration: std::time::Duration) -> Timestamp {
609 Timestamp::try_from(duration).unwrap_or(Timestamp::MAX)
610}
611
612/// The hydration-burst strategy.
613///
614/// Engaged for clusters whose `AUTO SCALING STRATEGY` sets `ON HYDRATION`. While
615/// the cluster is On and there exists an object on it that no steady-state
616/// (realized-config) replica has hydrated, it runs one extra replica at the
617/// configured `HYDRATION SIZE` to accelerate hydration; the burst replica tears
618/// down a `linger_duration` after the steady set first hydrates. Zero objects
619/// make the condition vacuously unsatisfied, so a brand-new cluster never bursts
620/// before its first object lands. The burst is keyed entirely on the presence of a
621/// durable `burst` record (written/cleared by [`Strategy::update_state`]); the
622/// burst replica is an ordinary replica. The union/diff reconciler creates and
623/// drops it by shape+count with no special identity.
624///
625/// There is deliberately no TTL on the burst replica: if the steady set can never
626/// hydrate at `cluster.size`, the burst stays up indefinitely (the cluster runs
627/// permanently oversized, visible in billing and the audit log), the accepted
628/// trade for keeping the cluster serving. Burst is **not** suppressed during a
629/// reconfiguration; the two coexist.
630///
631/// Steady-replica hydration and object existence are live signals requested via
632/// [`Strategy::signal_request`] while an `ON HYDRATION` policy is active.
633#[derive(Clone, Copy, Debug, Default)]
634pub struct HydrationBurstStrategy;
635
636impl HydrationBurstStrategy {
637 /// The cluster's active `ON HYDRATION` policy, but only when burst is permitted
638 /// at all: the break-glass flag is on and the cluster is On (`rf > 0`). `None`
639 /// otherwise. No burst is warranted and any existing record is torn down.
640 fn active_policy<'a>(
641 &self,
642 state: &'a ClusterState,
643 config: &ConfigSignals,
644 ) -> Option<&'a crate::ctx::OnHydrationPolicy> {
645 if !config.burst_enabled || state.replication_factor == 0 {
646 return None;
647 }
648 state.auto_scaling_policy.as_ref()?.on_hydration.as_ref()
649 }
650
651 /// The in-flight burst record, but only while the current config still
652 /// warrants it: the policy is active ([`Self::active_policy`]) and the
653 /// record's size matches the policy's `HYDRATION SIZE`. `None` for a stale
654 /// record, which `update_state` tears down.
655 fn warranted_record<'a>(
656 &self,
657 state: &'a ClusterState,
658 config: &ConfigSignals,
659 ) -> Option<&'a BurstRecord> {
660 let record = state.burst.as_ref()?;
661 // `active_policy` already folds in `replication_factor != 0`, so the
662 // shared predicate's own check is redundant here, but passing the real
663 // value keeps this a faithful call of the one warrant definition.
664 let hydration_size = self
665 .active_policy(state, config)
666 .map(|policy| policy.hydration_size.as_str());
667 mz_adapter_types::cluster_state::burst_record_warranted(
668 &record.burst_size,
669 state.replication_factor,
670 hydration_size,
671 )
672 .then_some(record)
673 }
674
675 /// Whether at least one steady-state (realized-config) replica reports all
676 /// current objects hydrated. `false` when no steady replica reports at all
677 /// (absent, or not yet registered with the compute controller).
678 fn steady_hydrated(&self, state: &ClusterState, signals: &LiveSignals) -> bool {
679 let steady_shape = state.realized_shape();
680 state
681 .replicas
682 .iter()
683 .filter(|r| r.owned_shape().is_some_and(|s| s.matches(&steady_shape)))
684 .any(|r| signals.hydrated_replicas.contains(&r.replica_id))
685 }
686}
687
688impl Strategy for HydrationBurstStrategy {
689 fn signal_request(&self, state: &ClusterState, config: &ConfigSignals) -> SignalRequest {
690 // Hydration drives both the arm check and the linger lifecycle. Object
691 // existence only gates arming, so it is requested only record-less.
692 let active = self.active_policy(state, config).is_some();
693 SignalRequest {
694 hydration: active,
695 hydratable_objects: active && state.burst.is_none(),
696 ..Default::default()
697 }
698 }
699
700 fn update_state(
701 &self,
702 state: &ClusterState,
703 signals: &LiveSignals,
704 config: &ConfigSignals,
705 now: Timestamp,
706 ) -> StateWrite {
707 // Both teardown arms clear the record, but they declare different
708 // causes: only this decision point knows whether the burst ran its
709 // course or was cut short by a config change.
710 let clear = |cause: BurstFinishCause| StateWrite {
711 burst: Some(BurstWrite {
712 record: None,
713 audit: Some(BurstAudit::Finished { cause }),
714 }),
715 ..Default::default()
716 };
717
718 // Cleanup precedence: a burst no longer warranted tears down regardless
719 // of linger. Catalog writes retire records they invalidate themselves,
720 // so this arm mainly covers the burst dyncfg switching off, and
721 // backstops any stale record that reaches us anyway.
722 if state.burst.is_some() && self.warranted_record(state, config).is_none() {
723 return clear(BurstFinishCause::NoLongerWarranted);
724 }
725 let Some(policy) = self.active_policy(state, config) else {
726 // No record (the cleanup above handled that) and no active policy:
727 // nothing to arm.
728 return StateWrite::default();
729 };
730
731 let steady_hydrated = self.steady_hydrated(state, signals);
732
733 match &state.burst {
734 // No record: arm a burst only while some object exists that the
735 // steady set has not hydrated. Without the object gate, a brand-new
736 // cluster would burst at creation with nothing to accelerate (an
737 // absent steady replica reads as un-hydrated). The record-present
738 // arms below do not consult the gate: if all objects are dropped
739 // mid-burst, the steady set reads hydrated and the linger clears
740 // the record.
741 None => {
742 if steady_hydrated || !signals.has_hydratable_objects {
743 StateWrite::default()
744 } else {
745 let linger_duration = policy
746 .linger_duration
747 .unwrap_or(config.default_burst_linger);
748 StateWrite {
749 burst: Some(BurstWrite {
750 record: Some(BurstRecord {
751 burst_size: policy.hydration_size.clone(),
752 linger_duration,
753 steady_hydrated_at: None,
754 }),
755 audit: Some(BurstAudit::Started),
756 }),
757 ..Default::default()
758 }
759 }
760 }
761 // Record present: drive the linger/teardown/re-arm lifecycle.
762 Some(record) => {
763 match (record.steady_hydrated_at, steady_hydrated) {
764 // Steady set hydrated and the linger has elapsed: tear down.
765 // A linger that overflows the timestamp space reads as
766 // never-elapsed.
767 (Some(hydrated_at), true)
768 if now
769 > hydrated_at
770 .try_step_forward_by(&duration_to_ts(record.linger_duration))
771 .unwrap_or(Timestamp::MAX) =>
772 {
773 clear(BurstFinishCause::LingerElapsed)
774 }
775 // Steady set hydrated, linger not yet elapsed: hold.
776 (Some(_), true) => StateWrite::default(),
777 // First observation of the steady set hydrated: stamp the
778 // linger start. A bookkeeping rewrite, not a lifecycle
779 // transition, so it declares no audit.
780 (None, true) => StateWrite {
781 burst: Some(BurstWrite {
782 record: Some(BurstRecord {
783 steady_hydrated_at: Some(now),
784 ..record.clone()
785 }),
786 audit: None,
787 }),
788 ..Default::default()
789 },
790 // The steady set went un-hydrated again after we had stamped a
791 // hydration time: re-arm so the linger restarts after the next
792 // successful hydration. Also bookkeeping: the burst replica
793 // keeps running throughout, so no lifecycle event.
794 (Some(_), false) => StateWrite {
795 burst: Some(BurstWrite {
796 record: Some(BurstRecord {
797 steady_hydrated_at: None,
798 ..record.clone()
799 }),
800 audit: None,
801 }),
802 ..Default::default()
803 },
804 // Steady set still un-hydrated and never stamped: keep waiting.
805 (None, false) => StateWrite::default(),
806 }
807 }
808 }
809 }
810
811 fn desired_replicas(
812 &self,
813 state: &ClusterState,
814 _signals: &LiveSignals,
815 _config: &ConfigSignals,
816 _now: Timestamp,
817 ) -> Vec<DesiredReplica> {
818 // A present record is never stale: catalog writes retire records they
819 // invalidate in the same transaction, and a dyncfg switch-off is
820 // handled by phase 1's cleanup (config signals are latched per tick).
821 // One replica at the burst size (only the size differs from steady).
822 let Some(record) = &state.burst else {
823 return Vec::new();
824 };
825 vec![DesiredReplica {
826 shape: ReplicaShape {
827 size: record.burst_size.clone(),
828 availability_zones: AvailabilityZones(state.availability_zones.clone()),
829 logging: state.logging.clone(),
830 arrangement_compression: state.arrangement_compression,
831 },
832 reason: CreateReason::HydrationBurst,
833 }]
834 }
835}