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}