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        let live_frontiers = self
275            .controller
276            .storage_collections
277            .snapshot_latest(replica_frontier_gid)
278            .await
279            .expect("can't read mz_cluster_replica_frontiers");
280
281        let live_frontiers = live_frontiers
282            .into_iter()
283            .map(|row| {
284                let mut iter = row.into_iter();
285
286                let id: GlobalId = iter
287                    .next()
288                    .expect("missing object id")
289                    .unwrap_str()
290                    .parse()
291                    .expect("cannot parse id");
292                let replica_id = iter
293                    .next()
294                    .expect("missing replica id")
295                    .unwrap_str()
296                    .to_string();
297                let maybe_upper_ts = iter.next().expect("missing upper_ts");
298                // The timestamp has a total order, so there can be at
299                // most one entry in the upper frontier, which is this
300                // timestamp here. And NULL encodes the empty upper
301                // frontier.
302                let upper_frontier = if maybe_upper_ts.is_null() {
303                    Antichain::new()
304                } else {
305                    let upper_ts = maybe_upper_ts.unwrap_mz_timestamp();
306                    Antichain::from_elem(upper_ts)
307                };
308
309                (id, replica_id, upper_frontier)
310            })
311            .collect_vec();
312
313        // We care about each collection being hydrated on _some_
314        // replica. We don't check that at least one replica has all
315        // collections of that cluster hydrated.
316        let live_collection_frontiers: BTreeMap<_, _> = live_frontiers
317            .into_iter()
318            .map(|(oid, _replica_id, upper_ts)| (oid, upper_ts))
319            .into_grouping_map()
320            .fold(
321                Antichain::from_elem(Timestamp::minimum()),
322                |mut acc, _key, upper| {
323                    acc.join_assign(&upper);
324                    acc
325                },
326            )
327            .into_iter()
328            .collect();
329
330        tracing::debug!(?live_collection_frontiers, "checking re-hydration status");
331
332        let allowed_lag =
333            WITH_0DT_CAUGHT_UP_CHECK_ALLOWED_LAG.get(self.catalog().system_config().dyncfgs());
334        let allowed_lag: u64 = allowed_lag
335            .as_millis()
336            .try_into()
337            .expect("must fit into u64");
338
339        let cutoff = WITH_0DT_CAUGHT_UP_CHECK_CUTOFF.get(self.catalog().system_config().dyncfgs());
340        let cutoff: u64 = cutoff.as_millis().try_into().expect("must fit into u64");
341
342        let now = self.now();
343
344        // Something might go wrong with querying the status collection, so we
345        // have an emergency flag for disabling it.
346        let replica_status_check_enabled =
347            ENABLE_0DT_CAUGHT_UP_REPLICA_STATUS_CHECK.get(self.catalog().system_config().dyncfgs());
348
349        // Analyze replica statuses to detect crash-looping or OOM-looping replicas
350        let problematic_replicas = if replica_status_check_enabled {
351            self.analyze_replica_looping(now).await
352        } else {
353            BTreeSet::new()
354        };
355
356        let stability_check_enabled =
357            ENABLE_0DT_CAUGHT_UP_STABILITY_CHECK.get(self.catalog().system_config().dyncfgs());
358        let stability_period =
359            WITH_0DT_CAUGHT_UP_CHECK_STABILITY_PERIOD.get(self.catalog().system_config().dyncfgs());
360        // Cap rather than panic on an absurdly large configured duration. A
361        // period of u64::MAX milliseconds means "effectively never auto-ready",
362        // which is the safe, conservative outcome: we won't cut over on our own,
363        // and an operator can still force it via skip-catchup.
364        let stability_period_ms = u64::try_from(stability_period.as_millis()).unwrap_or(u64::MAX);
365
366        // We clone the exclude set so we don't hold a borrow of `caught_up_check`
367        // across the classification, which lets us update the per-cluster
368        // stability state on it (mutably) afterwards.
369        let exclude_collections = self
370            .caught_up_check
371            .as_ref()
372            .expect("known to exist")
373            .exclude_collections
374            .clone();
375
376        let classification = self
377            .classify_clusters(
378                allowed_lag.into(),
379                cutoff.into(),
380                now.into(),
381                &live_collection_frontiers,
382                &exclude_collections,
383                &problematic_replicas,
384            )
385            .await;
386
387        // Read the health snapshots for genuinely caught-up clusters now, while we
388        // only hold a shared borrow of `self`. We update the stability state in a
389        // separate, mutable pass below.
390        let health: BTreeMap<ClusterId, ClusterHealthSnapshot> = classification
391            .iter()
392            .filter(|(_, status)| **status == ClusterCaughtUpStatus::CaughtUp)
393            .map(|(&cluster_id, _)| (cluster_id, self.cluster_health(cluster_id)))
394            .collect();
395
396        let ctx = self.caught_up_check.as_mut().expect("known to exist");
397
398        // Drop stability state for clusters that are no longer genuinely caught
399        // up, so the streak restarts from scratch when they become caught-up
400        // again.
401        ctx.cluster_stability.retain(|cluster_id, _| {
402            classification.get(cluster_id) == Some(&ClusterCaughtUpStatus::CaughtUp)
403        });
404
405        let mut all_ready = true;
406        for (&cluster_id, status) in &classification {
407            match status {
408                ClusterCaughtUpStatus::Ignored => {}
409                ClusterCaughtUpStatus::NotCaughtUp => {
410                    all_ready = false;
411                }
412                ClusterCaughtUpStatus::CaughtUp => {
413                    // Break-glass: when disabled, a caught-up cluster is
414                    // immediately ready, with no replica-health requirement,
415                    // i.e. the behavior from before this gate existed. We keep it
416                    // as a config-level, fleet-wide revert. Operators can already
417                    // force a single cutover via skip-catchup/promote, but this
418                    // flag restores prior auto-cutover behavior across all
419                    // environments without per-deploy manual intervention or a
420                    // code release, mirroring
421                    // `enable_0dt_caught_up_replica_status_check`.
422                    if !stability_check_enabled {
423                        continue;
424                    }
425                    let snapshot = health.get(&cluster_id).expect("computed above");
426                    let state = ctx.cluster_stability.entry(cluster_id).or_default();
427                    let observation = state.observe(snapshot, now, stability_period_ms);
428                    if !observation.ready {
429                        all_ready = false;
430                        tracing::info!(
431                            %cluster_id,
432                            reason = ?observation.blocked_by,
433                            all_healthy = snapshot.all_healthy,
434                            stable_for_ms = ?observation.stable_for_ms,
435                            required_period_ms = stability_period_ms,
436                            max_status_change = ?snapshot.max_status_change,
437                            // Summed only for a readable log line. The gate
438                            // compares the per-process map, not this total.
439                            restart_total = snapshot.restart_counts.values().sum::<u64>(),
440                            "cluster is caught up but not yet stable for the required period"
441                        );
442                    }
443                }
444            }
445        }
446
447        tracing::info!(%all_ready, "checked caught-up status of clusters");
448
449        if all_ready {
450            let ctx = self.caught_up_check.take().expect("known to exist");
451            ctx.trigger.fire();
452        }
453    }
454
455    /// Reads the current health of a cluster's replicas from the in-memory
456    /// mirror of orchestrator-reported statuses.
457    ///
458    /// A cluster with no replica status entries (e.g. a freshly created cluster
459    /// whose statuses haven't been initialized) is reported as not healthy.
460    fn cluster_health(&self, cluster_id: ClusterId) -> ClusterHealthSnapshot {
461        let Some(replicas) = self
462            .cluster_replica_statuses
463            .try_get_cluster_statuses(cluster_id)
464            .filter(|replicas| !replicas.is_empty())
465        else {
466            // A cluster with no replica statuses is treated as not healthy.
467            return ClusterHealthSnapshot {
468                all_healthy: false,
469                max_status_change: None,
470                restart_counts: BTreeMap::new(),
471            };
472        };
473
474        let mut all_healthy = true;
475        let mut max_status_change = None;
476        let mut restart_counts = BTreeMap::new();
477        for (replica_id, processes) in replicas {
478            if ClusterReplicaStatuses::cluster_replica_status(processes) != ClusterStatus::Online {
479                all_healthy = false;
480            }
481            for (process_id, process) in processes {
482                max_status_change = max_status_change.max(Some(process.time));
483                restart_counts.insert((*replica_id, *process_id), process.restart_count);
484            }
485        }
486
487        ClusterHealthSnapshot {
488            all_healthy,
489            max_status_change,
490            restart_counts,
491        }
492    }
493
494    /// Classifies every cluster for the caught-up check.
495    ///
496    /// Informally, a cluster is considered caught-up if it is at least as healthy as its
497    /// counterpart in the leader environment. To determine that, we use the following rules:
498    ///
499    ///  (1) A cluster is caught-up if all non-transient, non-excluded collections installed on it
500    ///      are either caught-up or ignored.
501    ///  (2) A collection is caught-up when it is (a) hydrated and (b) its write frontier is within
502    ///      `allowed_lag` of the "live" frontier, the collection's frontier reported by the leader
503    ///      environment.
504    ///  (3) A collection is ignored if its "live" frontier is behind `now` by more than `cutoff`.
505    ///      Such a collection is unhealthy in the leader environment, so we don't care about its
506    ///      health in the read-only environment either.
507    ///  (4) On a cluster that is crash-looping, all collections are ignored.
508    ///
509    /// A cluster that is caught-up only because it has no replicas, or because it is hopelessly
510    /// behind with only crash/OOM-looping replicas (rule 4), is reported as
511    /// [`ClusterCaughtUpStatus::Ignored`] rather than [`ClusterCaughtUpStatus::CaughtUp`]. The
512    /// caller does not health-gate ignored clusters, so we keep ignoring clusters that are already
513    /// unhealthy in the leader environment.
514    async fn classify_clusters(
515        &self,
516        allowed_lag: Timestamp,
517        cutoff: Timestamp,
518        now: Timestamp,
519        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
520        exclude_collections: &BTreeSet<GlobalId>,
521        problematic_replicas: &BTreeSet<ReplicaId>,
522    ) -> BTreeMap<ClusterId, ClusterCaughtUpStatus> {
523        let mut result = BTreeMap::new();
524        for cluster in self.catalog().clusters() {
525            let status = self
526                .collections_caught_up(
527                    cluster,
528                    allowed_lag.clone(),
529                    cutoff.clone(),
530                    now.clone(),
531                    live_frontiers,
532                    exclude_collections,
533                    problematic_replicas,
534                )
535                .await
536                .unwrap_or_else(|e| {
537                    tracing::error!(
538                        "unexpected error while checking if cluster {} caught up: {e:#}",
539                        cluster.id
540                    );
541                    ClusterCaughtUpStatus::NotCaughtUp
542                });
543
544            if status == ClusterCaughtUpStatus::NotCaughtUp {
545                // We log all non-caught-up clusters instead of breaking out early.
546                tracing::info!("cluster {} is not caught up", cluster.id);
547            }
548
549            result.insert(cluster.id, status);
550        }
551
552        result
553    }
554
555    /// Classifies the given cluster for the caught-up check.
556    ///
557    /// See [`Coordinator::classify_clusters`] for details.
558    async fn collections_caught_up(
559        &self,
560        cluster: &Cluster,
561        allowed_lag: Timestamp,
562        cutoff: Timestamp,
563        now: Timestamp,
564        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
565        exclude_collections: &BTreeSet<GlobalId>,
566        problematic_replicas: &BTreeSet<ReplicaId>,
567    ) -> Result<ClusterCaughtUpStatus, anyhow::Error> {
568        if cluster.replicas().next().is_none() {
569            return Ok(ClusterCaughtUpStatus::Ignored);
570        }
571
572        // Check if all replicas in this cluster are crash/OOM-looping. As long
573        // as there is at least one healthy replica, the cluster is okay-ish.
574        let cluster_has_only_problematic_replicas = cluster
575            .replicas()
576            .all(|replica| problematic_replicas.contains(&replica.replica_id));
577
578        enum CollectionType {
579            Storage,
580            Compute,
581        }
582
583        let mut all_caught_up = true;
584
585        let storage_frontiers = self
586            .controller
587            .storage
588            .active_ingestion_exports(cluster.id)
589            .copied()
590            .filter(|id| !id.is_transient() && !exclude_collections.contains(id))
591            .map(|id| {
592                let (_read_frontier, write_frontier) =
593                    self.controller.storage.collection_frontiers(id)?;
594                Ok::<_, anyhow::Error>((id, write_frontier, CollectionType::Storage))
595            });
596
597        let compute_frontiers = self
598            .controller
599            .compute
600            .collection_ids(cluster.id)?
601            .filter(|id| !id.is_transient() && !exclude_collections.contains(id))
602            .map(|id| {
603                let write_frontier = self
604                    .controller
605                    .compute
606                    .collection_frontiers(id, Some(cluster.id))?
607                    .write_frontier
608                    .to_owned();
609                Ok((id, write_frontier, CollectionType::Compute))
610            });
611
612        for res in itertools::chain(storage_frontiers, compute_frontiers) {
613            let (id, write_frontier, collection_type) = res?;
614            let live_write_frontier = match live_frontiers.get(&id) {
615                Some(frontier) => frontier,
616                None => {
617                    // The collection didn't previously exist, so consider
618                    // ourselves hydrated as long as our write_ts is > 0.
619                    tracing::info!(?write_frontier, "collection {id} not in live frontiers");
620                    if write_frontier.less_equal(&Timestamp::minimum()) {
621                        all_caught_up = false;
622                    }
623                    continue;
624                }
625            };
626
627            // We can't do comparisons and subtractions, so we bump up the live
628            // write frontier by the cutoff, and then compare that against
629            // `now`.
630            let live_write_frontier_plus_cutoff = live_write_frontier
631                .iter()
632                .map(|t| t.step_forward_by(&cutoff));
633            let live_write_frontier_plus_cutoff =
634                Antichain::from_iter(live_write_frontier_plus_cutoff);
635
636            let beyond_all_hope = live_write_frontier_plus_cutoff.less_equal(&now);
637
638            if beyond_all_hope && cluster_has_only_problematic_replicas {
639                tracing::info!(
640                    ?live_write_frontier,
641                    ?cutoff,
642                    ?now,
643                    "live write frontier of collection {id} is too far behind 'now'"
644                );
645                tracing::info!(
646                    "ALL replicas of cluster {} are crash/OOM-looping and it has at least one \
647                     collection that is too far behind 'now'; ignoring cluster for caught-up \
648                     checks",
649                    cluster.id
650                );
651                return Ok(ClusterCaughtUpStatus::Ignored);
652            } else if beyond_all_hope {
653                tracing::info!(
654                    ?live_write_frontier,
655                    ?cutoff,
656                    ?now,
657                    "live write frontier of collection {id} is too far behind 'now'; \
658                     ignoring for caught-up checks"
659                );
660                continue;
661            }
662
663            // We can't do easy comparisons and subtractions, so we bump up the
664            // write frontier by the allowed lag, and then compare that against
665            // the write frontier.
666            let write_frontier_plus_allowed_lag = write_frontier
667                .iter()
668                .map(|t| t.step_forward_by(&allowed_lag));
669            let bumped_write_plus_allowed_lag =
670                Antichain::from_iter(write_frontier_plus_allowed_lag);
671
672            let within_lag =
673                PartialOrder::less_equal(live_write_frontier, &bumped_write_plus_allowed_lag);
674
675            // This call is on the expensive side, because we have to do a call
676            // across a task/channel boundary, and our work competes with other
677            // things the compute/instance controller might be doing. But it's
678            // okay because we only do these hydration checks when in read-only
679            // mode, and only rarely.
680            let collection_hydrated = match collection_type {
681                CollectionType::Compute => {
682                    self.controller
683                        .compute
684                        .collection_hydrated(cluster.id, id)
685                        .await?
686                }
687                CollectionType::Storage => self.controller.storage.collection_hydrated(id)?,
688            };
689
690            // We don't expect collections to get hydrated, ingestions to be
691            // started, etc. when they are already at the empty write frontier.
692            if live_write_frontier.is_empty() || (within_lag && collection_hydrated) {
693                // This is a bit spammy, but log caught-up collections while we
694                // investigate why environments are cutting over but then a lot
695                // of compute collections are _not_ in fact hydrated on
696                // clusters.
697                tracing::info!(
698                    %id,
699                    %within_lag,
700                    %collection_hydrated,
701                    ?write_frontier,
702                    ?live_write_frontier,
703                    ?allowed_lag,
704                    %cluster.id,
705                    "collection is caught up");
706            } else {
707                // We are not within the allowed lag, or not hydrated!
708                //
709                // We continue with our loop instead of breaking out early, so
710                // that we log all non-caught-up replicas.
711                tracing::info!(
712                    %id,
713                    %within_lag,
714                    %collection_hydrated,
715                    ?write_frontier,
716                    ?live_write_frontier,
717                    ?allowed_lag,
718                    %cluster.id,
719                    "collection is not caught up"
720                );
721                all_caught_up = false;
722            }
723        }
724
725        Ok(if all_caught_up {
726            ClusterCaughtUpStatus::CaughtUp
727        } else {
728            ClusterCaughtUpStatus::NotCaughtUp
729        })
730    }
731
732    /// Analyzes replica status history to detect replicas that are
733    /// crash-looping or OOM-looping.
734    ///
735    /// A replica is considered problematic if it has multiple OOM kills in a
736    /// short-ish window.
737    async fn analyze_replica_looping(&self, now: EpochMillis) -> BTreeSet<ReplicaId> {
738        // Look back 1 day for patterns.
739        let lookback_window: u64 = Duration::from_secs(24 * 60 * 60)
740            .as_millis()
741            .try_into()
742            .expect("fits into u64");
743        let min_timestamp = now.saturating_sub(lookback_window);
744        let min_timestamp_dt = mz_ore::now::to_datetime(min_timestamp);
745
746        // Get the replica status collection GlobalId
747        let replica_status_item_id = self
748            .catalog()
749            .resolve_builtin_storage_collection(&MZ_CLUSTER_REPLICA_STATUS_HISTORY);
750        let replica_status_gid = self
751            .catalog()
752            .get_entry(&replica_status_item_id)
753            .latest_global_id();
754
755        // Acquire a read hold to determine the as_of timestamp for snapshot_and_stream
756        let read_holds = self
757            .controller
758            .storage_collections
759            .acquire_read_holds(vec![replica_status_gid])
760            .expect("can't acquire read hold for mz_cluster_replica_status_history");
761        let read_hold = if let Some(read_hold) = read_holds.into_iter().next() {
762            read_hold
763        } else {
764            // Collection is not readable anymore, but we return an empty set
765            // instead of panicing.
766            return BTreeSet::new();
767        };
768
769        let as_of = read_hold
770            .since()
771            .iter()
772            .next()
773            .cloned()
774            .expect("since should not be empty");
775
776        let mut replica_statuses_stream = self
777            .controller
778            .storage_collections
779            .snapshot_and_stream(replica_status_gid, as_of)
780            .await
781            .expect("can't read mz_cluster_replica_status_history");
782
783        let mut replica_problem_counts: BTreeMap<ReplicaId, u32> = BTreeMap::new();
784
785        while let Some((source_data, _ts, diff)) = replica_statuses_stream.next().await {
786            // Only process inserts (positive diffs)
787            if diff <= 0 {
788                continue;
789            }
790
791            // Extract the Row from SourceData
792            let row = match source_data.0 {
793                Ok(row) => row,
794                Err(err) => {
795                    // This builtin collection shouldn't have errors, so we at
796                    // least log an error so that tests or sentry will notice.
797                    tracing::error!(
798                        collection = MZ_CLUSTER_REPLICA_STATUS_HISTORY.name,
799                        ?err,
800                        "unexpected error in builtin collection"
801                    );
802                    continue;
803                }
804            };
805
806            let mut iter = row.into_iter();
807
808            let replica_id: ReplicaId = iter
809                .next()
810                .expect("missing replica_id")
811                .unwrap_str()
812                .parse()
813                .expect("must parse as replica ID");
814            let _process_id = iter.next().expect("missing process_id").unwrap_uint64();
815            let status = iter
816                .next()
817                .expect("missing status")
818                .unwrap_str()
819                .to_string();
820            let reason_datum = iter.next().expect("missing reason");
821            let reason = if reason_datum.is_null() {
822                None
823            } else {
824                Some(reason_datum.unwrap_str().to_string())
825            };
826            let occurred_at = iter
827                .next()
828                .expect("missing occurred_at")
829                .unwrap_timestamptz();
830
831            // Only consider events within the time window and that are problematic
832            if occurred_at.naive_utc() >= min_timestamp_dt.naive_utc() {
833                if Self::is_problematic_status(&status, reason.as_deref()) {
834                    *replica_problem_counts.entry(replica_id).or_insert(0) += 1;
835                }
836            }
837        }
838
839        // Filter to replicas with 3 or more problematic events.
840        let result = replica_problem_counts
841            .into_iter()
842            .filter_map(|(replica_id, count)| {
843                if count >= 3 {
844                    tracing::info!(
845                        "Detected problematic cluster replica {}: {} problematic events in last {:?}",
846                        replica_id,
847                        count,
848                        Duration::from_millis(lookback_window)
849                    );
850                    Some(replica_id)
851                } else {
852                    None
853                }
854            })
855            .collect();
856
857        // Explicitly keep the read hold alive until this point.
858        drop(read_hold);
859
860        result
861    }
862
863    /// Determines if a replica status indicates a problematic state that could
864    /// indicate looping.
865    fn is_problematic_status(_status: &str, reason: Option<&str>) -> bool {
866        // For now, we only look at the reason, but we could change/expand this
867        // if/when needed.
868        if let Some(reason) = reason {
869            return reason == OfflineReason::OomKilled.to_string();
870        }
871
872        false
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    /// Builds a health snapshot with all restarts attributed to a single
881    /// replica process. `change_secs` is the max status-change time as a
882    /// unix-second offset, `restarts` that process's restart count.
883    fn snapshot(all_healthy: bool, change_secs: i64, restarts: u64) -> ClusterHealthSnapshot {
884        ClusterHealthSnapshot {
885            all_healthy,
886            max_status_change: DateTime::from_timestamp(change_secs, 0),
887            restart_counts: BTreeMap::from([((ReplicaId::User(1), 0), restarts)]),
888        }
889    }
890
891    #[mz_ore::test]
892    fn stability_requires_sustained_health() {
893        let period_ms = 1000;
894        let mut state = ClusterStabilityState::default();
895
896        // The first healthy observation starts the streak but isn't yet stable.
897        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
898        // Still within the period.
899        assert!(!state.observe(&snapshot(true, 100, 0), 500, period_ms).ready);
900        // Past the period: ready.
901        assert!(
902            state
903                .observe(&snapshot(true, 100, 0), 1000, period_ms)
904                .ready
905        );
906    }
907
908    #[mz_ore::test]
909    fn unhealthy_resets_streak() {
910        let period_ms = 1000;
911        let mut state = ClusterStabilityState::default();
912
913        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
914        // A currently-unhealthy observation resets the streak.
915        assert!(
916            !state
917                .observe(&snapshot(false, 100, 0), 500, period_ms)
918                .ready
919        );
920        // Healthy again, but the clock restarts from here.
921        assert!(!state.observe(&snapshot(true, 100, 0), 600, period_ms).ready);
922        assert!(
923            !state
924                .observe(&snapshot(true, 100, 0), 1599, period_ms)
925                .ready
926        );
927        assert!(
928            state
929                .observe(&snapshot(true, 100, 0), 1600, period_ms)
930                .ready
931        );
932    }
933
934    #[mz_ore::test]
935    fn status_flap_between_ticks_resets_streak() {
936        let period_ms = 1000;
937        let mut state = ClusterStabilityState::default();
938
939        assert!(!state.observe(&snapshot(true, 100, 0), 0, period_ms).ready);
940        // Currently healthy, but the status-change time advanced, so a flap
941        // happened and resolved between ticks: reset.
942        assert!(
943            !state
944                .observe(&snapshot(true, 200, 0), 1000, period_ms)
945                .ready
946        );
947        // A clean streak from here.
948        assert!(
949            !state
950                .observe(&snapshot(true, 200, 0), 1500, period_ms)
951                .ready
952        );
953        assert!(
954            state
955                .observe(&snapshot(true, 200, 0), 2500, period_ms)
956                .ready
957        );
958    }
959
960    #[mz_ore::test]
961    fn restart_between_ticks_resets_streak() {
962        let period_ms = 1000;
963        let mut state = ClusterStabilityState::default();
964
965        assert!(!state.observe(&snapshot(true, 100, 3), 0, period_ms).ready);
966        // Healthy with the same status-change time, but the restart count went
967        // up: a restart happened and recovered between ticks, which the status
968        // alone would miss. Reset.
969        assert!(
970            !state
971                .observe(&snapshot(true, 100, 4), 1000, period_ms)
972                .ready
973        );
974        assert!(
975            !state
976                .observe(&snapshot(true, 100, 4), 1500, period_ms)
977                .ready
978        );
979        assert!(
980            state
981                .observe(&snapshot(true, 100, 4), 2500, period_ms)
982                .ready
983        );
984    }
985
986    #[mz_ore::test]
987    fn offsetting_restart_changes_reset_streak() {
988        // Two processes whose restart counts move in opposite directions by the
989        // same amount. A cluster-wide sum would be unchanged and miss the
990        // restart, but the per-process map differs, so the streak resets.
991        let period_ms = 1000;
992        let mut state = ClusterStabilityState::default();
993
994        let r = ReplicaId::User(1);
995        let snapshot = |a: u64, b: u64| ClusterHealthSnapshot {
996            all_healthy: true,
997            max_status_change: DateTime::from_timestamp(100, 0),
998            restart_counts: BTreeMap::from([((r, 0u64), a), ((r, 1u64), b)]),
999        };
1000
1001        // Start a streak with per-process counts summing to 2.
1002        assert!(!state.observe(&snapshot(1, 1), 0, period_ms).ready);
1003        // One process restarts (+1) while the other is recreated (-1). The sum
1004        // is still 2, but the per-process map changed: reset.
1005        assert!(!state.observe(&snapshot(2, 0), 1000, period_ms).ready);
1006        // A clean streak from here.
1007        assert!(!state.observe(&snapshot(2, 0), 1500, period_ms).ready);
1008        assert!(state.observe(&snapshot(2, 0), 2500, period_ms).ready);
1009    }
1010
1011    #[mz_ore::test]
1012    fn zero_period_ready_on_first_healthy_tick() {
1013        let mut state = ClusterStabilityState::default();
1014        // With a zero period a single clean, healthy observation is enough.
1015        assert!(state.observe(&snapshot(true, 100, 0), 0, 0).ready);
1016    }
1017}