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}