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