Skip to main content

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 and logging). It holds the steady-state set
171/// so that the policy strategies can be purely additive. They only ever add to
172/// the baseline. With only the baseline engaged, the desired set equals the
173/// realized set, 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#[derive(Clone, Copy, Debug, Default)]
182pub struct BaselineStrategy;
183
184impl Strategy for BaselineStrategy {
185    fn desired_replicas(
186        &self,
187        state: &ClusterState,
188        _signals: &LiveSignals,
189        _config: &ConfigSignals,
190        _now: Timestamp,
191    ) -> Vec<DesiredReplica> {
192        if !matches!(state.schedule, ClusterSchedule::Manual) {
193            return Vec::new();
194        }
195        let shape = state.realized_shape();
196        (0..state.replication_factor)
197            .map(|_| DesiredReplica {
198                shape: shape.clone(),
199                reason: CreateReason::Baseline,
200            })
201            .collect()
202    }
203}
204
205/// The graceful (zero-downtime) reconfiguration strategy.
206///
207/// Engaged whenever the durable `reconfiguration` record is in progress. It
208/// desires `target.replication_factor` replicas at the target shape in addition
209/// to the baseline's realized-shape replicas, so both sets serve while the new
210/// one hydrates. Once rf-many target replicas are present and hydrated,
211/// `update_state` cuts over: the realized config advances to the target, the
212/// record is marked finalized, and the old replicas fall out of the union and
213/// are dropped. Success takes precedence over the deadline. On a timeout,
214/// `Commit` cuts over to the un-hydrated target anyway while `Rollback` (the
215/// default) marks the record timed out without touching the realized config and
216/// stops desiring the target replicas, reverting to the pre-reconfiguration set.
217///
218/// Both functions are pure over the observed [`ClusterState`] and the fetched
219/// [`LiveSignals`]. Hydration is requested via [`Strategy::signal_request`]
220/// exactly while an in-progress reconfiguration is present.
221#[derive(Clone, Copy, Debug, Default)]
222pub struct GracefulReconfigurationStrategy;
223
224impl GracefulReconfigurationStrategy {
225    /// Whether the cut-over precondition holds: at least
226    /// `target.replication_factor` replicas of the target shape report
227    /// hydrated.
228    ///
229    /// Requiring rf-many hydrated replicas (not just one) preserves the
230    /// high-availability guarantee of `replication_factor > 1` across the
231    /// cut-over. Extra target-shape replicas beyond the rf do not block: the
232    /// post-cut-over reconcile retires them anyway, so waiting for them to
233    /// hydrate would only delay the cut-over.
234    fn target_hydrated(
235        &self,
236        state: &ClusterState,
237        signals: &LiveSignals,
238        record: &ReconfigurationRecord,
239    ) -> bool {
240        let target_shape = record.target.shape();
241        let hydrated_target_replicas = state
242            .replicas
243            .iter()
244            .filter(|r| r.owned_shape().is_some_and(|s| s.matches(&target_shape)))
245            .filter(|r| signals.hydrated_replicas.contains(&r.replica_id))
246            .count();
247        let target_rf = usize::try_from(record.target.replication_factor).unwrap_or(usize::MAX);
248        hydrated_target_replicas >= target_rf
249    }
250}
251
252impl Strategy for GracefulReconfigurationStrategy {
253    fn signal_request(&self, state: &ClusterState, _config: &ConfigSignals) -> SignalRequest {
254        SignalRequest {
255            hydration: state
256                .reconfiguration
257                .as_ref()
258                .is_some_and(|record| record.is_in_progress()),
259            ..Default::default()
260        }
261    }
262
263    fn update_state(
264        &self,
265        state: &ClusterState,
266        signals: &LiveSignals,
267        _config: &ConfigSignals,
268        now: Timestamp,
269    ) -> StateWrite {
270        let Some(record) = &state.reconfiguration else {
271            return StateWrite::default();
272        };
273        if !record.is_in_progress() {
274            return StateWrite::default();
275        }
276
277        // Cut over by advancing the realized config to the target and marking
278        // the record finalized on either of two conditions:
279        //   1. rf-many target replicas are present and hydrated (success, which
280        //      takes precedence over the deadline regardless of `on_timeout`), or
281        //   2. the deadline has been reached un-hydrated and `on_timeout` is
282        //      `Commit` (cut over to the not-yet-hydrated target anyway).
283        //
284        // NOTE: the deadline is reached at `now >= deadline`, not `now > deadline`.
285        // A `WAIT FOR '0s'` writes `deadline = now` to request an immediate
286        // cut-over. With a strict `>`, a first tick landing at exactly that
287        // timestamp would miss the deadline, so phase 2 would provision the overlap
288        // target replicas and only a later tick would cut over. `>=` fires the
289        // deadline the instant it is reached, so the zero-timeout cut-over happens
290        // on the first tick, before any overlap replica is desired.
291        let hydrated = self.target_hydrated(state, signals, record);
292        let deadline_reached = now >= record.deadline;
293        let commit_on_timeout = deadline_reached && matches!(record.on_timeout, OnTimeout::Commit);
294        if hydrated || commit_on_timeout {
295            return StateWrite {
296                new_size: Some(record.target.size.clone()),
297                new_replication_factor: Some(record.target.replication_factor),
298                new_availability_zones: Some(record.target.availability_zones.0.clone()),
299                new_logging: Some(record.target.logging.clone()),
300                new_arrangement_compression: Some(record.target.arrangement_compression),
301                reconfiguration: Some(ReconfigurationWrite {
302                    record: Some(ReconfigurationRecord {
303                        status: ReconfigurationStatus::Finalized,
304                        ..record.clone()
305                    }),
306                    // A cut-over that only happens because the deadline passed
307                    // under `Commit` is forced: the target has not hydrated.
308                    // Declared here because only this decision point knows.
309                    // The durable status reads `Finalized` either way.
310                    audit: Some(ReconfigurationAudit::Finalized { forced: !hydrated }),
311                }),
312                ..Default::default()
313            };
314        }
315
316        // Past the deadline un-hydrated under `Rollback`: abandon the
317        // reconfiguration while leaving the realized config untouched. The
318        // terminal status is the durable transition the audit event records. With
319        // the record no longer in progress the strategy stops contributing the
320        // target set, so the baseline alone shapes the cluster.
321        if deadline_reached && matches!(record.on_timeout, OnTimeout::Rollback) {
322            return StateWrite {
323                reconfiguration: Some(ReconfigurationWrite {
324                    record: Some(ReconfigurationRecord {
325                        status: ReconfigurationStatus::TimedOut,
326                        ..record.clone()
327                    }),
328                    audit: Some(ReconfigurationAudit::TimedOut),
329                }),
330                ..Default::default()
331            };
332        }
333
334        // Before the deadline: keep waiting.
335        StateWrite::default()
336    }
337
338    fn desired_replicas(
339        &self,
340        state: &ClusterState,
341        signals: &LiveSignals,
342        _config: &ConfigSignals,
343        now: Timestamp,
344    ) -> Vec<DesiredReplica> {
345        let Some(record) = &state.reconfiguration else {
346            return Vec::new();
347        };
348        if !record.is_in_progress() {
349            return Vec::new();
350        }
351
352        // Past the deadline with the target not hydrated under `Rollback`: stop
353        // contributing the target replicas. `update_state` marks the record
354        // timed out in this same tick's first phase, so this usually never fires
355        // against a re-read state. It matters when the deadline crosses between
356        // the two phases' `ctx.now()` reads within one tick: phase 1 saw the
357        // deadline unreached and wrote nothing, phase 2 sees it reached here and
358        // already stops desiring the target, keeping the rollback's replica
359        // drops prompt rather than waiting a tick for the status write.
360        // Everything else (before the deadline, awaiting a success cut-over
361        // past it, or a `Commit` cut-over `update_state` performs this tick)
362        // keeps desiring the target set.
363        // `now >= deadline` matches `update_state`'s boundary, so a zero-timeout
364        // rollback stops desiring the target on the same tick it marks the
365        // record timed out.
366        let timed_out = now >= record.deadline && !self.target_hydrated(state, signals, record);
367        if timed_out && matches!(record.on_timeout, OnTimeout::Rollback) {
368            return Vec::new();
369        }
370
371        let shape = record.target.shape();
372        (0..record.target.replication_factor)
373            .map(|_| DesiredReplica {
374                shape: shape.clone(),
375                reason: CreateReason::GracefulReconfiguration,
376            })
377            .collect()
378    }
379}
380
381/// The `ON REFRESH` scheduling strategy.
382///
383/// Engaged for clusters with a non-MANUAL [`ClusterSchedule`]. It contributes one
384/// replica at the cluster's realized shape while the cluster is inside a refresh
385/// window, and nothing otherwise. The window decision keys on the bound REFRESH
386/// materialized views' write frontiers, their refresh schedules, the configured
387/// hydration-time estimate, and the current read timestamp, all carried in
388/// [`RefreshWindowInputs`].
389///
390/// The controller (not the user's `replication_factor`) owns a scheduled
391/// cluster's replica set, so [`Strategy::update_state`] normalizes the realized
392/// `replication_factor` to `0`. This is self-healing (no migration needed to
393/// enable the controller) and makes `mz_clusters.replication_factor` read `0` for
394/// a scheduled cluster, with `mz_cluster_replicas` authoritative for what is
395/// actually running.
396///
397/// NB: the decision is re-derived purely from the live signals each tick, with
398/// no cross-tick latch. We pull a complete decision from durable and storage
399/// state on every tick, so the first tick after a restart already decides from
400/// the same inputs as a steady tick.
401#[derive(Clone, Copy, Debug, Default)]
402pub struct OnRefreshStrategy;
403
404impl OnRefreshStrategy {
405    /// The window decision for the cluster: which bound REFRESH MVs either still
406    /// need a refresh (their write frontier has not advanced past the read
407    /// timestamp adjusted by the hydration-time estimate) or are estimated to
408    /// still need Persist compaction after their last refresh. The cluster
409    /// should be On iff either list is non-empty
410    /// ([`RefreshWindowDecision::window_open`]), so an open window always names
411    /// the MVs that explain it.
412    ///
413    /// `hydration_time_estimate` comes from the schedule; the remaining signals
414    /// come from `inputs`. With no bound REFRESH MVs both lists are empty and
415    /// the cluster is Off.
416    fn window_decision(
417        &self,
418        hydration_time_estimate: std::time::Duration,
419        inputs: &RefreshWindowInputs,
420    ) -> RefreshWindowDecision {
421        // 1. Needs refresh: write_frontier < read_ts + hydration_time_estimate.
422        // The cluster is turned on `hydration_time_estimate` ahead of a refresh
423        // so it can rehydrate before the refresh time.
424        let read_ts_adjusted = inputs
425            .read_ts
426            .step_forward_by(&duration_to_ts(hydration_time_estimate));
427        let objects_needing_refresh = inputs
428            .refresh_mvs
429            .iter()
430            .filter(|mv| mv.write_frontier.less_than(&read_ts_adjusted))
431            .map(|mv| mv.id)
432            .collect();
433
434        // 2. Needs compaction: prev_refresh + compaction_estimate > read_ts. We
435        // keep the cluster on for a while after a refresh so Persist can compact.
436        let compaction_estimate = duration_to_ts(inputs.compaction_estimate);
437        let objects_needing_compaction = inputs
438            .refresh_mvs
439            .iter()
440            .filter(|mv| {
441                // `prev_refresh` is None in two cases, both meaning "schedule no
442                // compaction time now": no refresh has happened yet (no frontier to
443                // round down and no past `AT`), or a `REFRESH EVERY` MV with an empty
444                // write frontier (we have no wall-clock handle on its last refresh).
445                let prev_refresh = match mv.write_frontier.as_option() {
446                    Some(frontier) => frontier.round_down_minus_1(&mv.refresh_schedule),
447                    None => mv.refresh_schedule.last_refresh(),
448                };
449                prev_refresh.is_some_and(|prev| {
450                    // An estimate that overflows the timestamp space means
451                    // `prev + estimate` exceeds every possible read ts, so the
452                    // window reads as open.
453                    match prev.try_step_forward_by(&compaction_estimate) {
454                        Some(compacting_until) => compacting_until > inputs.read_ts,
455                        None => true,
456                    }
457                })
458            })
459            .map(|mv| mv.id)
460            .collect();
461
462        RefreshWindowDecision {
463            objects_needing_refresh,
464            objects_needing_compaction,
465            hydration_time_estimate,
466        }
467    }
468}
469
470impl Strategy for OnRefreshStrategy {
471    fn signal_request(&self, state: &ClusterState, _config: &ConfigSignals) -> SignalRequest {
472        SignalRequest {
473            refresh_window: !matches!(state.schedule, ClusterSchedule::Manual),
474            ..Default::default()
475        }
476    }
477
478    fn update_state(
479        &self,
480        state: &ClusterState,
481        _signals: &LiveSignals,
482        _config: &ConfigSignals,
483        _now: Timestamp,
484    ) -> StateWrite {
485        // The controller owns a scheduled cluster's replica set, so hold the
486        // realized `replication_factor` at `0`. A stale non-zero value (e.g.
487        // carried over from a cluster that was just given a schedule) would
488        // otherwise have the implicit baseline desire a replica the on-refresh
489        // strategy does not, a flap.
490        // Only write when it is actually non-zero, to keep steady ticks no-ops.
491        if matches!(state.schedule, ClusterSchedule::Manual) || state.replication_factor == 0 {
492            return StateWrite::default();
493        }
494        // While a reconfiguration record is in progress, the graceful strategy
495        // owns `new_replication_factor` (its cut-over sets it from the record's
496        // target), so skip the normalization to keep the field single-writer
497        // within a tick. The sequencer never writes a record for a scheduled
498        // cluster, so this state is reachable only for a record written before
499        // the cluster acquired its schedule (pre-upgrade catalog state). A
500        // cut-over there can briefly set a non-zero rf on the scheduled
501        // cluster. The next tick sees the record settled and normalizes it.
502        if state
503            .reconfiguration
504            .as_ref()
505            .is_some_and(|record| record.is_in_progress())
506        {
507            return StateWrite::default();
508        }
509        StateWrite {
510            new_replication_factor: Some(0),
511            ..Default::default()
512        }
513    }
514
515    fn desired_replicas(
516        &self,
517        state: &ClusterState,
518        signals: &LiveSignals,
519        _config: &ConfigSignals,
520        _now: Timestamp,
521    ) -> Vec<DesiredReplica> {
522        let ClusterSchedule::Refresh {
523            hydration_time_estimate,
524        } = state.schedule
525        else {
526            return Vec::new();
527        };
528        // The refresh-window signals are pulled for every scheduled cluster.
529        // The ctx returns `None` only when the cluster was gone, unmanaged, or
530        // no longer scheduled at pull time (a concurrent DDL moved it under the
531        // tick), so contributing nothing is the correct answer. The schedule is
532        // part of the compare-and-append witness, so a stale in-flight decision
533        // derived before such a change is rejected at apply anyway.
534        let Some(inputs) = &signals.refresh_window else {
535            return Vec::new();
536        };
537        let decision = self.window_decision(hydration_time_estimate, inputs);
538        if !decision.window_open() {
539            return Vec::new();
540        }
541        // One replica at the realized shape (`cluster.size` plus the cluster's AZ
542        // pool and logging). The window decision rides inside the reason so the
543        // create it may produce can carry the audit detail.
544        vec![DesiredReplica {
545            shape: state.realized_shape(),
546            reason: CreateReason::OnRefresh(decision),
547        }]
548    }
549}
550
551/// A millisecond [`Duration`] as a [`Timestamp`], saturating at [`Timestamp::MAX`]
552/// on overflow rather than panicking the controller on a bad input.
553///
554/// [`Duration`]: std::time::Duration
555fn duration_to_ts(duration: std::time::Duration) -> Timestamp {
556    Timestamp::try_from(duration).unwrap_or(Timestamp::MAX)
557}
558
559/// The hydration-burst strategy.
560///
561/// Engaged for clusters whose `AUTO SCALING STRATEGY` sets `ON HYDRATION`. While
562/// the cluster is On and there exists an object on it that no steady-state
563/// (realized-config) replica has hydrated, it runs one extra replica at the
564/// configured `HYDRATION SIZE` to accelerate hydration; the burst replica tears
565/// down a `linger_duration` after the steady set first hydrates. Zero objects
566/// make the condition vacuously unsatisfied, so a brand-new cluster never bursts
567/// before its first object lands. The burst is keyed entirely on the presence of a
568/// durable `burst` record (written/cleared by [`Strategy::update_state`]); the
569/// burst replica is an ordinary replica. The union/diff reconciler creates and
570/// drops it by shape+count with no special identity.
571///
572/// There is deliberately no TTL on the burst replica: if the steady set can never
573/// hydrate at `cluster.size`, the burst stays up indefinitely (the cluster runs
574/// permanently oversized, visible in billing and the audit log), the accepted
575/// trade for keeping the cluster serving. Burst is **not** suppressed during a
576/// reconfiguration; the two coexist.
577///
578/// Steady-replica hydration and object existence are live signals requested via
579/// [`Strategy::signal_request`] while an `ON HYDRATION` policy is active.
580#[derive(Clone, Copy, Debug, Default)]
581pub struct HydrationBurstStrategy;
582
583impl HydrationBurstStrategy {
584    /// The cluster's active `ON HYDRATION` policy, but only when burst is permitted
585    /// at all: the break-glass flag is on and the cluster is On (`rf > 0`). `None`
586    /// otherwise. No burst is warranted and any existing record is torn down.
587    fn active_policy<'a>(
588        &self,
589        state: &'a ClusterState,
590        config: &ConfigSignals,
591    ) -> Option<&'a crate::ctx::OnHydrationPolicy> {
592        if !config.burst_enabled || state.replication_factor == 0 {
593            return None;
594        }
595        state.auto_scaling_policy.as_ref()?.on_hydration.as_ref()
596    }
597
598    /// The in-flight burst record, but only while the current config still
599    /// warrants it: the policy is active ([`Self::active_policy`]) and the
600    /// record's size matches the policy's `HYDRATION SIZE`. `None` for a stale
601    /// record, which `update_state` tears down.
602    fn warranted_record<'a>(
603        &self,
604        state: &'a ClusterState,
605        config: &ConfigSignals,
606    ) -> Option<&'a BurstRecord> {
607        let record = state.burst.as_ref()?;
608        // `active_policy` already folds in `replication_factor != 0`, so the
609        // shared predicate's own check is redundant here, but passing the real
610        // value keeps this a faithful call of the one warrant definition.
611        let hydration_size = self
612            .active_policy(state, config)
613            .map(|policy| policy.hydration_size.as_str());
614        mz_adapter_types::cluster_state::burst_record_warranted(
615            &record.burst_size,
616            state.replication_factor,
617            hydration_size,
618        )
619        .then_some(record)
620    }
621
622    /// Whether at least one steady-state (realized-config) replica reports all
623    /// current objects hydrated. `false` when no steady replica reports at all
624    /// (absent, or not yet registered with the compute controller).
625    fn steady_hydrated(&self, state: &ClusterState, signals: &LiveSignals) -> bool {
626        let steady_shape = state.realized_shape();
627        state
628            .replicas
629            .iter()
630            .filter(|r| r.owned_shape().is_some_and(|s| s.matches(&steady_shape)))
631            .any(|r| signals.hydrated_replicas.contains(&r.replica_id))
632    }
633}
634
635impl Strategy for HydrationBurstStrategy {
636    fn signal_request(&self, state: &ClusterState, config: &ConfigSignals) -> SignalRequest {
637        // Hydration drives both the arm check and the linger lifecycle. Object
638        // existence only gates arming, so it is requested only record-less.
639        let active = self.active_policy(state, config).is_some();
640        SignalRequest {
641            hydration: active,
642            hydratable_objects: active && state.burst.is_none(),
643            ..Default::default()
644        }
645    }
646
647    fn update_state(
648        &self,
649        state: &ClusterState,
650        signals: &LiveSignals,
651        config: &ConfigSignals,
652        now: Timestamp,
653    ) -> StateWrite {
654        // Both teardown arms clear the record, but they declare different
655        // causes: only this decision point knows whether the burst ran its
656        // course or was cut short by a config change.
657        let clear = |cause: BurstFinishCause| StateWrite {
658            burst: Some(BurstWrite {
659                record: None,
660                audit: Some(BurstAudit::Finished { cause }),
661            }),
662            ..Default::default()
663        };
664
665        // Cleanup precedence: a burst no longer warranted tears down regardless
666        // of linger. Catalog writes retire records they invalidate themselves,
667        // so this arm mainly covers the burst dyncfg switching off, and
668        // backstops any stale record that reaches us anyway.
669        if state.burst.is_some() && self.warranted_record(state, config).is_none() {
670            return clear(BurstFinishCause::NoLongerWarranted);
671        }
672        let Some(policy) = self.active_policy(state, config) else {
673            // No record (the cleanup above handled that) and no active policy:
674            // nothing to arm.
675            return StateWrite::default();
676        };
677
678        let steady_hydrated = self.steady_hydrated(state, signals);
679
680        match &state.burst {
681            // No record: arm a burst only while some object exists that the
682            // steady set has not hydrated. Without the object gate, a brand-new
683            // cluster would burst at creation with nothing to accelerate (an
684            // absent steady replica reads as un-hydrated). The record-present
685            // arms below do not consult the gate: if all objects are dropped
686            // mid-burst, the steady set reads hydrated and the linger clears
687            // the record.
688            None => {
689                if steady_hydrated || !signals.has_hydratable_objects {
690                    StateWrite::default()
691                } else {
692                    let linger_duration = policy
693                        .linger_duration
694                        .unwrap_or(config.default_burst_linger);
695                    StateWrite {
696                        burst: Some(BurstWrite {
697                            record: Some(BurstRecord {
698                                burst_size: policy.hydration_size.clone(),
699                                linger_duration,
700                                steady_hydrated_at: None,
701                            }),
702                            audit: Some(BurstAudit::Started),
703                        }),
704                        ..Default::default()
705                    }
706                }
707            }
708            // Record present: drive the linger/teardown/re-arm lifecycle.
709            Some(record) => {
710                match (record.steady_hydrated_at, steady_hydrated) {
711                    // Steady set hydrated and the linger has elapsed: tear down.
712                    // A linger that overflows the timestamp space reads as
713                    // never-elapsed.
714                    (Some(hydrated_at), true)
715                        if now
716                            > hydrated_at
717                                .try_step_forward_by(&duration_to_ts(record.linger_duration))
718                                .unwrap_or(Timestamp::MAX) =>
719                    {
720                        clear(BurstFinishCause::LingerElapsed)
721                    }
722                    // Steady set hydrated, linger not yet elapsed: hold.
723                    (Some(_), true) => StateWrite::default(),
724                    // First observation of the steady set hydrated: stamp the
725                    // linger start. A bookkeeping rewrite, not a lifecycle
726                    // transition, so it declares no audit.
727                    (None, true) => StateWrite {
728                        burst: Some(BurstWrite {
729                            record: Some(BurstRecord {
730                                steady_hydrated_at: Some(now),
731                                ..record.clone()
732                            }),
733                            audit: None,
734                        }),
735                        ..Default::default()
736                    },
737                    // The steady set went un-hydrated again after we had stamped a
738                    // hydration time: re-arm so the linger restarts after the next
739                    // successful hydration. Also bookkeeping: the burst replica
740                    // keeps running throughout, so no lifecycle event.
741                    (Some(_), false) => StateWrite {
742                        burst: Some(BurstWrite {
743                            record: Some(BurstRecord {
744                                steady_hydrated_at: None,
745                                ..record.clone()
746                            }),
747                            audit: None,
748                        }),
749                        ..Default::default()
750                    },
751                    // Steady set still un-hydrated and never stamped: keep waiting.
752                    (None, false) => StateWrite::default(),
753                }
754            }
755        }
756    }
757
758    fn desired_replicas(
759        &self,
760        state: &ClusterState,
761        _signals: &LiveSignals,
762        _config: &ConfigSignals,
763        _now: Timestamp,
764    ) -> Vec<DesiredReplica> {
765        // A present record is never stale: catalog writes retire records they
766        // invalidate in the same transaction, and a dyncfg switch-off is
767        // handled by phase 1's cleanup (config signals are latched per tick).
768        // One replica at the burst size (only the size differs from steady).
769        let Some(record) = &state.burst else {
770            return Vec::new();
771        };
772        vec![DesiredReplica {
773            shape: ReplicaShape {
774                size: record.burst_size.clone(),
775                availability_zones: AvailabilityZones(state.availability_zones.clone()),
776                logging: state.logging.clone(),
777                arrangement_compression: state.arrangement_compression,
778            },
779            reason: CreateReason::HydrationBurst,
780        }]
781    }
782}