1use 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
88const STABILITY_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
93
94fn 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#[derive(Debug)]
122pub struct CaughtUpCheckContext {
123 pub trigger: Trigger,
126 pub exclude_collections: BTreeSet<GlobalId>,
131}
132
133#[derive(Debug)]
135pub struct CaughtUpCheckRequest {
136 exclude_collections: Arc<BTreeSet<GlobalId>>,
137 tx: oneshot::Sender<CaughtUpCheck>,
138}
139
140#[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#[derive(Debug)]
171struct CaughtUpSnapshot {
172 now: EpochMillis,
173 all_caught_up: bool,
175 stability_check_enabled: bool,
176 stability_period_ms: u64,
177 caught_up_clusters: BTreeMap<ClusterId, CaughtUpCluster>,
179}
180
181#[derive(Debug)]
183struct CaughtUpCluster {
184 ignored_compute_collections: BTreeSet<GlobalId>,
187 replicas: BTreeMap<ReplicaId, ReplicaTarget>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ReplicaTarget {
194 NotOnline,
196 Exempt,
198 Ask,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204enum ClusterCaughtUpStatus {
205 CaughtUp {
208 ignored_compute_collections: BTreeSet<GlobalId>,
211 },
212 Ignored,
217 NotCaughtUp,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum ReplicaHealth {
224 NotOnline,
226 NotHydrated,
229 HealthySince(EpochMillis),
231 Exempt,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238enum StabilityBlocker {
239 NotOnline,
241 NotHydrated,
243 WithinPeriod,
245}
246
247#[derive(Debug, Clone, Copy)]
249struct StabilityObservation {
250 stable_for_ms: Option<u64>,
253 blocked_by: Option<StabilityBlocker>,
255}
256
257fn 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 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
307struct StabilityGate {
310 client: PeekClient,
311 replica_created_at: BTreeMap<ReplicaId, EpochMillis>,
312}
313
314impl StabilityGate {
315 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 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 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 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
469fn replica_health(rows: &[Row]) -> ReplicaHealth {
471 let Some(row) = rows.first() else {
472 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 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 u64::try_from(millis).map_or(ReplicaHealth::NotHydrated, ReplicaHealth::HealthySince)
490}
491
492impl Coordinator {
493 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 if let Some(delay) = delay {
523 tokio::time::sleep(delay).await;
524 }
525 delay = Some(period);
526 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 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 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 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 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 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 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 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 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 tracing::info!("cluster {} is not caught up", cluster_id);
932 }
933
934 result.insert(cluster_id, status);
935 }
936
937 result
938 }
939
940 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 let cluster_has_only_problematic_replicas = cluster
960 .replicas
961 .keys()
962 .all(|id| problematic_replicas.contains(id));
963 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 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 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 if live_write_frontier.is_empty() || readiness == CollectionReadiness::Ready {
1075 if (readiness != CollectionReadiness::Ready || !collection_hydrated)
1077 && matches!(collection_type, CollectionType::Compute)
1078 {
1079 ignored_compute_collections.insert(id);
1080 }
1081 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 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 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 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 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 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 if diff <= 0 {
1174 continue;
1175 }
1176
1177 let row = match source_data.0 {
1179 Ok(row) => row,
1180 Err(err) => {
1181 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 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 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 drop(read_hold);
1245
1246 Ok(result)
1247 }
1248
1249 fn is_problematic_status(_status: &str, reason: Option<&str>) -> bool {
1252 if let Some(reason) = reason {
1255 return reason == OfflineReason::OomKilled.to_string();
1256 }
1257
1258 false
1259 }
1260}
1261
1262#[derive(Debug, Default)]
1264struct LeaderHydration {
1265 hydrated_on_any_replica: BTreeSet<GlobalId>,
1266 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 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 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), row("u4", "u10", Datum::Null),
1476 row("u4", "u12", Datum::UInt64(9)),
1477 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 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 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}