Skip to main content

mz_adapter/coord/
caught_up.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//! Support for checking whether clusters/collections are caught up during a 0dt
11//! deployment.
12//!
13//! During a zero-downtime upgrade the new `environmentd` boots read-only and
14//! reports "ready to promote" once its clusters have caught up with the leader
15//! generation. [`Coordinator::maybe_check_caught_up`] runs that check on an
16//! interval (see `with_0dt_deployment_caught_up_check_interval`). We call one
17//! such run a "tick", and the term is used throughout this module.
18//!
19//! A point-in-time caught-up check is not enough on its own: a crash- or
20//! OOM-looping replica can momentarily look hydrated and caught-up, and cutting
21//! over right then drops us straight into a crashing replica. On top of the
22//! per-tick caught-up classification we therefore run a stability gate. Once a
23//! cluster is genuinely caught-up it must stay caught-up and have all replicas
24//! healthy for a configurable period before we report it ready. Any disruption
25//! (a replica not `Online`, a status flap between ticks, or a replica restart)
26//! resets the streak, so a crash-looping replica never accumulates the required
27//! stable time. [`ClusterStabilityState`] holds the per-cluster gate state
28//! across ticks.
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::time::Duration;
32
33use chrono::{DateTime, Utc};
34use differential_dataflow::lattice::Lattice as _;
35use futures::StreamExt;
36use itertools::Itertools;
37use mz_adapter_types::dyncfgs::{
38    ENABLE_0DT_CAUGHT_UP_REPLICA_STATUS_CHECK, ENABLE_0DT_CAUGHT_UP_STABILITY_CHECK,
39    WITH_0DT_CAUGHT_UP_CHECK_ALLOWED_LAG, WITH_0DT_CAUGHT_UP_CHECK_CUTOFF,
40    WITH_0DT_CAUGHT_UP_CHECK_STABILITY_PERIOD,
41};
42use mz_catalog::builtin::{MZ_CLUSTER_REPLICA_FRONTIERS, MZ_CLUSTER_REPLICA_STATUS_HISTORY};
43use mz_catalog::memory::objects::Cluster;
44use mz_controller::clusters::{ClusterStatus, ProcessId};
45use mz_controller_types::{ClusterId, ReplicaId};
46use mz_orchestrator::OfflineReason;
47use mz_ore::channel::trigger::Trigger;
48use mz_ore::now::EpochMillis;
49use mz_repr::{GlobalId, Timestamp};
50use timely::PartialOrder;
51use timely::progress::{Antichain, Timestamp as _};
52
53use crate::coord::{ClusterReplicaStatuses, Coordinator};
54
55/// Context needed to check whether clusters/collections are caught up.
56#[derive(Debug)]
57pub struct CaughtUpCheckContext {
58    /// A trigger that signals that all clusters/collections have been caught
59    /// up.
60    pub trigger: Trigger,
61    /// Collections to exclude from the caught up check.
62    ///
63    /// When a caught up check is performed as part of a 0dt upgrade, it makes sense to exclude
64    /// collections of newly added builtin objects, as these might not hydrate in read-only mode.
65    pub exclude_collections: BTreeSet<GlobalId>,
66    /// Per-cluster state for the stability gate, retained across checks.
67    ///
68    /// Only genuinely caught-up clusters have an entry. Entries are dropped as
69    /// soon as a cluster stops being caught-up, so the streak restarts from
70    /// scratch when it becomes caught-up again.
71    pub cluster_stability: BTreeMap<ClusterId, ClusterStabilityState>,
72}
73
74/// How a cluster relates to the 0dt caught-up check on a given tick.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum ClusterCaughtUpStatus {
77    /// Genuinely hydrated and within lag. Subject to the stability gate.
78    CaughtUp,
79    /// Excluded by the existing checks (no replicas, or hopelessly behind with
80    /// only crash/OOM-looping replicas). Does not block readiness and is not
81    /// health-gated, so we keep ignoring clusters that are already unhealthy in
82    /// the leader environment.
83    Ignored,
84    /// Not yet caught up. Blocks readiness.
85    NotCaughtUp,
86}
87
88/// Per-cluster state for the stability gate, retained across caught-up checks.
89///
90/// The gate requires a cluster to stay caught-up and fully healthy for a
91/// configurable period before we report it ready. A point-in-time check isn't
92/// enough: a crash-looping replica can momentarily look hydrated and healthy, so
93/// we'd cut over right into a crash. We therefore track health over time here.
94#[derive(Debug, Default, Clone)]
95pub struct ClusterStabilityState {
96    /// Wall-clock time (environmentd's `now()`) at which the current
97    /// uninterrupted caught-up-and-healthy streak began. `None` while the
98    /// cluster isn't currently in such a streak.
99    ///
100    /// We anchor the window with environmentd's clock so the configured period
101    /// means real wall-clock seconds, independent of orchestrator event times.
102    stable_since: Option<EpochMillis>,
103    /// Max replica-process status-change time observed on the previous tick.
104    ///
105    /// Used to detect status transitions that happened and resolved between two
106    /// ticks (a fast flap we'd otherwise miss by only sampling the current
107    /// status). This is an orchestrator-supplied timestamp (`process.time`), not
108    /// a locally measured one, which is why it's a `DateTime` and not an
109    /// `Instant`. We only ever compare these orchestrator times against each
110    /// other, so orchestrator/environmentd clock skew doesn't matter.
111    last_status_change: Option<DateTime<Utc>>,
112    /// Restart count per replica process observed on the previous tick.
113    ///
114    /// Any difference from this tick resets the streak: an increased count means
115    /// a restart, a decreased one means the process was recreated, and an added
116    /// or removed key means replica/process churn. Restart counts survive gaps
117    /// in the orchestrator watch, so they catch restarts the status stream can
118    /// drop. We track them per process rather than as a cluster-wide sum so that
119    /// offsetting changes across processes can't cancel out and hide a restart.
120    last_restart_counts: Option<BTreeMap<(ReplicaId, ProcessId), u64>>,
121}
122
123/// A point-in-time view of a cluster's replica health, derived from the
124/// in-memory mirror of orchestrator-reported replica statuses.
125#[derive(Debug, Clone)]
126struct ClusterHealthSnapshot {
127    /// True iff the cluster has replicas and every process of every replica is
128    /// `Online`. We deliberately require all replicas to be healthy, so we only
129    /// cut over when the new environment is fully healthy.
130    all_healthy: bool,
131    /// Max status-change time across all of the cluster's replica processes.
132    max_status_change: Option<DateTime<Utc>>,
133    /// Restart count per replica process.
134    ///
135    /// Kept per process rather than summed: restart counts are not monotonic (a
136    /// recreated process resets to zero), so a cluster-wide sum could cancel
137    /// offsetting changes across processes and hide a restart. Comparing the
138    /// whole map between ticks also catches replica/process churn.
139    restart_counts: BTreeMap<(ReplicaId, ProcessId), u64>,
140}
141
142/// Why a caught-up cluster is being held back by the stability gate on a given
143/// tick.
144///
145/// Only ever set when the cluster is not yet ready, so there is no "stable"
146/// variant. Recorded so we can log the cause.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum StabilityBlocker {
149    /// Not all replicas are currently `Online`.
150    NotHealthy,
151    /// A status change happened and resolved between two ticks.
152    StatusFlapped,
153    /// A replica process restarted between two ticks.
154    Restarted,
155    /// Currently caught-up and healthy, but the streak hasn't reached the
156    /// required period yet.
157    WithinPeriod,
158}
159
160/// Outcome of folding one health snapshot into a [`ClusterStabilityState`].
161#[derive(Debug, Clone, Copy)]
162struct StabilityObservation {
163    /// Whether the cluster has now been continuously caught-up and healthy for
164    /// at least the required period.
165    ready: bool,
166    /// How long the current uninterrupted streak has lasted, in milliseconds.
167    /// `None` when the cluster is not currently in a streak (this tick reset it).
168    stable_for_ms: Option<u64>,
169    /// Why the cluster is being held back, for logging. `None` once it's ready.
170    blocked_by: Option<StabilityBlocker>,
171}
172
173impl ClusterStabilityState {
174    /// Folds in the latest health snapshot and returns an observation: whether
175    /// the cluster has now been continuously caught-up and healthy for at least
176    /// `period_ms`, how long the current streak has lasted, and (when not ready)
177    /// what is holding it back.
178    ///
179    /// A cluster is "good" on a tick only if all its replicas are currently
180    /// healthy and nothing changed since the previous tick (no status flap, no
181    /// restart). Any disruption resets the streak, so a crash-looping replica can
182    /// never accumulate the required stable time.
183    fn observe(
184        &mut self,
185        snapshot: &ClusterHealthSnapshot,
186        now: EpochMillis,
187        period_ms: u64,
188    ) -> StabilityObservation {
189        // NOTE: We don't assume orchestrator status events arrive in order or
190        // that every process of a cluster reports within the same tick. The
191        // snapshot reflects whatever the in-memory mirror holds right now, and
192        // the three checks below are deliberately redundant so no single one has
193        // to be reliable on its own:
194        //
195        //   - `all_healthy` is a point-in-time check, independent of ordering.
196        //   - a change in the per-process `restart_counts` is the durable signal:
197        //     k8s reports restart counts and they survive gaps in the orchestrator
198        //     watch, so they catch restarts the status stream drops. We compare
199        //     the whole map, never a cluster-wide sum: restart counts are not
200        //     monotonic (a recreated process resets to zero), so a sum could
201        //     cancel offsetting changes across processes and hide a restart.
202        //   - `max_status_change` advancing is a best-effort flap detector. A
203        //     cluster-wide max is enough here, unlike the restart counts, because
204        //     any status change stamps `process.time` at ~now, so a flap pushes
205        //     the max past the previous tick's value. It can still miss a flap if
206        //     an out-of-order event reports an older time, which is why the
207        //     restart counts are the belt-and-suspenders.
208        //
209        // We only compare the orchestrator-supplied times against each other, so
210        // clock skew between the orchestrator and environmentd doesn't matter.
211        let status_flapped = match (self.last_status_change, snapshot.max_status_change) {
212            (Some(prev), Some(cur)) => cur > prev,
213            _ => false,
214        };
215        let restarted = self
216            .last_restart_counts
217            .as_ref()
218            .is_some_and(|prev| prev != &snapshot.restart_counts);
219
220        let good = snapshot.all_healthy && !status_flapped && !restarted;
221
222        self.stable_since = if good {
223            self.stable_since.or(Some(now))
224        } else {
225            None
226        };
227        self.last_status_change = snapshot.max_status_change;
228        self.last_restart_counts = Some(snapshot.restart_counts.clone());
229
230        let stable_for_ms = self.stable_since.map(|since| now.saturating_sub(since));
231        let ready = stable_for_ms.is_some_and(|elapsed| elapsed >= period_ms);
232
233        let blocked_by = if ready {
234            None
235        } else if !snapshot.all_healthy {
236            Some(StabilityBlocker::NotHealthy)
237        } else if status_flapped {
238            Some(StabilityBlocker::StatusFlapped)
239        } else if restarted {
240            Some(StabilityBlocker::Restarted)
241        } else {
242            Some(StabilityBlocker::WithinPeriod)
243        };
244
245        StabilityObservation {
246            ready,
247            stable_for_ms,
248            blocked_by,
249        }
250    }
251}
252
253impl Coordinator {
254    /// Checks that all clusters/collections are caught up. If so, this will
255    /// trigger `self.caught_up_check.trigger`.
256    ///
257    /// This method is a no-op when the trigger has already been fired.
258    pub async fn maybe_check_caught_up(&mut self) {
259        if self.caught_up_check.is_none() {
260            return;
261        }
262
263        let replica_frontier_item_id = self
264            .catalog()
265            .resolve_builtin_storage_collection(&MZ_CLUSTER_REPLICA_FRONTIERS);
266        let replica_frontier_gid = self
267            .catalog()
268            .get_entry(&replica_frontier_item_id)
269            .latest_global_id();
270
271        // `snapshot_latest` requires that the collection consolidates to a
272        // set. `mz_cluster_replica_frontiers` is a controller-managed builtin
273        // written with ±1 diffs, so it satisfies that invariant.
274        //
275        // NOTE: these are the leader's frontiers only because we read the leader's shard.
276        // `validate_migration_steps` forbids migrating `mz_cluster_replica_frontiers` for this
277        // reason, so a declared migration can't reach here. A test forcing replacement across all
278        // builtins bypasses that guard, hands us a shard we write ourselves, and the lag check
279        // below then compares this deployment against itself.
280        let live_frontiers = self
281            .controller
282            .storage_collections
283            .snapshot_latest(replica_frontier_gid)
284            .await
285            .expect("can't read mz_cluster_replica_frontiers");
286
287        let live_frontiers = live_frontiers
288            .into_iter()
289            .map(|row| {
290                let mut iter = row.into_iter();
291
292                let id: GlobalId = iter
293                    .next()
294                    .expect("missing object id")
295                    .unwrap_str()
296                    .parse()
297                    .expect("cannot parse id");
298                let replica_id = iter
299                    .next()
300                    .expect("missing replica id")
301                    .unwrap_str()
302                    .to_string();
303                let maybe_upper_ts = iter.next().expect("missing upper_ts");
304                // The timestamp has a total order, so there can be at
305                // most one entry in the upper frontier, which is this
306                // timestamp here. And NULL encodes the empty upper
307                // frontier.
308                let upper_frontier = if maybe_upper_ts.is_null() {
309                    Antichain::new()
310                } else {
311                    let upper_ts = maybe_upper_ts.unwrap_mz_timestamp();
312                    Antichain::from_elem(upper_ts)
313                };
314
315                (id, replica_id, upper_frontier)
316            })
317            .collect_vec();
318
319        // We care about each collection being hydrated on _some_
320        // replica. We don't check that at least one replica has all
321        // collections of that cluster hydrated.
322        let live_collection_frontiers: BTreeMap<_, _> = live_frontiers
323            .into_iter()
324            .map(|(oid, _replica_id, upper_ts)| (oid, upper_ts))
325            .into_grouping_map()
326            .fold(
327                Antichain::from_elem(Timestamp::minimum()),
328                |mut acc, _key, upper| {
329                    acc.join_assign(&upper);
330                    acc
331                },
332            )
333            .into_iter()
334            .collect();
335
336        tracing::debug!(?live_collection_frontiers, "checking re-hydration status");
337
338        let allowed_lag =
339            WITH_0DT_CAUGHT_UP_CHECK_ALLOWED_LAG.get(self.catalog().system_config().dyncfgs());
340        let allowed_lag: u64 = allowed_lag
341            .as_millis()
342            .try_into()
343            .expect("must fit into u64");
344
345        let cutoff = WITH_0DT_CAUGHT_UP_CHECK_CUTOFF.get(self.catalog().system_config().dyncfgs());
346        let cutoff: u64 = cutoff.as_millis().try_into().expect("must fit into u64");
347
348        let now = self.now();
349
350        // Something might go wrong with querying the status collection, so we
351        // have an emergency flag for disabling it.
352        let replica_status_check_enabled =
353            ENABLE_0DT_CAUGHT_UP_REPLICA_STATUS_CHECK.get(self.catalog().system_config().dyncfgs());
354
355        // Analyze replica statuses to detect crash-looping or OOM-looping replicas
356        let problematic_replicas = if replica_status_check_enabled {
357            self.analyze_replica_looping(now).await
358        } else {
359            BTreeSet::new()
360        };
361
362        let stability_check_enabled =
363            ENABLE_0DT_CAUGHT_UP_STABILITY_CHECK.get(self.catalog().system_config().dyncfgs());
364        let stability_period =
365            WITH_0DT_CAUGHT_UP_CHECK_STABILITY_PERIOD.get(self.catalog().system_config().dyncfgs());
366        // Cap rather than panic on an absurdly large configured duration. A
367        // period of u64::MAX milliseconds means "effectively never auto-ready",
368        // which is the safe, conservative outcome: we won't cut over on our own,
369        // and an operator can still force it via skip-catchup.
370        let stability_period_ms = u64::try_from(stability_period.as_millis()).unwrap_or(u64::MAX);
371
372        // We clone the exclude set so we don't hold a borrow of `caught_up_check`
373        // across the classification, which lets us update the per-cluster
374        // stability state on it (mutably) afterwards.
375        let exclude_collections = self
376            .caught_up_check
377            .as_ref()
378            .expect("known to exist")
379            .exclude_collections
380            .clone();
381
382        let classification = self
383            .classify_clusters(
384                allowed_lag.into(),
385                cutoff.into(),
386                now.into(),
387                &live_collection_frontiers,
388                &exclude_collections,
389                &problematic_replicas,
390            )
391            .await;
392
393        // Read the health snapshots for genuinely caught-up clusters now, while we
394        // only hold a shared borrow of `self`. We update the stability state in a
395        // separate, mutable pass below.
396        let health: BTreeMap<ClusterId, ClusterHealthSnapshot> = classification
397            .iter()
398            .filter(|(_, status)| **status == ClusterCaughtUpStatus::CaughtUp)
399            .map(|(&cluster_id, _)| (cluster_id, self.cluster_health(cluster_id)))
400            .collect();
401
402        let ctx = self.caught_up_check.as_mut().expect("known to exist");
403
404        // Drop stability state for clusters that are no longer genuinely caught
405        // up, so the streak restarts from scratch when they become caught-up
406        // again.
407        ctx.cluster_stability.retain(|cluster_id, _| {
408            classification.get(cluster_id) == Some(&ClusterCaughtUpStatus::CaughtUp)
409        });
410
411        let mut all_ready = true;
412        for (&cluster_id, status) in &classification {
413            match status {
414                ClusterCaughtUpStatus::Ignored => {}
415                ClusterCaughtUpStatus::NotCaughtUp => {
416                    all_ready = false;
417                }
418                ClusterCaughtUpStatus::CaughtUp => {
419                    // Break-glass: when disabled, a caught-up cluster is
420                    // immediately ready, with no replica-health requirement,
421                    // i.e. the behavior from before this gate existed. We keep it
422                    // as a config-level, fleet-wide revert. Operators can already
423                    // force a single cutover via skip-catchup/promote, but this
424                    // flag restores prior auto-cutover behavior across all
425                    // environments without per-deploy manual intervention or a
426                    // code release, mirroring
427                    // `enable_0dt_caught_up_replica_status_check`.
428                    if !stability_check_enabled {
429                        continue;
430                    }
431                    let snapshot = health.get(&cluster_id).expect("computed above");
432                    let state = ctx.cluster_stability.entry(cluster_id).or_default();
433                    let observation = state.observe(snapshot, now, stability_period_ms);
434                    if !observation.ready {
435                        all_ready = false;
436                        tracing::info!(
437                            %cluster_id,
438                            reason = ?observation.blocked_by,
439                            all_healthy = snapshot.all_healthy,
440                            stable_for_ms = ?observation.stable_for_ms,
441                            required_period_ms = stability_period_ms,
442                            max_status_change = ?snapshot.max_status_change,
443                            // Summed only for a readable log line. The gate
444                            // compares the per-process map, not this total.
445                            restart_total = snapshot.restart_counts.values().sum::<u64>(),
446                            "cluster is caught up but not yet stable for the required period"
447                        );
448                    }
449                }
450            }
451        }
452
453        tracing::info!(%all_ready, "checked caught-up status of clusters");
454
455        if all_ready {
456            let ctx = self.caught_up_check.take().expect("known to exist");
457            ctx.trigger.fire();
458        }
459    }
460
461    /// Reads the current health of a cluster's replicas from the in-memory
462    /// mirror of orchestrator-reported statuses.
463    ///
464    /// A cluster with no replica status entries (e.g. a freshly created cluster
465    /// whose statuses haven't been initialized) is reported as not healthy.
466    fn cluster_health(&self, cluster_id: ClusterId) -> ClusterHealthSnapshot {
467        let Some(replicas) = self
468            .cluster_replica_statuses
469            .try_get_cluster_statuses(cluster_id)
470            .filter(|replicas| !replicas.is_empty())
471        else {
472            // A cluster with no replica statuses is treated as not healthy.
473            return ClusterHealthSnapshot {
474                all_healthy: false,
475                max_status_change: None,
476                restart_counts: BTreeMap::new(),
477            };
478        };
479
480        let mut all_healthy = true;
481        let mut max_status_change = None;
482        let mut restart_counts = BTreeMap::new();
483        for (replica_id, processes) in replicas {
484            if ClusterReplicaStatuses::cluster_replica_status(processes) != ClusterStatus::Online {
485                all_healthy = false;
486            }
487            for (process_id, process) in processes {
488                max_status_change = max_status_change.max(Some(process.time));
489                restart_counts.insert((*replica_id, *process_id), process.restart_count);
490            }
491        }
492
493        ClusterHealthSnapshot {
494            all_healthy,
495            max_status_change,
496            restart_counts,
497        }
498    }
499
500    /// Classifies every cluster for the caught-up check.
501    ///
502    /// Informally, a cluster is considered caught-up if it is at least as healthy as its
503    /// counterpart in the leader environment. To determine that, we use the following rules:
504    ///
505    ///  (1) A cluster is caught-up if all non-transient, non-excluded collections installed on it
506    ///      are either caught-up or ignored.
507    ///  (2) A collection is caught-up when it is (a) hydrated and (b) its write frontier is within
508    ///      `allowed_lag` of the "live" frontier, the collection's frontier reported by the leader
509    ///      environment.
510    ///  (3) A collection is ignored if its "live" frontier is behind `now` by more than `cutoff`.
511    ///      Such a collection is unhealthy in the leader environment, so we don't care about its
512    ///      health in the read-only environment either.
513    ///  (4) On a cluster that is crash-looping, all collections are ignored.
514    ///
515    /// A cluster that is caught-up only because it has no replicas, or because it is hopelessly
516    /// behind with only crash/OOM-looping replicas (rule 4), is reported as
517    /// [`ClusterCaughtUpStatus::Ignored`] rather than [`ClusterCaughtUpStatus::CaughtUp`]. The
518    /// caller does not health-gate ignored clusters, so we keep ignoring clusters that are already
519    /// unhealthy in the leader environment.
520    async fn classify_clusters(
521        &self,
522        allowed_lag: Timestamp,
523        cutoff: Timestamp,
524        now: Timestamp,
525        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
526        exclude_collections: &BTreeSet<GlobalId>,
527        problematic_replicas: &BTreeSet<ReplicaId>,
528    ) -> BTreeMap<ClusterId, ClusterCaughtUpStatus> {
529        let mut result = BTreeMap::new();
530        for cluster in self.catalog().clusters() {
531            let status = self
532                .collections_caught_up(
533                    cluster,
534                    allowed_lag.clone(),
535                    cutoff.clone(),
536                    now.clone(),
537                    live_frontiers,
538                    exclude_collections,
539                    problematic_replicas,
540                )
541                .await
542                .unwrap_or_else(|e| {
543                    tracing::error!(
544                        "unexpected error while checking if cluster {} caught up: {e:#}",
545                        cluster.id
546                    );
547                    ClusterCaughtUpStatus::NotCaughtUp
548                });
549
550            if status == ClusterCaughtUpStatus::NotCaughtUp {
551                // We log all non-caught-up clusters instead of breaking out early.
552                tracing::info!("cluster {} is not caught up", cluster.id);
553            }
554
555            result.insert(cluster.id, status);
556        }
557
558        result
559    }
560
561    /// Classifies the given cluster for the caught-up check.
562    ///
563    /// See [`Coordinator::classify_clusters`] for details.
564    async fn collections_caught_up(
565        &self,
566        cluster: &Cluster,
567        allowed_lag: Timestamp,
568        cutoff: Timestamp,
569        now: Timestamp,
570        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
571        exclude_collections: &BTreeSet<GlobalId>,
572        problematic_replicas: &BTreeSet<ReplicaId>,
573    ) -> Result<ClusterCaughtUpStatus, anyhow::Error> {
574        if cluster.replicas().next().is_none() {
575            return Ok(ClusterCaughtUpStatus::Ignored);
576        }
577
578        // Check if all replicas in this cluster are crash/OOM-looping. As long
579        // as there is at least one healthy replica, the cluster is okay-ish.
580        let cluster_has_only_problematic_replicas = cluster
581            .replicas()
582            .all(|replica| problematic_replicas.contains(&replica.replica_id));
583
584        enum CollectionType {
585            Storage,
586            Compute,
587        }
588
589        let mut all_caught_up = true;
590
591        let storage_frontiers = self
592            .controller
593            .storage
594            .active_ingestion_exports(cluster.id)
595            .copied()
596            .filter(|id| !id.is_transient() && !exclude_collections.contains(id))
597            .map(|id| {
598                let (_read_frontier, write_frontier) =
599                    self.controller.storage.collection_frontiers(id)?;
600                Ok::<_, anyhow::Error>((id, write_frontier, CollectionType::Storage))
601            });
602
603        let compute_frontiers = self
604            .controller
605            .compute
606            .collection_ids(cluster.id)?
607            .filter(|id| !id.is_transient() && !exclude_collections.contains(id))
608            .map(|id| {
609                let write_frontier = self
610                    .controller
611                    .compute
612                    .collection_frontiers(id, Some(cluster.id))?
613                    .write_frontier
614                    .to_owned();
615                Ok((id, write_frontier, CollectionType::Compute))
616            });
617
618        for res in itertools::chain(storage_frontiers, compute_frontiers) {
619            let (id, write_frontier, collection_type) = res?;
620            let live_write_frontier = match live_frontiers.get(&id) {
621                Some(frontier) => frontier,
622                None => {
623                    // No live frontier to compare against, either because the collection didn't
624                    // exist on the leader or because the leader hosts it as something
625                    // `mz_cluster_replica_frontiers` doesn't track. A table→MV conversion is the
626                    // latter: it keeps the table's `GlobalId`, still a table on the leader, so the
627                    // new MV lands here instead of the strong path below.
628                    //
629                    // Require hydration, not just a write frontier past the minimum. A fresh MV's
630                    // sink reaches frontier 1 after one batch, which would otherwise look caught
631                    // up mid-hydration and bring back the cut-over spike this gate prevents.
632                    let collection_hydrated = match collection_type {
633                        CollectionType::Compute => {
634                            self.controller
635                                .compute
636                                .collection_hydrated(cluster.id, id)
637                                .await?
638                        }
639                        CollectionType::Storage => {
640                            self.controller.storage.collection_hydrated(id)?
641                        }
642                    };
643
644                    // Also require the frontier to be within the allowed lag, the bound the
645                    // live-frontier path applies, with `now` standing in for the missing live
646                    // frontier. Hydration is one-shot: a collection that hydrated and then stalled
647                    // would otherwise satisfy this branch forever.
648                    //
649                    // NOTE: there is deliberately no `cutoff` escape hatch here. A frontier frozen
650                    // at the minimum is exactly what this gate must catch, so a collection stuck
651                    // here blocks promotion until `with_0dt_deployment_max_wait` elapses.
652                    let write_frontier_plus_allowed_lag = Antichain::from_iter(
653                        write_frontier
654                            .iter()
655                            .map(|t| t.step_forward_by(&allowed_lag)),
656                    );
657                    let within_lag = PartialOrder::less_equal(
658                        &Antichain::from_elem(now),
659                        &write_frontier_plus_allowed_lag,
660                    );
661
662                    tracing::info!(
663                        ?write_frontier,
664                        %collection_hydrated,
665                        %within_lag,
666                        ?allowed_lag,
667                        ?now,
668                        "collection {id} not in live frontiers"
669                    );
670                    if write_frontier.less_equal(&Timestamp::minimum())
671                        || !collection_hydrated
672                        || !within_lag
673                    {
674                        all_caught_up = false;
675                    }
676                    continue;
677                }
678            };
679
680            // We can't do comparisons and subtractions, so we bump up the live
681            // write frontier by the cutoff, and then compare that against
682            // `now`.
683            let live_write_frontier_plus_cutoff = live_write_frontier
684                .iter()
685                .map(|t| t.step_forward_by(&cutoff));
686            let live_write_frontier_plus_cutoff =
687                Antichain::from_iter(live_write_frontier_plus_cutoff);
688
689            let beyond_all_hope = live_write_frontier_plus_cutoff.less_equal(&now);
690
691            if beyond_all_hope && cluster_has_only_problematic_replicas {
692                tracing::info!(
693                    ?live_write_frontier,
694                    ?cutoff,
695                    ?now,
696                    "live write frontier of collection {id} is too far behind 'now'"
697                );
698                tracing::info!(
699                    "ALL replicas of cluster {} are crash/OOM-looping and it has at least one \
700                     collection that is too far behind 'now'; ignoring cluster for caught-up \
701                     checks",
702                    cluster.id
703                );
704                return Ok(ClusterCaughtUpStatus::Ignored);
705            } else if beyond_all_hope {
706                tracing::info!(
707                    ?live_write_frontier,
708                    ?cutoff,
709                    ?now,
710                    "live write frontier of collection {id} is too far behind 'now'; \
711                     ignoring for caught-up checks"
712                );
713                continue;
714            }
715
716            // We can't do easy comparisons and subtractions, so we bump up the
717            // write frontier by the allowed lag, and then compare that against
718            // the write frontier.
719            let write_frontier_plus_allowed_lag = write_frontier
720                .iter()
721                .map(|t| t.step_forward_by(&allowed_lag));
722            let bumped_write_plus_allowed_lag =
723                Antichain::from_iter(write_frontier_plus_allowed_lag);
724
725            let within_lag =
726                PartialOrder::less_equal(live_write_frontier, &bumped_write_plus_allowed_lag);
727
728            // This call is on the expensive side, because we have to do a call
729            // across a task/channel boundary, and our work competes with other
730            // things the compute/instance controller might be doing. But it's
731            // okay because we only do these hydration checks when in read-only
732            // mode, and only rarely.
733            let collection_hydrated = match collection_type {
734                CollectionType::Compute => {
735                    self.controller
736                        .compute
737                        .collection_hydrated(cluster.id, id)
738                        .await?
739                }
740                CollectionType::Storage => self.controller.storage.collection_hydrated(id)?,
741            };
742
743            // We don't expect collections to get hydrated, ingestions to be
744            // started, etc. when they are already at the empty write frontier.
745            if live_write_frontier.is_empty() || (within_lag && collection_hydrated) {
746                // This is a bit spammy, but log caught-up collections while we
747                // investigate why environments are cutting over but then a lot
748                // of compute collections are _not_ in fact hydrated on
749                // clusters.
750                tracing::info!(
751                    %id,
752                    %within_lag,
753                    %collection_hydrated,
754                    ?write_frontier,
755                    ?live_write_frontier,
756                    ?allowed_lag,
757                    %cluster.id,
758                    "collection is caught up");
759            } else {
760                // We are not within the allowed lag, or not hydrated!
761                //
762                // We continue with our loop instead of breaking out early, so
763                // that we log all non-caught-up replicas.
764                tracing::info!(
765                    %id,
766                    %within_lag,
767                    %collection_hydrated,
768                    ?write_frontier,
769                    ?live_write_frontier,
770                    ?allowed_lag,
771                    %cluster.id,
772                    "collection is not caught up"
773                );
774                all_caught_up = false;
775            }
776        }
777
778        Ok(if all_caught_up {
779            ClusterCaughtUpStatus::CaughtUp
780        } else {
781            ClusterCaughtUpStatus::NotCaughtUp
782        })
783    }
784
785    /// Analyzes replica status history to detect replicas that are
786    /// crash-looping or OOM-looping.
787    ///
788    /// A replica is considered problematic if it has multiple OOM kills in a
789    /// short-ish window.
790    async fn analyze_replica_looping(&self, now: EpochMillis) -> BTreeSet<ReplicaId> {
791        // Look back 1 day for patterns.
792        let lookback_window: u64 = Duration::from_secs(24 * 60 * 60)
793            .as_millis()
794            .try_into()
795            .expect("fits into u64");
796        let min_timestamp = now.saturating_sub(lookback_window);
797        let min_timestamp_dt = mz_ore::now::to_datetime(min_timestamp);
798
799        // Get the replica status collection GlobalId
800        let replica_status_item_id = self
801            .catalog()
802            .resolve_builtin_storage_collection(&MZ_CLUSTER_REPLICA_STATUS_HISTORY);
803        let replica_status_gid = self
804            .catalog()
805            .get_entry(&replica_status_item_id)
806            .latest_global_id();
807
808        // Acquire a read hold to determine the as_of timestamp for snapshot_and_stream
809        let read_holds = self
810            .controller
811            .storage_collections
812            .acquire_read_holds(vec![replica_status_gid])
813            .expect("can't acquire read hold for mz_cluster_replica_status_history");
814        let read_hold = if let Some(read_hold) = read_holds.into_iter().next() {
815            read_hold
816        } else {
817            // Collection is not readable anymore, but we return an empty set
818            // instead of panicing.
819            return BTreeSet::new();
820        };
821
822        let as_of = read_hold
823            .since()
824            .iter()
825            .next()
826            .cloned()
827            .expect("since should not be empty");
828
829        let mut replica_statuses_stream = self
830            .controller
831            .storage_collections
832            .snapshot_and_stream(replica_status_gid, as_of)
833            .await
834            .expect("can't read mz_cluster_replica_status_history");
835
836        let mut replica_problem_counts: BTreeMap<ReplicaId, u32> = BTreeMap::new();
837
838        while let Some((source_data, _ts, diff)) = replica_statuses_stream.next().await {
839            // Only process inserts (positive diffs)
840            if diff <= 0 {
841                continue;
842            }
843
844            // Extract the Row from SourceData
845            let row = match source_data.0 {
846                Ok(row) => row,
847                Err(err) => {
848                    // This builtin collection shouldn't have errors, so we at
849                    // least log an error so that tests or sentry will notice.
850                    tracing::error!(
851                        collection = MZ_CLUSTER_REPLICA_STATUS_HISTORY.name,
852                        ?err,
853                        "unexpected error in builtin collection"
854                    );
855                    continue;
856                }
857            };
858
859            let mut iter = row.into_iter();
860
861            let replica_id: ReplicaId = iter
862                .next()
863                .expect("missing replica_id")
864                .unwrap_str()
865                .parse()
866                .expect("must parse as replica ID");
867            let _process_id = iter.next().expect("missing process_id").unwrap_uint64();
868            let status = iter
869                .next()
870                .expect("missing status")
871                .unwrap_str()
872                .to_string();
873            let reason_datum = iter.next().expect("missing reason");
874            let reason = if reason_datum.is_null() {
875                None
876            } else {
877                Some(reason_datum.unwrap_str().to_string())
878            };
879            let occurred_at = iter
880                .next()
881                .expect("missing occurred_at")
882                .unwrap_timestamptz();
883
884            // Only consider events within the time window and that are problematic
885            if occurred_at.naive_utc() >= min_timestamp_dt.naive_utc() {
886                if Self::is_problematic_status(&status, reason.as_deref()) {
887                    *replica_problem_counts.entry(replica_id).or_insert(0) += 1;
888                }
889            }
890        }
891
892        // Filter to replicas with 3 or more problematic events.
893        let result = replica_problem_counts
894            .into_iter()
895            .filter_map(|(replica_id, count)| {
896                if count >= 3 {
897                    tracing::info!(
898                        "Detected problematic cluster replica {}: {} problematic events in last {:?}",
899                        replica_id,
900                        count,
901                        Duration::from_millis(lookback_window)
902                    );
903                    Some(replica_id)
904                } else {
905                    None
906                }
907            })
908            .collect();
909
910        // Explicitly keep the read hold alive until this point.
911        drop(read_hold);
912
913        result
914    }
915
916    /// Determines if a replica status indicates a problematic state that could
917    /// indicate looping.
918    fn is_problematic_status(_status: &str, reason: Option<&str>) -> bool {
919        // For now, we only look at the reason, but we could change/expand this
920        // if/when needed.
921        if let Some(reason) = reason {
922            return reason == OfflineReason::OomKilled.to_string();
923        }
924
925        false
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    /// Builds a health snapshot with all restarts attributed to a single
934    /// replica process. `change_secs` is the max status-change time as a
935    /// unix-second offset, `restarts` that process's restart count.
936    fn snapshot(all_healthy: bool, change_secs: i64, restarts: u64) -> ClusterHealthSnapshot {
937        ClusterHealthSnapshot {
938            all_healthy,
939            max_status_change: DateTime::from_timestamp(change_secs, 0),
940            restart_counts: BTreeMap::from([((ReplicaId::User(1), 0), restarts)]),
941        }
942    }
943
944    #[mz_ore::test]
945    fn stability_requires_sustained_health() {
946        let period_ms = 1000;
947        let mut state = ClusterStabilityState::default();
948
949        // The first healthy observation starts the streak but isn't yet stable.
950        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
951        // Still within the period.
952        assert!(!state.observe(&snapshot(true, 100, 0), 500, period_ms).ready);
953        // Past the period: ready.
954        assert!(
955            state
956                .observe(&snapshot(true, 100, 0), 1000, period_ms)
957                .ready
958        );
959    }
960
961    #[mz_ore::test]
962    fn unhealthy_resets_streak() {
963        let period_ms = 1000;
964        let mut state = ClusterStabilityState::default();
965
966        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
967        // A currently-unhealthy observation resets the streak.
968        assert!(
969            !state
970                .observe(&snapshot(false, 100, 0), 500, period_ms)
971                .ready
972        );
973        // Healthy again, but the clock restarts from here.
974        assert!(!state.observe(&snapshot(true, 100, 0), 600, period_ms).ready);
975        assert!(
976            !state
977                .observe(&snapshot(true, 100, 0), 1599, period_ms)
978                .ready
979        );
980        assert!(
981            state
982                .observe(&snapshot(true, 100, 0), 1600, period_ms)
983                .ready
984        );
985    }
986
987    #[mz_ore::test]
988    fn status_flap_between_ticks_resets_streak() {
989        let period_ms = 1000;
990        let mut state = ClusterStabilityState::default();
991
992        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
993        // Currently healthy, but the status-change time advanced, so a flap
994        // happened and resolved between ticks: reset.
995        assert!(
996            !state
997                .observe(&snapshot(true, 200, 0), 1000, period_ms)
998                .ready
999        );
1000        // A clean streak from here.
1001        assert!(
1002            !state
1003                .observe(&snapshot(true, 200, 0), 1500, period_ms)
1004                .ready
1005        );
1006        assert!(
1007            state
1008                .observe(&snapshot(true, 200, 0), 2500, period_ms)
1009                .ready
1010        );
1011    }
1012
1013    #[mz_ore::test]
1014    fn restart_between_ticks_resets_streak() {
1015        let period_ms = 1000;
1016        let mut state = ClusterStabilityState::default();
1017
1018        assert!(!state.observe(&snapshot(true, 100, 3), 0, period_ms).ready);
1019        // Healthy with the same status-change time, but the restart count went
1020        // up: a restart happened and recovered between ticks, which the status
1021        // alone would miss. Reset.
1022        assert!(
1023            !state
1024                .observe(&snapshot(true, 100, 4), 1000, period_ms)
1025                .ready
1026        );
1027        assert!(
1028            !state
1029                .observe(&snapshot(true, 100, 4), 1500, period_ms)
1030                .ready
1031        );
1032        assert!(
1033            state
1034                .observe(&snapshot(true, 100, 4), 2500, period_ms)
1035                .ready
1036        );
1037    }
1038
1039    #[mz_ore::test]
1040    fn offsetting_restart_changes_reset_streak() {
1041        // Two processes whose restart counts move in opposite directions by the
1042        // same amount. A cluster-wide sum would be unchanged and miss the
1043        // restart, but the per-process map differs, so the streak resets.
1044        let period_ms = 1000;
1045        let mut state = ClusterStabilityState::default();
1046
1047        let r = ReplicaId::User(1);
1048        let snapshot = |a: u64, b: u64| ClusterHealthSnapshot {
1049            all_healthy: true,
1050            max_status_change: DateTime::from_timestamp(100, 0),
1051            restart_counts: BTreeMap::from([((r, 0u64), a), ((r, 1u64), b)]),
1052        };
1053
1054        // Start a streak with per-process counts summing to 2.
1055        assert!(!state.observe(&snapshot(1, 1), 0, period_ms).ready);
1056        // One process restarts (+1) while the other is recreated (-1). The sum
1057        // is still 2, but the per-process map changed: reset.
1058        assert!(!state.observe(&snapshot(2, 0), 1000, period_ms).ready);
1059        // A clean streak from here.
1060        assert!(!state.observe(&snapshot(2, 0), 1500, period_ms).ready);
1061        assert!(state.observe(&snapshot(2, 0), 2500, period_ms).ready);
1062    }
1063
1064    #[mz_ore::test]
1065    fn zero_period_ready_on_first_healthy_tick() {
1066        let mut state = ClusterStabilityState::default();
1067        // With a zero period a single clean, healthy observation is enough.
1068        assert!(state.observe(&snapshot(true, 100, 0), 0, 0).ready);
1069    }
1070}