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. A task spawned by [`Coordinator::spawn_caught_up_check_task`]
16//! runs that check on an interval (see
17//! [`WITH_0DT_DEPLOYMENT_CAUGHT_UP_CHECK_INTERVAL`]). We call one such run a
18//! "tick", and the term is used throughout this module.
19//!
20//! Each tick the task reads the leader's Persist collections, then asks the
21//! coordinator to capture local state and issue hydration requests. The task
22//! awaits the replies, classifies the clusters and applies the stability gate.
23//! None of those waits run on the coordinator loop.
24//!
25//! A point-in-time caught-up check is not enough on its own: a crash- or
26//! OOM-looping replica can momentarily look hydrated and caught-up, and cutting
27//! over right then drops us straight into a crashing replica. On top of the
28//! per-tick caught-up classification we therefore run a stability gate. A
29//! caught-up cluster is ready only once every replica is `Online` and every
30//! non-exempt replica has had its non-ignored collections hydrated for a
31//! configurable period, capped for young replicas by [`observe_stability`].
32//!
33//! Each replica reports its hydration times in its
34//! `mz_compute_hydration_times_per_worker` introspection log. The log lives in
35//! the replica process. It survives environmentd restarts, because a restarted
36//! environmentd reconciles with the same replica processes and they keep
37//! compatible dataflows, so a DDL-triggered restart does not restart the
38//! period. A dataflow that changed rehydrates and restarts it. The log dies
39//! with the process, so a restarted replica must hydrate again and establish
40//! a new observation period, subject to the young-replica cap.
41//!
42//! Hydration times come from the replica's clock, while the period is measured
43//! against environmentd's. A replica clock that runs ahead only lengthens the
44//! wait, but one that runs behind shortens the period by the skew. We rely on
45//! hosts keeping their clocks in sync.
46//!
47//! A replica without introspection logging cannot report hydration times. It
48//! only has to be `Online`. Leader-relative exemptions are defined by
49//! [`LeaderHydration::replica_has_unhydrated_collection`].
50
51use std::collections::{BTreeMap, BTreeSet};
52use std::sync::Arc;
53use std::time::Duration;
54
55use anyhow::Context;
56use differential_dataflow::lattice::Lattice as _;
57use futures::{StreamExt, future};
58use itertools::Itertools;
59use mz_adapter_types::dyncfgs::{
60    ENABLE_0DT_CAUGHT_UP_REPLICA_STATUS_CHECK, ENABLE_0DT_CAUGHT_UP_STABILITY_CHECK,
61    WITH_0DT_CAUGHT_UP_CHECK_ALLOWED_LAG, WITH_0DT_CAUGHT_UP_CHECK_CUTOFF,
62    WITH_0DT_CAUGHT_UP_CHECK_STABILITY_PERIOD, WITH_0DT_DEPLOYMENT_CAUGHT_UP_CHECK_INTERVAL,
63};
64use mz_catalog::builtin::{
65    MZ_CATALOG_SERVER_CLUSTER, MZ_CLUSTER_REPLICA_FRONTIERS, MZ_CLUSTER_REPLICA_STATUS_HISTORY,
66    MZ_COMPUTE_HYDRATION_TIMES,
67};
68use mz_catalog::memory::objects::Cluster;
69use mz_compute_client::controller::CollectionReadiness;
70use mz_compute_client::controller::error::CollectionMissing;
71use mz_compute_client::logging::{ComputeLog, LogVariant};
72use mz_controller::clusters::ClusterStatus;
73use mz_controller_types::{ClusterId, ReplicaId};
74use mz_orchestrator::OfflineReason;
75use mz_ore::channel::trigger::Trigger;
76use mz_ore::now::EpochMillis;
77use mz_ore::task;
78use mz_repr::{GlobalId, Row, Timestamp};
79use mz_storage_client::storage_collections::StorageCollections;
80use timely::progress::{Antichain, Timestamp as _};
81use tokio::sync::oneshot;
82
83use crate::PeekClient;
84use crate::catalog::Catalog;
85use crate::command::{CatalogSnapshot, Command};
86use crate::coord::{ClusterReplicaStatuses, Coordinator, Message};
87
88/// How long the stability gate waits for an internal query.
89///
90/// Timeout drops the query without canceling its peek. See
91/// [`PeekClient::background_peek`] for the lifetime of retained resources.
92const STABILITY_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
93
94/// Returns a query for when a replica last finished hydrating an export.
95///
96/// It returns no row if the replica has reported no non-transient export.
97/// Otherwise its one row says whether the export is in `ignored`, which holds
98/// only when every reported export is, and its hydration time: NULL if some
99/// export not in `ignored` is not hydrated on some worker, else the latest
100/// time one of them hydrated.
101///
102/// NOTE: This must stay a fast-path peek, an index scan plus a finishing, so
103/// that it does not install a dataflow on every replica on every tick.
104/// Aggregates would make it a slow-path peek.
105fn hydration_query(ignored: &BTreeSet<GlobalId>) -> String {
106    let is_ignored = if ignored.is_empty() {
107        "false".to_string()
108    } else {
109        let ids = ignored.iter().map(|id| format!("'{id}'")).join(", ");
110        format!("export_id IN ({ids})")
111    };
112    format!(
113        "SELECT {is_ignored} AS ignored, hydrated_at \
114        FROM mz_introspection.mz_compute_hydration_times_per_worker \
115        WHERE export_id NOT LIKE 't%' \
116        ORDER BY ignored, hydrated_at DESC NULLS FIRST LIMIT 1"
117    )
118}
119
120/// Context needed to check whether clusters/collections are caught up.
121#[derive(Debug)]
122pub struct CaughtUpCheckContext {
123    /// A trigger that signals that all clusters/collections have been caught
124    /// up.
125    pub trigger: Trigger,
126    /// Collections to exclude from the caught up check.
127    ///
128    /// When a caught up check is performed as part of a 0dt upgrade, it makes sense to exclude
129    /// collections of newly added builtin objects, as these might not hydrate in read-only mode.
130    pub exclude_collections: BTreeSet<GlobalId>,
131}
132
133/// Requests local controller state without waiting for external reads.
134#[derive(Debug)]
135pub struct CaughtUpCheckRequest {
136    exclude_collections: Arc<BTreeSet<GlobalId>>,
137    tx: oneshot::Sender<CaughtUpCheck>,
138}
139
140/// Inputs owned by one background check.
141#[derive(Debug)]
142struct CaughtUpCheck {
143    catalog: Arc<Catalog>,
144    now: EpochMillis,
145    clusters: BTreeMap<ClusterId, Result<ClusterSnapshot, anyhow::Error>>,
146}
147
148struct LeaderSnapshot {
149    frontiers: BTreeMap<GlobalId, Antichain<Timestamp>>,
150    hydration: LeaderHydration,
151    unhydrated_collections: BTreeSet<GlobalId>,
152    problematic_replicas: BTreeSet<ReplicaId>,
153}
154
155#[derive(Debug)]
156struct ClusterSnapshot {
157    replicas: BTreeMap<ReplicaId, ReplicaTarget>,
158    ignored_compute_collections: BTreeSet<GlobalId>,
159    collections: Vec<(GlobalId, Antichain<Timestamp>, CollectionType)>,
160    compute_hydration: oneshot::Receiver<BTreeMap<GlobalId, Result<bool, CollectionMissing>>>,
161}
162
163#[derive(Debug)]
164enum CollectionType {
165    Storage { hydrated: bool },
166    Compute,
167}
168
169/// The cluster classification and stability inputs for one tick.
170#[derive(Debug)]
171struct CaughtUpSnapshot {
172    now: EpochMillis,
173    /// Whether every cluster is caught up or ignored.
174    all_caught_up: bool,
175    stability_check_enabled: bool,
176    stability_period_ms: u64,
177    /// Every caught-up cluster. Empty when the stability check is disabled.
178    caught_up_clusters: BTreeMap<ClusterId, CaughtUpCluster>,
179}
180
181/// A caught-up cluster, as the stability gate needs to know it.
182#[derive(Debug)]
183struct CaughtUpCluster {
184    /// Compute collections the classification ignored, which therefore need not
185    /// hydrate.
186    ignored_compute_collections: BTreeSet<GlobalId>,
187    replicas: BTreeMap<ReplicaId, ReplicaTarget>,
188}
189
190/// What the coordinator knows about a replica of a caught-up cluster, and so
191/// whether the stability gate has to ask it.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ReplicaTarget {
194    /// See [`ReplicaHealth::NotOnline`].
195    NotOnline,
196    /// See [`ReplicaHealth::Exempt`].
197    Exempt,
198    /// The replica is `Online` and has to report its hydration times.
199    Ask,
200}
201
202/// How a cluster relates to the 0dt caught-up check on a given tick.
203#[derive(Debug, Clone, PartialEq, Eq)]
204enum ClusterCaughtUpStatus {
205    /// Hydrated and within lag, apart from `ignored_compute_collections`.
206    /// Subject to the stability gate.
207    CaughtUp {
208        /// Compute collections the classification ignored, see
209        /// [`CaughtUpCluster::ignored_compute_collections`].
210        ignored_compute_collections: BTreeSet<GlobalId>,
211    },
212    /// Excluded by the existing checks (no replicas, or hopelessly behind with
213    /// only crash/OOM-looping replicas). Does not block readiness and is not
214    /// health-gated, so we keep ignoring clusters that are already unhealthy in
215    /// the leader environment.
216    Ignored,
217    /// Not yet caught up. Blocks readiness.
218    NotCaughtUp,
219}
220
221/// What the stability gate knows about one replica on a given tick.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum ReplicaHealth {
224    /// The replica is not currently `Online`.
225    NotOnline,
226    /// The replica is `Online`, but has not hydrated everything it hosts, or
227    /// did not answer the hydration query.
228    NotHydrated,
229    /// The replica has been healthy since this wall-clock time.
230    HealthySince(EpochMillis),
231    /// The replica is `Online` and exempt from the hydration stability check.
232    Exempt,
233}
234
235/// Why a caught-up cluster is being held back by the stability gate on a given
236/// tick. Recorded so we can log the cause.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238enum StabilityBlocker {
239    /// Not all replicas are currently `Online`.
240    NotOnline,
241    /// Some replica has not hydrated everything it hosts.
242    NotHydrated,
243    /// Every replica is healthy, but not yet for the required period.
244    WithinPeriod,
245}
246
247/// Outcome of folding a cluster's replica health.
248#[derive(Debug, Clone, Copy)]
249struct StabilityObservation {
250    /// How long the cluster has been stable, in milliseconds. `None` when some
251    /// replica has no evidence of health.
252    stable_for_ms: Option<u64>,
253    /// Why the cluster is held back. `None` once it is ready.
254    blocked_by: Option<StabilityBlocker>,
255}
256
257/// Folds the health of a cluster's replicas into the gate's verdict.
258///
259/// Each replica waits for the configured period, capped at its creation age
260/// when it hydrated. Missing or invalid creation evidence retains the full
261/// period. Replica clocks running behind shorten the wait by their skew.
262fn observe_stability(
263    replicas: &[(ReplicaHealth, Option<EpochMillis>)],
264    now: EpochMillis,
265    period_ms: u64,
266) -> StabilityObservation {
267    let blocked = |blocker| StabilityObservation {
268        stable_for_ms: None,
269        blocked_by: Some(blocker),
270    };
271    if replicas.is_empty()
272        || replicas
273            .iter()
274            .any(|(health, _)| *health == ReplicaHealth::NotOnline)
275    {
276        return blocked(StabilityBlocker::NotOnline);
277    }
278    let mut stable_since = EpochMillis::MIN;
279    let mut within_period = false;
280    for (health, created_at) in replicas {
281        match health {
282            ReplicaHealth::HealthySince(since) => {
283                stable_since = stable_since.max(*since);
284                // Freeze the age at hydration. Using its current age makes
285                // the required period grow as quickly as the observed period.
286                let required = match created_at {
287                    Some(created) if created <= since && *since <= now => {
288                        period_ms.min(since - created)
289                    }
290                    _ => period_ms,
291                };
292                within_period |= now.saturating_sub(*since) < required;
293            }
294            ReplicaHealth::Exempt => {}
295            ReplicaHealth::NotOnline | ReplicaHealth::NotHydrated => {
296                return blocked(StabilityBlocker::NotHydrated);
297            }
298        }
299    }
300    let stable_for_ms = now.saturating_sub(stable_since.min(now));
301    StabilityObservation {
302        stable_for_ms: Some(stable_for_ms),
303        blocked_by: within_period.then_some(StabilityBlocker::WithinPeriod),
304    }
305}
306
307/// The part of the caught-up check that runs in its own task: it asks replicas
308/// for their hydration times and applies the stability gate.
309struct StabilityGate {
310    client: PeekClient,
311    replica_created_at: BTreeMap<ReplicaId, EpochMillis>,
312}
313
314impl StabilityGate {
315    /// Returns whether `snapshot` shows every cluster ready for promotion.
316    async fn ready(&mut self, snapshot: CaughtUpSnapshot) -> bool {
317        fail::fail_point!("0dt_caught_up_check", |_| false);
318        let CaughtUpSnapshot {
319            now,
320            all_caught_up,
321            stability_check_enabled,
322            stability_period_ms,
323            caught_up_clusters,
324        } = snapshot;
325        // Disabling the stability check is a fleet-wide break-glass that makes
326        // caught-up clusters ready without waiting on replica health. While
327        // some cluster is not caught up, readiness is blocked anyway, so no
328        // replica needs to be asked.
329        if !all_caught_up || !stability_check_enabled {
330            tracing::info!(all_ready = %all_caught_up, "checked caught-up status of clusters");
331            return all_caught_up;
332        }
333
334        // Missing evidence keeps the full period. Retry until every queried
335        // user replica has a creation time, since catalog views may hydrate late.
336        if caught_up_clusters.values().any(|cluster| {
337            cluster.replicas.iter().any(|(id, target)| {
338                id.is_user()
339                    && *target == ReplicaTarget::Ask
340                    && !self.replica_created_at.contains_key(id)
341            })
342        }) {
343            self.load_replica_creation_times().await;
344        }
345        let answers = self.ask_replicas(&caught_up_clusters).await;
346
347        let mut all_ready = true;
348        for (cluster_id, cluster) in caught_up_clusters {
349            let health: BTreeMap<_, _> = cluster
350                .replicas
351                .iter()
352                .map(|(replica_id, target)| {
353                    let replica_health = match target {
354                        ReplicaTarget::NotOnline => ReplicaHealth::NotOnline,
355                        ReplicaTarget::Exempt => ReplicaHealth::Exempt,
356                        ReplicaTarget::Ask => answers
357                            .get(replica_id)
358                            .copied()
359                            .unwrap_or(ReplicaHealth::NotHydrated),
360                    };
361                    (*replica_id, replica_health)
362                })
363                .collect();
364
365            let replica_health = health
366                .iter()
367                .map(|(id, health)| (*health, self.replica_created_at.get(id).copied()))
368                .collect_vec();
369            let observation = observe_stability(&replica_health, now, stability_period_ms);
370            if let Some(reason) = observation.blocked_by {
371                all_ready = false;
372                tracing::info!(
373                    %cluster_id,
374                    ?reason,
375                    replicas = ?health,
376                    stable_for_ms = ?observation.stable_for_ms,
377                    required_period_ms = stability_period_ms,
378                    "cluster is caught up but not yet stable for the required period"
379                );
380            }
381        }
382
383        tracing::info!(%all_ready, "checked caught-up status of clusters");
384        all_ready
385    }
386
387    async fn load_replica_creation_times(&mut self) {
388        let query = async {
389            let CatalogSnapshot { catalog } = self
390                .client
391                .call_coordinator(|tx| Command::CatalogSnapshot { tx })
392                .await?;
393            let cluster = catalog.resolve_builtin_cluster(&MZ_CATALOG_SERVER_CLUSTER);
394            let replica = cluster.replicas().next().ok_or_else(|| {
395                crate::AdapterError::Internal("catalog server has no replicas".into())
396            })?;
397            let (cluster_id, replica_id) = (cluster.id, replica.replica_id);
398            drop(catalog);
399            self.client.background_peek(
400                "SELECT replica_id, created_at FROM mz_internal.mz_cluster_replica_history WHERE dropped_at IS NULL",
401                cluster_id, replica_id,
402            ).await
403        };
404        match tokio::time::timeout(STABILITY_QUERY_TIMEOUT, query).await {
405            Ok(Ok(rows)) => {
406                for row in rows {
407                    let mut values = row.iter();
408                    let id = values.next().expect("replica_id");
409                    let created = values.next().expect("created_at");
410                    if id.is_null() || created.is_null() {
411                        continue;
412                    }
413                    if let (Ok(id), Ok(created)) = (
414                        id.unwrap_str().parse::<ReplicaId>(),
415                        u64::try_from(created.unwrap_timestamptz().timestamp_millis()),
416                    ) {
417                        self.replica_created_at.insert(id, created);
418                    }
419                }
420            }
421            Ok(Err(error)) => {
422                tracing::warn!(%error, "replica creation query failed; retaining full stability period")
423            }
424            Err(_) => {
425                tracing::warn!("replica creation query timed out; retaining full stability period")
426            }
427        }
428    }
429
430    /// Asks every [`ReplicaTarget::Ask`] replica, concurrently, when it last
431    /// finished hydrating an export.
432    async fn ask_replicas(
433        &self,
434        clusters: &BTreeMap<ClusterId, CaughtUpCluster>,
435    ) -> BTreeMap<ReplicaId, ReplicaHealth> {
436        let queries = clusters.iter().flat_map(|(&cluster_id, cluster)| {
437            let sql = hydration_query(&cluster.ignored_compute_collections);
438            cluster
439                .replicas
440                .iter()
441                .filter(|(_, target)| **target == ReplicaTarget::Ask)
442                .map(move |(&replica_id, _)| {
443                    let mut client = self.client.clone();
444                    let sql = sql.clone();
445                    async move {
446                        let query = client.background_peek(&sql, cluster_id, replica_id);
447                        let answer = tokio::time::timeout(STABILITY_QUERY_TIMEOUT, query).await;
448                        let health = match answer {
449                            Ok(Ok(rows)) => replica_health(&rows),
450                            Ok(Err(error)) => {
451                                tracing::warn!(
452                                    %cluster_id, %replica_id, %error, "hydration query failed"
453                                );
454                                ReplicaHealth::NotHydrated
455                            }
456                            Err(_elapsed) => {
457                                tracing::warn!(%cluster_id, %replica_id, "hydration query timed out");
458                                ReplicaHealth::NotHydrated
459                            }
460                        };
461                        (replica_id, health)
462                    }
463                })
464        });
465        future::join_all(queries).await.into_iter().collect()
466    }
467}
468
469/// Interprets the rows of a [`hydration_query`].
470fn replica_health(rows: &[Row]) -> ReplicaHealth {
471    let Some(row) = rows.first() else {
472        // The replica has not reported its exports yet.
473        return ReplicaHealth::NotHydrated;
474    };
475    let mut datums = row.iter();
476    let ignored = datums.next().expect("ignored column");
477    let hydrated_at = datums.next().expect("hydrated_at column");
478    if ignored.unwrap_bool() {
479        // The classification ignored every export the replica hosts, so no
480        // hydration is left to wait for.
481        return ReplicaHealth::Exempt;
482    }
483    if hydrated_at.is_null() {
484        return ReplicaHealth::NotHydrated;
485    }
486    let millis = hydrated_at.unwrap_timestamptz().timestamp_millis();
487    // A time before the epoch is nonsense, and must not read as stable for
488    // decades.
489    u64::try_from(millis).map_or(ReplicaHealth::NotHydrated, ReplicaHealth::HealthySince)
490}
491
492impl Coordinator {
493    /// Spawns the task that checks, on an interval, whether all
494    /// clusters/collections are caught up, and fires the trigger of
495    /// `self.caught_up_check` once they are.
496    ///
497    /// A no-op when there is no caught-up check, i.e. outside read-only mode.
498    pub(crate) fn spawn_caught_up_check_task(&mut self) {
499        let Some(CaughtUpCheckContext {
500            trigger,
501            exclude_collections,
502        }) = self.caught_up_check.take()
503        else {
504            return;
505        };
506        let exclude_collections = Arc::new(exclude_collections);
507        let period = WITH_0DT_DEPLOYMENT_CAUGHT_UP_CHECK_INTERVAL
508            .get(self.catalog().system_config().dyncfgs());
509        let internal_cmd_tx = self.internal_cmd_tx.clone();
510        let mut catalog = self.owned_catalog();
511        let storage_collections = Arc::clone(&self.controller.storage_collections);
512        let mut gate = StabilityGate {
513            client: self.background_peek_client(&self.owned_catalog()),
514            replica_created_at: BTreeMap::new(),
515        };
516
517        task::spawn(|| "caught_up_check", async move {
518            let mut delay = None;
519            loop {
520                // Leave a full idle period between attempts, including retries.
521                // An interval would immediately retry a check that outlasted its period.
522                if let Some(delay) = delay {
523                    tokio::time::sleep(delay).await;
524                }
525                delay = Some(period);
526                // Sample the leader before local frontiers and hydration. Slow
527                // Persist reads must not age local evidence or create artificial lag.
528                let leader = match LeaderSnapshot::read(&catalog, &*storage_collections).await {
529                    Ok(leader) => leader,
530                    Err(error) => {
531                        tracing::warn!(%error, "cannot read leader caught-up state");
532                        continue;
533                    }
534                };
535                let leader_revision = catalog.transient_revision();
536                let (tx, rx) = oneshot::channel();
537                let request = CaughtUpCheckRequest {
538                    exclude_collections: Arc::clone(&exclude_collections),
539                    tx,
540                };
541                // A failed send or a dropped reply means the coordinator is
542                // gone.
543                //
544                // NOTE: Returning drops `trigger`, which fires it, so a
545                // coordinator shutdown reads as caught up.
546                if internal_cmd_tx
547                    .send(Message::CaughtUpCheck(request))
548                    .is_err()
549                {
550                    return;
551                }
552                let Ok(check) = rx.await else {
553                    return;
554                };
555                catalog = Arc::clone(&check.catalog);
556                // Read-only mode rejects catalog transactions and handles leader DDL
557                // by restarting via `check_ddl_changes`. Both revision checks guard
558                // against stale evidence if catalog changes are ever applied in place.
559                if catalog.transient_revision() != leader_revision {
560                    continue;
561                }
562                let snapshot = check.run(leader).await;
563                if gate.ready(snapshot).await && catalog.transient_revision_is_current() {
564                    trigger.fire();
565                    return;
566                }
567            }
568        });
569    }
570
571    /// Captures local state and issues hydration requests without awaiting them.
572    pub(crate) fn handle_caught_up_check_request(&self, request: CaughtUpCheckRequest) {
573        let CaughtUpCheckRequest {
574            exclude_collections,
575            tx,
576        } = request;
577        let clusters = self
578            .catalog()
579            .clusters()
580            .filter(|cluster| cluster.replicas().next().is_some())
581            .map(|cluster| {
582                (
583                    cluster.id,
584                    self.caught_up_cluster_snapshot(cluster, &exclude_collections),
585                )
586            })
587            .collect();
588        let _ = tx.send(CaughtUpCheck {
589            catalog: self.owned_catalog(),
590            now: self.now(),
591            clusters,
592        });
593    }
594
595    fn caught_up_cluster_snapshot(
596        &self,
597        cluster: &Cluster,
598        exclude_collections: &BTreeSet<GlobalId>,
599    ) -> Result<ClusterSnapshot, anyhow::Error> {
600        let has_hydration_log = cluster
601            .log_indexes
602            .contains_key(&LogVariant::Compute(ComputeLog::HydrationTime));
603        let replicas = cluster
604            .replicas()
605            .map(|replica| {
606                let online = self
607                    .cluster_replica_statuses
608                    .try_get_cluster_replica_statuses(cluster.id, replica.replica_id)
609                    .is_some_and(|processes| {
610                        !processes.is_empty()
611                            && ClusterReplicaStatuses::cluster_replica_status(processes)
612                                == ClusterStatus::Online
613                    });
614                let target = if !online {
615                    ReplicaTarget::NotOnline
616                } else if !has_hydration_log || !replica.config.compute.logging.enabled() {
617                    ReplicaTarget::Exempt
618                } else {
619                    ReplicaTarget::Ask
620                };
621                (replica.replica_id, target)
622            })
623            .collect();
624        let mut collections = Vec::new();
625        for id in self
626            .controller
627            .storage
628            .active_ingestion_exports(cluster.id)
629            .copied()
630            .filter(|id| !id.is_transient() && !exclude_collections.contains(id))
631        {
632            let (_, frontier) = self.controller.storage.collection_frontiers(id)?;
633            let hydrated = self.controller.storage.collection_hydrated(id)?;
634            collections.push((id, frontier, CollectionType::Storage { hydrated }));
635        }
636        let mut ignored_compute_collections = BTreeSet::new();
637        let mut compute_ids = Vec::new();
638        for id in self.controller.compute.collection_ids(cluster.id)? {
639            if exclude_collections.contains(&id) {
640                ignored_compute_collections.insert(id);
641            } else if !id.is_transient() {
642                let frontier = self
643                    .controller
644                    .compute
645                    .collection_frontiers(id, Some(cluster.id))?
646                    .write_frontier
647                    .to_owned();
648                collections.push((id, frontier, CollectionType::Compute));
649                compute_ids.push(id);
650            }
651        }
652        let compute_hydration = self
653            .controller
654            .compute
655            .collections_hydrated(cluster.id, compute_ids)?;
656        Ok(ClusterSnapshot {
657            replicas,
658            ignored_compute_collections,
659            collections,
660            compute_hydration,
661        })
662    }
663}
664
665impl LeaderSnapshot {
666    async fn read(
667        catalog: &Catalog,
668        storage_collections: &(dyn StorageCollections + Send + Sync),
669    ) -> Result<Self, anyhow::Error> {
670        fail::fail_point!("0dt_caught_up_read");
671        let replica_frontier_item_id =
672            catalog.resolve_builtin_storage_collection(&MZ_CLUSTER_REPLICA_FRONTIERS);
673        let replica_frontier_gid = catalog
674            .get_entry(&replica_frontier_item_id)
675            .latest_global_id();
676
677        // `snapshot_latest` requires that the collection consolidates to a
678        // set. `mz_cluster_replica_frontiers` is a controller-managed builtin
679        // written with ±1 diffs, so it satisfies that invariant.
680        //
681        // NOTE: these are the leader's frontiers only because we read the leader's shard.
682        // `validate_migration_steps` forbids migrating `mz_cluster_replica_frontiers` for this
683        // reason, so a declared migration can't reach here. A test forcing replacement across all
684        // builtins bypasses that guard, hands us a shard we write ourselves, and the lag check
685        // below then compares this deployment against itself.
686        let live_frontiers = storage_collections
687            .snapshot_latest(replica_frontier_gid)
688            .await
689            .context("cannot read mz_cluster_replica_frontiers")?;
690
691        let live_frontiers = live_frontiers
692            .into_iter()
693            .map(|row| {
694                let mut iter = row.into_iter();
695
696                let id: GlobalId = iter
697                    .next()
698                    .expect("missing object id")
699                    .unwrap_str()
700                    .parse()
701                    .expect("cannot parse id");
702                let replica_id = iter
703                    .next()
704                    .expect("missing replica id")
705                    .unwrap_str()
706                    .to_string();
707                let maybe_upper_ts = iter.next().expect("missing upper_ts");
708                // The timestamp has a total order, so there can be at
709                // most one entry in the upper frontier, which is this
710                // timestamp here. And NULL encodes the empty upper
711                // frontier.
712                let upper_frontier = if maybe_upper_ts.is_null() {
713                    Antichain::new()
714                } else {
715                    let upper_ts = maybe_upper_ts.unwrap_mz_timestamp();
716                    Antichain::from_elem(upper_ts)
717                };
718
719                (id, replica_id, upper_frontier)
720            })
721            .collect_vec();
722
723        let leader_hydration = {
724            let item_id = catalog.resolve_builtin_storage_collection(&MZ_COMPUTE_HYDRATION_TIMES);
725            let id = catalog.get_entry(&item_id).latest_global_id();
726            match storage_collections.snapshot_latest(id).await {
727                Ok(rows) => LeaderHydration::from_rows(rows),
728                Err(error) => {
729                    tracing::warn!(%error, "cannot read leader hydration; requiring local hydration");
730                    LeaderHydration::default()
731                }
732            }
733        };
734        let leader_unhydrated_collections =
735            leader_hydration.collections_unhydrated_on_all_hosting_replicas(&live_frontiers);
736
737        let live_collection_frontiers: BTreeMap<_, _> = live_frontiers
738            .into_iter()
739            .map(|(oid, _replica_id, upper_ts)| (oid, upper_ts))
740            .into_grouping_map()
741            .fold(
742                Antichain::from_elem(Timestamp::minimum()),
743                |mut acc, _key, upper| {
744                    acc.join_assign(&upper);
745                    acc
746                },
747            )
748            .into_iter()
749            .collect();
750
751        let problematic_replicas = if ENABLE_0DT_CAUGHT_UP_REPLICA_STATUS_CHECK
752            .get(catalog.system_config().dyncfgs())
753        {
754            Self::analyze_replica_looping(catalog, storage_collections, (catalog.config().now)())
755                .await?
756        } else {
757            BTreeSet::new()
758        };
759        Ok(Self {
760            frontiers: live_collection_frontiers,
761            hydration: leader_hydration,
762            unhydrated_collections: leader_unhydrated_collections,
763            problematic_replicas,
764        })
765    }
766}
767
768impl CaughtUpCheck {
769    async fn run(mut self, leader: LeaderSnapshot) -> CaughtUpSnapshot {
770        let LeaderSnapshot {
771            frontiers,
772            hydration,
773            unhydrated_collections,
774            problematic_replicas,
775        } = leader;
776        tracing::debug!(?frontiers, "checking re-hydration status");
777        let allowed_lag =
778            WITH_0DT_CAUGHT_UP_CHECK_ALLOWED_LAG.get(self.catalog.system_config().dyncfgs());
779        let allowed_lag: u64 = allowed_lag
780            .as_millis()
781            .try_into()
782            .expect("must fit into u64");
783
784        let cutoff = WITH_0DT_CAUGHT_UP_CHECK_CUTOFF.get(self.catalog.system_config().dyncfgs());
785        let cutoff: u64 = cutoff.as_millis().try_into().expect("must fit into u64");
786
787        let now = self.now;
788
789        let stability_check_enabled =
790            ENABLE_0DT_CAUGHT_UP_STABILITY_CHECK.get(self.catalog.system_config().dyncfgs());
791        let stability_period =
792            WITH_0DT_CAUGHT_UP_CHECK_STABILITY_PERIOD.get(self.catalog.system_config().dyncfgs());
793        // Cap rather than panic on an absurdly large configured duration. A
794        // period of u64::MAX milliseconds means "effectively never auto-ready",
795        // which is the safe, conservative outcome: we won't cut over on our own,
796        // and an operator can still force it via skip-catchup.
797        let stability_period_ms = u64::try_from(stability_period.as_millis()).unwrap_or(u64::MAX);
798
799        let classification = self
800            .classify_clusters(
801                allowed_lag.into(),
802                cutoff.into(),
803                now.into(),
804                &frontiers,
805                &unhydrated_collections,
806                &problematic_replicas,
807            )
808            .await;
809
810        let all_caught_up = classification
811            .values()
812            .all(|status| *status != ClusterCaughtUpStatus::NotCaughtUp);
813        let caught_up_clusters = if stability_check_enabled {
814            classification
815                .into_iter()
816                .filter_map(|(cluster_id, status)| match status {
817                    ClusterCaughtUpStatus::CaughtUp {
818                        ignored_compute_collections,
819                    } => Some((
820                        cluster_id,
821                        CaughtUpCluster {
822                            replicas: self.replica_targets(
823                                cluster_id,
824                                &hydration,
825                                &ignored_compute_collections,
826                            ),
827                            ignored_compute_collections,
828                        },
829                    )),
830                    _ => None,
831                })
832                .collect()
833        } else {
834            BTreeMap::new()
835        };
836
837        CaughtUpSnapshot {
838            now,
839            all_caught_up,
840            stability_check_enabled,
841            stability_period_ms,
842            caught_up_clusters,
843        }
844    }
845
846    /// Classifies the replicas of a cluster for the stability gate.
847    fn replica_targets(
848        &self,
849        cluster_id: ClusterId,
850        leader: &LeaderHydration,
851        ignored: &BTreeSet<GlobalId>,
852    ) -> BTreeMap<ReplicaId, ReplicaTarget> {
853        let cluster = self.clusters[&cluster_id]
854            .as_ref()
855            .expect("classified cluster");
856        cluster
857            .replicas
858            .iter()
859            .map(|(&id, &target)| {
860                let target = if target == ReplicaTarget::Ask
861                    && leader.replica_has_unhydrated_collection(id, ignored)
862                {
863                    ReplicaTarget::Exempt
864                } else {
865                    target
866                };
867                (id, target)
868            })
869            .collect()
870    }
871
872    /// Classifies every cluster for the caught-up check.
873    ///
874    /// Informally, a cluster is considered caught-up if it is at least as healthy as its
875    /// counterpart in the leader environment. To determine that, we use the following rules:
876    ///
877    ///  (1) A cluster is caught-up if all non-transient, non-excluded collections installed on it
878    ///      are either caught-up or ignored.
879    ///  (2) A collection is caught-up when it is (a) hydrated (waived for compute collections
880    ///      returned by [`LeaderHydration::collections_unhydrated_on_all_hosting_replicas`]),
881    ///      and (b) its write frontier is within
882    ///      `allowed_lag` of the "live" frontier, the collection's frontier reported by the leader
883    ///      environment.
884    ///  (3) A collection is ignored if its "live" frontier is behind `now` by more than `cutoff`.
885    ///      Such a collection is unhealthy in the leader environment, so we don't care about its
886    ///      health in the read-only environment either.
887    ///  (4) On a cluster that is crash-looping, all collections are ignored.
888    ///
889    /// A cluster that is caught-up only because it has no replicas, or because it is hopelessly
890    /// behind with only crash/OOM-looping replicas (rule 4), is reported as
891    /// [`ClusterCaughtUpStatus::Ignored`] rather than [`ClusterCaughtUpStatus::CaughtUp`]. The
892    /// caller does not health-gate ignored clusters, so we keep ignoring clusters that are already
893    /// unhealthy in the leader environment.
894    async fn classify_clusters(
895        &mut self,
896        allowed_lag: Timestamp,
897        cutoff: Timestamp,
898        now: Timestamp,
899        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
900        leader_unhydrated_collections: &BTreeSet<GlobalId>,
901        problematic_replicas: &BTreeSet<ReplicaId>,
902    ) -> BTreeMap<ClusterId, ClusterCaughtUpStatus> {
903        let mut result = BTreeMap::new();
904        for (&cluster_id, cluster) in &mut self.clusters {
905            let status = match cluster {
906                Ok(cluster) => {
907                    Self::collections_caught_up(
908                        cluster_id,
909                        cluster,
910                        allowed_lag.clone(),
911                        cutoff.clone(),
912                        now.clone(),
913                        live_frontiers,
914                        leader_unhydrated_collections,
915                        problematic_replicas,
916                    )
917                    .await
918                }
919                Err(error) => Err(anyhow::anyhow!("{error:#}")),
920            }
921            .unwrap_or_else(|e| {
922                tracing::error!(
923                    "unexpected error while checking if cluster {} caught up: {e:#}",
924                    cluster_id
925                );
926                ClusterCaughtUpStatus::NotCaughtUp
927            });
928
929            if status == ClusterCaughtUpStatus::NotCaughtUp {
930                // We log all non-caught-up clusters instead of breaking out early.
931                tracing::info!("cluster {} is not caught up", cluster_id);
932            }
933
934            result.insert(cluster_id, status);
935        }
936
937        result
938    }
939
940    /// Classifies the given cluster for the caught-up check.
941    ///
942    /// See [`Self::classify_clusters`] for details.
943    async fn collections_caught_up(
944        cluster_id: ClusterId,
945        cluster: &mut ClusterSnapshot,
946        allowed_lag: Timestamp,
947        cutoff: Timestamp,
948        now: Timestamp,
949        live_frontiers: &BTreeMap<GlobalId, Antichain<Timestamp>>,
950        leader_unhydrated_collections: &BTreeSet<GlobalId>,
951        problematic_replicas: &BTreeSet<ReplicaId>,
952    ) -> Result<ClusterCaughtUpStatus, anyhow::Error> {
953        if cluster.replicas.is_empty() {
954            return Ok(ClusterCaughtUpStatus::Ignored);
955        }
956
957        // Check if all replicas in this cluster are crash/OOM-looping. As long
958        // as there is at least one healthy replica, the cluster is okay-ish.
959        let cluster_has_only_problematic_replicas = cluster
960            .replicas
961            .keys()
962            .all(|id| problematic_replicas.contains(id));
963        // A cluster whose collections all exceed the cutoff must not wait on
964        // its compute controller.
965        let mut hydration = None;
966        let mut all_caught_up = true;
967        let mut ignored_compute_collections = cluster.ignored_compute_collections.clone();
968
969        for (id, write_frontier, collection_type) in &cluster.collections {
970            let id = *id;
971            if let Some(live_write_frontier) = live_frontiers.get(&id) {
972                // We can't do comparisons and subtractions, so we bump up the live
973                // write frontier by the cutoff, and then compare that against
974                // `now`.
975                let live_write_frontier_plus_cutoff = live_write_frontier
976                    .iter()
977                    .map(|t| t.step_forward_by(&cutoff));
978                let live_write_frontier_plus_cutoff =
979                    Antichain::from_iter(live_write_frontier_plus_cutoff);
980
981                let beyond_all_hope = live_write_frontier_plus_cutoff.less_equal(&now);
982
983                if beyond_all_hope && cluster_has_only_problematic_replicas {
984                    tracing::info!(
985                        ?live_write_frontier,
986                        ?cutoff,
987                        ?now,
988                        "live write frontier of collection {id} is too far behind 'now'"
989                    );
990                    tracing::info!(
991                        "ALL replicas of cluster {} are crash/OOM-looping and it has at least one \
992                     collection that is too far behind 'now'; ignoring cluster for caught-up \
993                     checks",
994                        cluster_id
995                    );
996                    return Ok(ClusterCaughtUpStatus::Ignored);
997                } else if beyond_all_hope {
998                    tracing::info!(
999                        ?live_write_frontier,
1000                        ?cutoff,
1001                        ?now,
1002                        "live write frontier of collection {id} is too far behind 'now'; \
1003                     ignoring for caught-up checks"
1004                    );
1005                    if matches!(collection_type, CollectionType::Compute) {
1006                        ignored_compute_collections.insert(id);
1007                    }
1008                    continue;
1009                }
1010            }
1011            let collection_hydrated = match collection_type {
1012                CollectionType::Compute => {
1013                    if hydration.is_none() {
1014                        hydration = Some((&mut cluster.compute_hydration).await?);
1015                    }
1016                    *hydration.as_ref().expect("hydration received")[&id]
1017                        .as_ref()
1018                        .map_err(|_| CollectionMissing(id))?
1019                }
1020                CollectionType::Storage { hydrated } => *hydrated,
1021            };
1022            let live_write_frontier = match live_frontiers.get(&id) {
1023                Some(frontier) => frontier,
1024                None => {
1025                    // No live frontier to compare against, either because the collection didn't
1026                    // exist on the leader or because the leader hosts it as something
1027                    // `mz_cluster_replica_frontiers` doesn't track. A table→MV conversion is the
1028                    // latter: it keeps the table's `GlobalId`, still a table on the leader, so the
1029                    // new MV lands here instead of the strong path below.
1030                    //
1031                    // Require hydration, not just a write frontier past the minimum. A fresh MV's
1032                    // sink reaches frontier 1 after one batch, which would otherwise look caught
1033                    // up mid-hydration and bring back the cut-over spike this gate prevents.
1034                    // Also require the frontier to be within the allowed lag, the bound the
1035                    // live-frontier path applies, with `now` standing in for the missing live
1036                    // frontier. Hydration is one-shot: a collection that hydrated and then stalled
1037                    // would otherwise satisfy this branch forever.
1038                    //
1039                    // NOTE: there is deliberately no `cutoff` escape hatch here. A frontier frozen
1040                    // at the minimum is exactly what this gate must catch, so a collection stuck
1041                    // here blocks promotion until `with_0dt_deployment_max_wait` elapses.
1042                    let readiness = CollectionReadiness::classify(
1043                        collection_hydrated,
1044                        write_frontier,
1045                        Some((&Antichain::from_elem(now), allowed_lag)),
1046                    );
1047
1048                    tracing::info!(
1049                        ?write_frontier,
1050                        ?readiness,
1051                        ?allowed_lag,
1052                        ?now,
1053                        "collection {id} not in live frontiers"
1054                    );
1055                    if write_frontier.less_equal(&Timestamp::minimum())
1056                        || readiness != CollectionReadiness::Ready
1057                    {
1058                        all_caught_up = false;
1059                    }
1060                    continue;
1061                }
1062            };
1063
1064            let unhydrated_on_leader = matches!(collection_type, CollectionType::Compute)
1065                && leader_unhydrated_collections.contains(&id);
1066            let readiness = CollectionReadiness::classify(
1067                collection_hydrated || unhydrated_on_leader,
1068                write_frontier,
1069                Some((live_write_frontier, allowed_lag)),
1070            );
1071
1072            // We don't expect collections to get hydrated, ingestions to be
1073            // started, etc. when they are already at the empty write frontier.
1074            if live_write_frontier.is_empty() || readiness == CollectionReadiness::Ready {
1075                // Carry hydration waivers into the per-replica stability check.
1076                if (readiness != CollectionReadiness::Ready || !collection_hydrated)
1077                    && matches!(collection_type, CollectionType::Compute)
1078                {
1079                    ignored_compute_collections.insert(id);
1080                }
1081                // This is a bit spammy, but log caught-up collections while we
1082                // investigate why environments are cutting over but then a lot
1083                // of compute collections are _not_ in fact hydrated on
1084                // clusters.
1085                tracing::info!(
1086                    %id,
1087                    ?readiness,
1088                    ?write_frontier,
1089                    ?live_write_frontier,
1090                    ?allowed_lag,
1091                    %cluster_id,
1092                    "collection is caught up");
1093            } else {
1094                // We are not within the allowed lag, or not hydrated!
1095                //
1096                // We continue with our loop instead of breaking out early, so
1097                // that we log all non-caught-up replicas.
1098                tracing::info!(
1099                    %id,
1100                    ?readiness,
1101                    ?write_frontier,
1102                    ?live_write_frontier,
1103                    ?allowed_lag,
1104                    %cluster_id,
1105                    "collection is not caught up"
1106                );
1107                all_caught_up = false;
1108            }
1109        }
1110
1111        Ok(if all_caught_up {
1112            ClusterCaughtUpStatus::CaughtUp {
1113                ignored_compute_collections,
1114            }
1115        } else {
1116            ClusterCaughtUpStatus::NotCaughtUp
1117        })
1118    }
1119}
1120
1121impl LeaderSnapshot {
1122    /// Analyzes replica status history to detect replicas that are
1123    /// crash-looping or OOM-looping.
1124    ///
1125    /// A replica is considered problematic if it has multiple OOM kills in a
1126    /// short-ish window.
1127    async fn analyze_replica_looping(
1128        catalog: &Catalog,
1129        storage_collections: &(dyn StorageCollections + Send + Sync),
1130        now: EpochMillis,
1131    ) -> Result<BTreeSet<ReplicaId>, anyhow::Error> {
1132        // Look back 1 day for patterns.
1133        let lookback_window: u64 = Duration::from_secs(24 * 60 * 60)
1134            .as_millis()
1135            .try_into()
1136            .expect("fits into u64");
1137        let min_timestamp = now.saturating_sub(lookback_window);
1138        let min_timestamp_dt = mz_ore::now::to_datetime(min_timestamp);
1139
1140        // Get the replica status collection GlobalId
1141        let replica_status_item_id =
1142            catalog.resolve_builtin_storage_collection(&MZ_CLUSTER_REPLICA_STATUS_HISTORY);
1143        let replica_status_gid = catalog
1144            .get_entry(&replica_status_item_id)
1145            .latest_global_id();
1146
1147        // Acquire a read hold to determine the as_of timestamp for snapshot_and_stream
1148        let read_holds = storage_collections
1149            .acquire_read_holds(vec![replica_status_gid])
1150            .context("cannot acquire read hold for mz_cluster_replica_status_history")?;
1151        let read_hold = if let Some(read_hold) = read_holds.into_iter().next() {
1152            read_hold
1153        } else {
1154            anyhow::bail!("mz_cluster_replica_status_history is not readable");
1155        };
1156
1157        let as_of = read_hold
1158            .since()
1159            .iter()
1160            .next()
1161            .cloned()
1162            .context("mz_cluster_replica_status_history is closed")?;
1163
1164        let mut replica_statuses_stream = storage_collections
1165            .snapshot_and_stream(replica_status_gid, as_of)
1166            .await
1167            .context("cannot read mz_cluster_replica_status_history")?;
1168
1169        let mut replica_problem_counts: BTreeMap<ReplicaId, u32> = BTreeMap::new();
1170
1171        while let Some((source_data, _ts, diff)) = replica_statuses_stream.next().await {
1172            // Only process inserts (positive diffs)
1173            if diff <= 0 {
1174                continue;
1175            }
1176
1177            // Extract the Row from SourceData
1178            let row = match source_data.0 {
1179                Ok(row) => row,
1180                Err(err) => {
1181                    // This builtin collection shouldn't have errors, so we at
1182                    // least log an error so that tests or sentry will notice.
1183                    tracing::error!(
1184                        collection = MZ_CLUSTER_REPLICA_STATUS_HISTORY.name,
1185                        ?err,
1186                        "unexpected error in builtin collection"
1187                    );
1188                    continue;
1189                }
1190            };
1191
1192            let mut iter = row.into_iter();
1193
1194            let replica_id: ReplicaId = iter
1195                .next()
1196                .expect("missing replica_id")
1197                .unwrap_str()
1198                .parse()
1199                .expect("must parse as replica ID");
1200            let _process_id = iter.next().expect("missing process_id").unwrap_uint64();
1201            let status = iter
1202                .next()
1203                .expect("missing status")
1204                .unwrap_str()
1205                .to_string();
1206            let reason_datum = iter.next().expect("missing reason");
1207            let reason = if reason_datum.is_null() {
1208                None
1209            } else {
1210                Some(reason_datum.unwrap_str().to_string())
1211            };
1212            let occurred_at = iter
1213                .next()
1214                .expect("missing occurred_at")
1215                .unwrap_timestamptz();
1216
1217            // Only consider events within the time window and that are problematic
1218            if occurred_at.naive_utc() >= min_timestamp_dt.naive_utc() {
1219                if Self::is_problematic_status(&status, reason.as_deref()) {
1220                    *replica_problem_counts.entry(replica_id).or_insert(0) += 1;
1221                }
1222            }
1223        }
1224
1225        // Filter to replicas with 3 or more problematic events.
1226        let result = replica_problem_counts
1227            .into_iter()
1228            .filter_map(|(replica_id, count)| {
1229                if count >= 3 {
1230                    tracing::info!(
1231                        "Detected problematic cluster replica {}: {} problematic events in last {:?}",
1232                        replica_id,
1233                        count,
1234                        Duration::from_millis(lookback_window)
1235                    );
1236                    Some(replica_id)
1237                } else {
1238                    None
1239                }
1240            })
1241            .collect();
1242
1243        // Explicitly keep the read hold alive until this point.
1244        drop(read_hold);
1245
1246        Ok(result)
1247    }
1248
1249    /// Determines if a replica status indicates a problematic state that could
1250    /// indicate looping.
1251    fn is_problematic_status(_status: &str, reason: Option<&str>) -> bool {
1252        // For now, we only look at the reason, but we could change/expand this
1253        // if/when needed.
1254        if let Some(reason) = reason {
1255            return reason == OfflineReason::OomKilled.to_string();
1256        }
1257
1258        false
1259    }
1260}
1261
1262/// The leader's hydration reports, read from its `mz_compute_hydration_times`.
1263#[derive(Debug, Default)]
1264struct LeaderHydration {
1265    hydrated_on_any_replica: BTreeSet<GlobalId>,
1266    /// (Collection ID, replica ID) pairs explicitly reported as unhydrated.
1267    unhydrated_replica_collections: BTreeSet<(GlobalId, String)>,
1268}
1269
1270impl LeaderHydration {
1271    fn from_rows(rows: Vec<Row>) -> Self {
1272        let mut leader = Self::default();
1273        for row in rows {
1274            let mut values = row.iter();
1275            let replica = values
1276                .next()
1277                .expect("missing replica_id")
1278                .unwrap_str()
1279                .to_owned();
1280            let id: GlobalId = values
1281                .next()
1282                .expect("missing object_id")
1283                .unwrap_str()
1284                .parse()
1285                .expect("valid object ID");
1286            if values.next().expect("missing time_ns").is_null() {
1287                leader.unhydrated_replica_collections.insert((id, replica));
1288            } else {
1289                leader.hydrated_on_any_replica.insert(id);
1290            }
1291        }
1292        leader
1293    }
1294
1295    /// Returns the collections that every frontier-hosting leader replica
1296    /// explicitly reports unhydrated.
1297    ///
1298    /// Missing reports or frontiers grant no waiver. A hydrated report from any
1299    /// replica vetoes the waiver, even if that replica has no frontier report.
1300    fn collections_unhydrated_on_all_hosting_replicas(
1301        &self,
1302        frontiers: &[(GlobalId, String, Antichain<Timestamp>)],
1303    ) -> BTreeSet<GlobalId> {
1304        let mut collections = BTreeMap::new();
1305        for (id, replica, _) in frontiers {
1306            let all_unhydrated = collections.entry(*id).or_insert(true);
1307            *all_unhydrated &= !self.hydrated_on_any_replica.contains(id)
1308                && self
1309                    .unhydrated_replica_collections
1310                    .contains(&(*id, replica.clone()));
1311        }
1312        collections
1313            .into_iter()
1314            .filter_map(|(id, unhydrated)| unhydrated.then_some(id))
1315            .collect()
1316    }
1317
1318    /// Returns whether leader replica `replica_id` explicitly reports at least
1319    /// one collection outside `ignored` as unhydrated.
1320    ///
1321    /// The leader snapshot has per-collection reports, not a separate replica
1322    /// hydration signal. The gate uses these as a proxy: a replica is fully
1323    /// hydrated only when all its non-ignored collections are hydrated. One
1324    /// unhydrated collection therefore waives the incoming replica's stability
1325    /// wait, since the leader lacks a fully hydrated counterpart. Missing
1326    /// reports grant no exemption.
1327    fn replica_has_unhydrated_collection(
1328        &self,
1329        replica_id: ReplicaId,
1330        ignored: &BTreeSet<GlobalId>,
1331    ) -> bool {
1332        let replica_id = replica_id.to_string();
1333        self.unhydrated_replica_collections
1334            .iter()
1335            .any(|(id, replica)| *replica == replica_id && !ignored.contains(id))
1336    }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341    use mz_repr::Datum;
1342    use mz_repr::adt::timestamp::CheckedTimestamp;
1343
1344    use super::*;
1345
1346    use ReplicaHealth::{Exempt, HealthySince, NotHydrated, NotOnline};
1347
1348    #[mz_ore::test(tokio::test)]
1349    async fn classification_waits_for_hydration_and_rejects_lost_responses() {
1350        use futures::poll;
1351        use std::task::Poll;
1352
1353        for response in [
1354            Some(Ok(true)),
1355            Some(Err(CollectionMissing(GlobalId::User(1)))),
1356            None,
1357        ] {
1358            let id = GlobalId::User(1);
1359            let (tx, rx) = oneshot::channel();
1360            let mut cluster = ClusterSnapshot {
1361                replicas: BTreeMap::from([(ReplicaId::User(1), ReplicaTarget::Ask)]),
1362                ignored_compute_collections: BTreeSet::new(),
1363                collections: vec![(
1364                    id,
1365                    Antichain::from_elem(100.into()),
1366                    CollectionType::Compute,
1367                )],
1368                compute_hydration: rx,
1369            };
1370            let frontiers = BTreeMap::from([(id, Antichain::from_elem(100.into()))]);
1371            let empty = BTreeSet::new();
1372            let problematic = BTreeSet::new();
1373            let check = CaughtUpCheck::collections_caught_up(
1374                ClusterId::User(1),
1375                &mut cluster,
1376                0.into(),
1377                1000.into(),
1378                100.into(),
1379                &frontiers,
1380                &empty,
1381                &problematic,
1382            );
1383            tokio::pin!(check);
1384            assert!(matches!(poll!(&mut check), Poll::Pending));
1385            let success = matches!(response, Some(Ok(true)));
1386            match response {
1387                Some(response) => tx
1388                    .send(BTreeMap::from([(id, response)]))
1389                    .expect("classification still holds the receiver"),
1390                None => drop(tx),
1391            }
1392            let result = check.await;
1393            if success {
1394                assert_eq!(
1395                    result.expect("hydration reply succeeds"),
1396                    ClusterCaughtUpStatus::CaughtUp {
1397                        ignored_compute_collections: BTreeSet::new(),
1398                    }
1399                );
1400            } else {
1401                assert!(
1402                    result.is_err(),
1403                    "missing hydration evidence must not authorize promotion"
1404                );
1405            }
1406        }
1407    }
1408
1409    #[mz_ore::test(tokio::test)]
1410    async fn cutoff_exemptions_do_not_wait_for_hydration() {
1411        use futures::FutureExt;
1412
1413        let id = GlobalId::User(1);
1414        let replica = ReplicaId::User(1);
1415        for problematic in [BTreeSet::new(), BTreeSet::from([replica])] {
1416            let (_tx, rx) = oneshot::channel();
1417            let mut cluster = ClusterSnapshot {
1418                replicas: BTreeMap::from([(replica, ReplicaTarget::Ask)]),
1419                ignored_compute_collections: BTreeSet::new(),
1420                collections: vec![(id, Antichain::from_elem(0.into()), CollectionType::Compute)],
1421                compute_hydration: rx,
1422            };
1423            let status = CaughtUpCheck::collections_caught_up(
1424                ClusterId::User(1),
1425                &mut cluster,
1426                0.into(),
1427                100.into(),
1428                100.into(),
1429                &BTreeMap::from([(id, Antichain::from_elem(0.into()))]),
1430                &BTreeSet::new(),
1431                &problematic,
1432            )
1433            .now_or_never()
1434            .expect("ignored collections must not wait for hydration")
1435            .expect("ignored collections need no hydration evidence");
1436            let expected = if problematic.is_empty() {
1437                ClusterCaughtUpStatus::CaughtUp {
1438                    ignored_compute_collections: BTreeSet::from([id]),
1439                }
1440            } else {
1441                ClusterCaughtUpStatus::Ignored
1442            };
1443            assert_eq!(status, expected);
1444        }
1445    }
1446
1447    #[mz_ore::test]
1448    fn collection_hydration_waiver_requires_complete_leader_reports() {
1449        let frontier = |id, replica: &str| {
1450            (
1451                GlobalId::User(id),
1452                replica.to_owned(),
1453                Antichain::from_elem(Timestamp::from(100)),
1454            )
1455        };
1456        let row = |id: &str, replica: &str, time| {
1457            Row::pack_slice(&[Datum::String(replica), Datum::String(id), time])
1458        };
1459        let frontiers = vec![
1460            frontier(1, "u10"),
1461            frontier(1, "u11"),
1462            frontier(2, "u10"),
1463            frontier(2, "u11"),
1464            frontier(3, "u10"),
1465            frontier(3, "u11"),
1466            frontier(4, "u10"),
1467            frontier(5, "u10"),
1468        ];
1469        let rows = vec![
1470            row("u1", "u10", Datum::Null),
1471            row("u1", "u11", Datum::Null),
1472            row("u2", "u10", Datum::Null),
1473            row("u2", "u11", Datum::UInt64(7)),
1474            row("u3", "u10", Datum::Null), // u11 has not reported.
1475            row("u4", "u10", Datum::Null),
1476            row("u4", "u12", Datum::UInt64(9)),
1477            // u5 has no hydration reports. u6 has no frontier.
1478            row("u6", "u10", Datum::Null),
1479        ];
1480        assert_eq!(
1481            LeaderHydration::from_rows(rows)
1482                .collections_unhydrated_on_all_hosting_replicas(&frontiers),
1483            BTreeSet::from([GlobalId::User(1)])
1484        );
1485        assert!(
1486            LeaderHydration::default()
1487                .collections_unhydrated_on_all_hosting_replicas(&frontiers)
1488                .is_empty()
1489        );
1490    }
1491
1492    #[mz_ore::test]
1493    fn replica_hydration_exemption_requires_an_unhydrated_collection_report() {
1494        let row = |id: &str, replica: &str, time| {
1495            Row::pack_slice(&[Datum::String(replica), Datum::String(id), time])
1496        };
1497        let leader = LeaderHydration::from_rows(vec![
1498            row("u1", "u10", Datum::UInt64(7)),
1499            row("u2", "u10", Datum::UInt64(7)),
1500            row("u1", "u11", Datum::UInt64(7)),
1501            row("u2", "u11", Datum::Null),
1502            row("u3", "u12", Datum::Null),
1503        ]);
1504        let none = BTreeSet::new();
1505        let replica = ReplicaId::User;
1506        assert!(
1507            !leader.replica_has_unhydrated_collection(replica(10), &none),
1508            "fully hydrated"
1509        );
1510        assert!(leader.replica_has_unhydrated_collection(replica(11), &none));
1511        assert!(
1512            !leader.replica_has_unhydrated_collection(
1513                replica(12),
1514                &BTreeSet::from([GlobalId::User(3)])
1515            ),
1516            "unhydrated only for an ignored collection"
1517        );
1518        assert!(
1519            !leader.replica_has_unhydrated_collection(replica(13), &none),
1520            "no reports"
1521        );
1522    }
1523
1524    #[mz_ore::test]
1525    fn stable_since_latest_replica() {
1526        let period_ms = 1000;
1527        let replicas = [
1528            (HealthySince(100), None),
1529            (HealthySince(500), None),
1530            (Exempt, None),
1531        ];
1532        let observation = observe_stability(&replicas, 1499, period_ms);
1533        assert_eq!(observation.stable_for_ms, Some(999));
1534        assert_eq!(observation.blocked_by, Some(StabilityBlocker::WithinPeriod));
1535        assert_eq!(
1536            observe_stability(&replicas, 1500, period_ms).blocked_by,
1537            None
1538        );
1539    }
1540
1541    #[mz_ore::test]
1542    fn every_replica_needs_evidence() {
1543        // `NotOnline` wins, because it is the more fundamental problem.
1544        for (replicas, blocker) in [
1545            (vec![], StabilityBlocker::NotOnline),
1546            (
1547                vec![HealthySince(0), NotOnline],
1548                StabilityBlocker::NotOnline,
1549            ),
1550            (vec![NotHydrated, NotOnline], StabilityBlocker::NotOnline),
1551            (
1552                vec![HealthySince(0), NotHydrated],
1553                StabilityBlocker::NotHydrated,
1554            ),
1555            (vec![Exempt, NotHydrated], StabilityBlocker::NotHydrated),
1556        ] {
1557            // Even a zero period requires evidence from every replica.
1558            let evidence = replicas.iter().map(|health| (*health, None)).collect_vec();
1559            let observation = observe_stability(&evidence, 10_000, 0);
1560            assert_eq!(observation.blocked_by, Some(blocker), "{replicas:?}");
1561            assert_eq!(observation.stable_for_ms, None, "{replicas:?}");
1562        }
1563    }
1564
1565    #[mz_ore::test]
1566    fn replica_clock_ahead_is_clamped() {
1567        let observation = observe_stability(&[(HealthySince(2000), None)], 1000, 0);
1568        assert_eq!(observation.stable_for_ms, Some(0));
1569        assert_eq!(observation.blocked_by, None);
1570    }
1571
1572    #[mz_ore::test]
1573    fn young_replica_has_a_fixed_deadline_until_it_rehydrates() {
1574        let evidence = [(HealthySince(1010), Some(1000))];
1575        assert_eq!(
1576            observe_stability(&evidence, 1019, 1000).blocked_by,
1577            Some(StabilityBlocker::WithinPeriod)
1578        );
1579        assert_eq!(observe_stability(&evidence, 1020, 1000).blocked_by, None);
1580
1581        let restarted = [(HealthySince(1030), Some(1000))];
1582        assert_eq!(
1583            observe_stability(&restarted, 1059, 1000).blocked_by,
1584            Some(StabilityBlocker::WithinPeriod)
1585        );
1586        assert_eq!(observe_stability(&restarted, 1060, 1000).blocked_by, None);
1587    }
1588
1589    #[mz_ore::test]
1590    fn age_cap_does_not_waive_other_replicas_or_missing_health() {
1591        let young = (HealthySince(1010), Some(1000));
1592        for other in [
1593            (HealthySince(1000), Some(0)),
1594            (HealthySince(1000), None),
1595            (HealthySince(1000), Some(1001)),
1596        ] {
1597            let replicas = [young, other];
1598            assert_eq!(
1599                observe_stability(&replicas, 1999, 1000).blocked_by,
1600                Some(StabilityBlocker::WithinPeriod)
1601            );
1602            assert_eq!(observe_stability(&replicas, 2000, 1000).blocked_by, None);
1603        }
1604        assert_eq!(
1605            observe_stability(&[young, (NotOnline, Some(1000))], 2000, 1000).blocked_by,
1606            Some(StabilityBlocker::NotOnline)
1607        );
1608        assert_eq!(
1609            observe_stability(&[young, (NotHydrated, Some(1000))], 2000, 1000).blocked_by,
1610            Some(StabilityBlocker::NotHydrated)
1611        );
1612        assert_eq!(
1613            observe_stability(&[(HealthySince(3000), Some(3001))], 2000, 1000).blocked_by,
1614            Some(StabilityBlocker::WithinPeriod)
1615        );
1616    }
1617
1618    #[mz_ore::test]
1619    fn hydration_answer_fails_closed() {
1620        assert_eq!(
1621            replica_health(&answer(false, Some(1000))),
1622            HealthySince(1000)
1623        );
1624        assert_eq!(
1625            replica_health(&answer(false, Some(-1))),
1626            NotHydrated,
1627            "pre-epoch time"
1628        );
1629        assert_eq!(
1630            replica_health(&answer(false, None)),
1631            NotHydrated,
1632            "an export is not hydrated on some worker"
1633        );
1634        assert_eq!(replica_health(&[]), NotHydrated, "nothing reported yet");
1635    }
1636
1637    #[mz_ore::test]
1638    fn replica_with_only_ignored_exports_is_exempt() {
1639        assert_eq!(replica_health(&answer(true, Some(1000))), Exempt);
1640        assert_eq!(replica_health(&answer(true, None)), Exempt);
1641    }
1642
1643    fn answer(ignored: bool, hydrated_at: Option<i64>) -> Vec<Row> {
1644        let hydrated_at = hydrated_at.map_or(Datum::Null, |millis| {
1645            let time = chrono::DateTime::from_timestamp_millis(millis).expect("valid time");
1646            Datum::TimestampTz(CheckedTimestamp::from_timestamplike(time).expect("in range"))
1647        });
1648        vec![Row::pack_slice(&[Datum::from(ignored), hydrated_at])]
1649    }
1650}