Skip to main content

mz_adapter/coord/
cluster_scheduling.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
10use itertools::Itertools;
11use mz_adapter_types::dyncfgs::ENABLE_CLUSTER_CONTROLLER;
12use mz_audit_log::SchedulingDecisionsWithReasonsV2;
13use mz_catalog::memory::objects::{CatalogItem, ClusterVariant, ClusterVariantManaged};
14use mz_controller_types::ClusterId;
15use mz_ore::collections::CollectionExt;
16use mz_ore::{soft_assert_or_log, soft_panic_or_log};
17use mz_repr::adt::interval::Interval;
18use mz_repr::{GlobalId, TimestampManipulation};
19use mz_sql::catalog::CatalogCluster;
20use mz_sql::plan::{AlterClusterPlanStrategy, ClusterSchedule};
21use std::time::{Duration, Instant};
22use tracing::{debug, warn};
23
24use crate::AdapterError;
25use crate::coord::sequencer::cancel_carried_reconfiguration;
26use crate::coord::{Coordinator, Message};
27
28const POLICIES: &[&str] = &[REFRESH_POLICY_NAME];
29
30const REFRESH_POLICY_NAME: &str = "refresh";
31
32/// A policy's decision for whether it wants a certain cluster to be On, along with its reason.
33/// (Among the reasons there can be settings of the policy as well as other information about the
34/// state of the system.)
35#[derive(Clone, Debug)]
36pub enum SchedulingDecision {
37    /// The reason for the refresh policy for wanting to turn a cluster On or Off.
38    Refresh(RefreshDecision),
39}
40
41impl SchedulingDecision {
42    /// Extract the On/Off decision from the policy-specific structs.
43    pub fn cluster_on(&self) -> bool {
44        match &self {
45            SchedulingDecision::Refresh(RefreshDecision { cluster_on, .. }) => cluster_on.clone(),
46        }
47    }
48}
49
50#[derive(Clone, Debug)]
51pub struct RefreshDecision {
52    /// Whether the ON REFRESH policy wants a certain cluster to be On.
53    cluster_on: bool,
54    /// Objects that currently need a refresh on the cluster (taking into account the rehydration
55    /// time estimate), and therefore should keep the cluster On.
56    objects_needing_refresh: Vec<GlobalId>,
57    /// Objects for which we estimate that they currently need Persist compaction, and therefore
58    /// should keep the cluster On.
59    objects_needing_compaction: Vec<GlobalId>,
60    /// The HYDRATION TIME ESTIMATE setting of the cluster.
61    hydration_time_estimate: Duration,
62}
63
64impl SchedulingDecision {
65    pub fn reasons_to_audit_log_reasons<'a, I>(reasons: I) -> SchedulingDecisionsWithReasonsV2
66    where
67        I: IntoIterator<Item = &'a SchedulingDecision>,
68    {
69        SchedulingDecisionsWithReasonsV2 {
70            on_refresh: reasons
71                .into_iter()
72                .filter_map(|r| match r {
73                    SchedulingDecision::Refresh(RefreshDecision {
74                        cluster_on,
75                        objects_needing_refresh,
76                        objects_needing_compaction,
77                        hydration_time_estimate,
78                    }) => {
79                        soft_assert_or_log!(
80                            !cluster_on
81                                || !objects_needing_refresh.is_empty()
82                                || !objects_needing_compaction.is_empty(),
83                            "`cluster_on = true` should have an explanation"
84                        );
85                        let mut hydration_time_estimate_str = String::new();
86                        mz_repr::strconv::format_interval(
87                            &mut hydration_time_estimate_str,
88                            Interval::from_duration(hydration_time_estimate).expect(
89                                "planning ensured that this is convertible back to Interval",
90                            ),
91                        );
92                        Some(mz_audit_log::RefreshDecisionWithReasonV2 {
93                            decision: (*cluster_on).into(),
94                            objects_needing_refresh: objects_needing_refresh
95                                .iter()
96                                .map(|id| id.to_string())
97                                .collect(),
98                            objects_needing_compaction: objects_needing_compaction
99                                .iter()
100                                .map(|id| id.to_string())
101                                .collect(),
102                            hydration_time_estimate: hydration_time_estimate_str,
103                        })
104                    }
105                })
106                .into_element(), // Each policy should have exactly one opinion on each cluster.
107        }
108    }
109}
110
111impl Coordinator {
112    #[mz_ore::instrument(level = "debug")]
113    /// Call each scheduling policy.
114    ///
115    /// No-ops when the cluster controller owns the replica set
116    /// ([`ENABLE_CLUSTER_CONTROLLER`]): the controller's `OnRefreshStrategy` is
117    /// then the sole authority over scheduled clusters, so the legacy policy must
118    /// not also toggle their replication factor (two writers of the replica set is
119    /// not allowed). The legacy path remains in place to drive scheduling while the
120    /// gate is off.
121    pub(crate) async fn check_scheduling_policies(&self) {
122        if ENABLE_CLUSTER_CONTROLLER.get(self.catalog().system_config().dyncfgs()) {
123            return;
124        }
125        // (So far, we have only this one policy.)
126        self.check_refresh_policy();
127    }
128
129    /// Runs the `SCHEDULE = ON REFRESH` cluster scheduling policy, which makes cluster On/Off
130    /// decisions based on REFRESH materialized view write frontiers and the current time (the local
131    /// oracle read ts), and sends `Message::SchedulingDecisions` with these decisions.
132    /// (Queries the timestamp oracle on a background task.)
133    fn check_refresh_policy(&self) {
134        let start_time = Instant::now();
135
136        // Collect information about REFRESH MVs:
137        // - cluster
138        // - hydration_time_estimate of the cluster
139        // - MV's id
140        // - MV's write frontier
141        // - MV's refresh schedule
142        let mut refresh_mv_infos = Vec::new();
143        for cluster in self.catalog().clusters() {
144            if let ClusterVariant::Managed(ref config) = cluster.config.variant {
145                match config.schedule {
146                    ClusterSchedule::Manual => {
147                        // Nothing to do, user manages this cluster manually.
148                    }
149                    ClusterSchedule::Refresh {
150                        hydration_time_estimate,
151                    } => {
152                        let mvs = cluster
153                            .bound_objects()
154                            .iter()
155                            .filter_map(|id| {
156                                if let CatalogItem::MaterializedView(mv) =
157                                    self.catalog().get_entry(id).item()
158                                {
159                                    mv.refresh_schedule.clone().map(|refresh_schedule| {
160                                        let (_since, write_frontier) = self
161                                            .controller
162                                            .storage
163                                            .collection_frontiers(mv.global_id_writes())
164                                            .expect("the storage controller should know about MVs that exist in the catalog");
165                                        (mv.global_id_writes(), write_frontier, refresh_schedule)
166                                    })
167                                } else {
168                                    None
169                                }
170                            })
171                            .collect_vec();
172                        debug!(%cluster.id, ?refresh_mv_infos, "check_refresh_policy");
173                        refresh_mv_infos.push((cluster.id, hydration_time_estimate, mvs));
174                    }
175                }
176            }
177        }
178
179        // Spawn a background task that queries the timestamp oracle for the current read timestamp,
180        // compares this ts with the REFRESH MV write frontiers, thus making On/Off decisions per
181        // cluster, and sends a `Message::SchedulingDecisions` with these decisions.
182        let ts_oracle = self.get_local_timestamp_oracle();
183        let internal_cmd_tx = self.internal_cmd_tx.clone();
184        let check_scheduling_policies_seconds_cloned =
185            self.metrics.check_scheduling_policies_seconds.clone();
186        let compaction_estimate = self
187            .catalog()
188            .system_config()
189            .cluster_refresh_mv_compaction_estimate()
190            .try_into()
191            .expect("should be configured to a reasonable value");
192        mz_ore::task::spawn(|| "refresh policy get ts and make decisions", async move {
193            let task_start_time = Instant::now();
194            let local_read_ts = ts_oracle.read_ts().await;
195            debug!(%local_read_ts, ?refresh_mv_infos, "check_refresh_policy background task");
196            let decisions = refresh_mv_infos
197                .into_iter()
198                .map(|(cluster_id, hydration_time_estimate, refresh_mv_info)| {
199                    // 1. check that
200                    // write_frontier < local_read_ts + hydration_time_estimate
201                    let hydration_estimate = &hydration_time_estimate
202                        .try_into()
203                        .expect("checked during planning");
204                    let local_read_ts_adjusted = local_read_ts.step_forward_by(hydration_estimate);
205                    let mvs_needing_refresh = refresh_mv_info
206                        .iter()
207                        .cloned()
208                        .filter_map(|(id, frontier, _refresh_schedule)| {
209                            if frontier.less_than(&local_read_ts_adjusted) {
210                                Some(id)
211                            } else {
212                                None
213                            }
214                        })
215                        .collect_vec();
216
217                    // 2. check that
218                    // prev_refresh + compaction_estimate > local_read_ts
219                    let mvs_needing_compaction = refresh_mv_info
220                        .into_iter()
221                        .filter_map(|(id, frontier, refresh_schedule)| {
222                            let frontier = frontier.as_option();
223                            // `prev_refresh` will be None in two cases:
224                            // 1. When there is no previous refresh, because we haven't yet had
225                            // the first refresh. In this case, there is no need to schedule
226                            // time now for compaction.
227                            // 2. In the niche case where a `REFRESH EVERY` MV's write frontier
228                            // is empty. In this case, it's not impossible that there would be a
229                            // need for compaction. But I can't see any easy way to correctly
230                            // handle this case, because we don't have any info handy about when
231                            // the last refresh happened in wall clock time, because the
232                            // frontiers have no relation to wall clock time. So, we'll not
233                            // schedule any compaction time.
234                            // (Note that `REFRESH AT` MVs with empty frontiers, which is a more
235                            // common case, are fine, because `last_refresh` will return
236                            // Some(...) for them.)
237                            let prev_refresh = match frontier {
238                                Some(frontier) => frontier.round_down_minus_1(&refresh_schedule),
239                                None => refresh_schedule.last_refresh(),
240                            };
241                            prev_refresh
242                                .map(|prev_refresh| {
243                                    if prev_refresh.step_forward_by(&compaction_estimate)
244                                        > local_read_ts
245                                    {
246                                        Some(id)
247                                    } else {
248                                        None
249                                    }
250                                })
251                                .flatten()
252                        })
253                        .collect_vec();
254
255                    let cluster_on =
256                        !mvs_needing_refresh.is_empty() || !mvs_needing_compaction.is_empty();
257                    (
258                        cluster_id,
259                        SchedulingDecision::Refresh(RefreshDecision {
260                            cluster_on,
261                            objects_needing_refresh: mvs_needing_refresh,
262                            objects_needing_compaction: mvs_needing_compaction,
263                            hydration_time_estimate,
264                        }),
265                    )
266                })
267                .collect();
268            if let Err(e) = internal_cmd_tx.send(Message::SchedulingDecisions(vec![(
269                REFRESH_POLICY_NAME,
270                decisions,
271            )])) {
272                // It is not an error for this task to be running after `internal_cmd_rx` is dropped.
273                warn!("internal_cmd_rx dropped before we could send: {:?}", e);
274            }
275            check_scheduling_policies_seconds_cloned
276                .with_label_values(&[REFRESH_POLICY_NAME, "background"])
277                .observe((Instant::now() - task_start_time).as_secs_f64());
278        });
279
280        self.metrics
281            .check_scheduling_policies_seconds
282            .with_label_values(&[REFRESH_POLICY_NAME, "main"])
283            .observe((Instant::now() - start_time).as_secs_f64());
284    }
285
286    /// Handles `SchedulingDecisions`:
287    /// 1. Adds the newly made decisions to `cluster_scheduling_decisions`.
288    /// 2. Cleans up old decisions that are for clusters no longer in scope of automated scheduling
289    ///   decisions.
290    /// 3. For each cluster, it sums up `cluster_scheduling_decisions`, checks the summed up decision
291    ///   against the cluster state, and turns cluster On/Off if needed.
292    #[mz_ore::instrument(level = "debug")]
293    pub(crate) async fn handle_scheduling_decisions(
294        &mut self,
295        decisions: Vec<(&'static str, Vec<(ClusterId, SchedulingDecision)>)>,
296    ) {
297        // When the cluster controller owns the replica set it is the sole writer
298        // for scheduled clusters. Drop any legacy decisions still in flight from a
299        // background task spawned before the gate flipped on, so the two never
300        // contend. (`check_scheduling_policies` already stops spawning new ones.)
301        if ENABLE_CLUSTER_CONTROLLER.get(self.catalog().system_config().dyncfgs()) {
302            return;
303        }
304
305        let start_time = Instant::now();
306
307        // 1. Add the received decisions to `cluster_scheduling_decisions`.
308        for (policy_name, decisions) in decisions.iter() {
309            for (cluster_id, decision) in decisions {
310                self.cluster_scheduling_decisions
311                    .entry(*cluster_id)
312                    .or_insert_with(Default::default)
313                    .insert(policy_name, decision.clone());
314            }
315        }
316
317        // 2. Clean up those clusters from `scheduling_decisions` that
318        // - have been dropped, or
319        // - were switched to unmanaged, or
320        // - were switched to `SCHEDULE = MANUAL`.
321        for cluster_id in self
322            .cluster_scheduling_decisions
323            .keys()
324            .cloned()
325            .collect_vec()
326        {
327            match self.get_managed_cluster_config(cluster_id) {
328                None => {
329                    // Cluster have been dropped or switched to unmanaged.
330                    debug!(
331                        "handle_scheduling_decisions: \
332                        Removing cluster {} from cluster_scheduling_decisions, \
333                        because get_managed_cluster_config returned None",
334                        cluster_id
335                    );
336                    self.cluster_scheduling_decisions.remove(&cluster_id);
337                }
338                Some(managed_config) => {
339                    if matches!(managed_config.schedule, ClusterSchedule::Manual) {
340                        debug!(
341                            "handle_scheduling_decisions: \
342                            Removing cluster {} from cluster_scheduling_decisions, \
343                            because schedule is Manual",
344                            cluster_id
345                        );
346                        self.cluster_scheduling_decisions.remove(&cluster_id);
347                    }
348                }
349            }
350        }
351
352        // 3. Act on `scheduling_decisions` where needed.
353        let mut altered_a_cluster = false;
354        for (cluster_id, decisions) in self.cluster_scheduling_decisions.clone() {
355            // We touch a cluster only when all policies have made a decision about it. This is
356            // to ensure that after an envd restart all policies have a chance to run at least once
357            // before we turn off a cluster, to avoid spuriously turning off a cluster and possibly
358            // losing a hydrated state.
359            if POLICIES.iter().all(|policy| decisions.contains_key(policy)) {
360                // Check whether the cluster's state matches the needed state.
361                // If any policy says On, then we need a replica.
362                let needs_replica = decisions
363                    .values()
364                    .map(|decision| decision.cluster_on())
365                    .contains(&true);
366                let cluster = self.catalog().get_cluster(cluster_id);
367                let cluster_name = cluster.name().to_string();
368                let cluster_config = cluster.config.clone();
369                // NOTE: the durable replication factor is not a reliable
370                // on/off signal here. The cluster controller runs a scheduled
371                // cluster's replica while holding the factor at 0, so after a
372                // controller gate-off the factor can disagree with the replica
373                // set that actually exists. Decide from the physical replicas,
374                // with the same exclusions as the controller's ownership test
375                // (`ObservedReplica::owned_shape`): internal and billed-as
376                // replicas are manually managed, pending ones belong to an
377                // in-flight reconfiguration. In a pure legacy world the factor
378                // and the replica set always agree, so both signals give the
379                // same answer there.
380                let owned_replicas: Vec<_> = cluster
381                    .replicas()
382                    .filter(|r| {
383                        !r.config.location.internal()
384                            && r.config.location.billed_as().is_none()
385                            && !r.config.location.pending()
386                    })
387                    .map(|r| r.replica_id)
388                    .collect();
389                let has_pending_replica = cluster.replicas().any(|r| r.config.location.pending());
390                let mut new_config = cluster_config.clone();
391                let ClusterVariant::Managed(managed_config) = &mut new_config.variant else {
392                    panic!("cleaned up unmanaged clusters above");
393                };
394                let replication_factor = managed_config.replication_factor;
395                let has_replica = !owned_replicas.is_empty(); // Is it On?
396                let reason = crate::catalog::ReplicaCreateDropReason::ClusterScheduling(
397                    decisions.values().cloned().collect(),
398                );
399                if has_pending_replica {
400                    // A graceful reconfiguration owns the replica set until it
401                    // finalizes. The turn-on alter below would reject this
402                    // case itself, the direct drop and adopt paths must not
403                    // race the finalization either. This covers only legacy
404                    // `-pending` replicas: controller-created overlap replicas
405                    // are not pending and are handled by the adopt and
406                    // turn-off branches instead.
407                    debug!(
408                        "handle_scheduling_decisions skipped cluster {} because it is \
409                        undergoing a graceful reconfiguration",
410                        cluster_id
411                    );
412                } else if needs_replica && !has_replica {
413                    // Turn the cluster On.
414                    altered_a_cluster = true;
415                    managed_config.replication_factor = 1;
416                    if let Err(e) = self
417                        .sequence_alter_cluster_managed_to_managed(
418                            None,
419                            cluster_id,
420                            new_config.clone(),
421                            reason,
422                            AlterClusterPlanStrategy::None,
423                        )
424                        .await
425                    {
426                        if let AdapterError::AlterClusterWhilePendingReplicas = e {
427                            debug!(
428                                "handle_scheduling_decisions tried to alter a cluster that is undergoing a graceful reconfiguration"
429                            );
430                        } else {
431                            soft_panic_or_log!(
432                                "handle_scheduling_decisions couldn't alter cluster {}. \
433                                 Old config: {:?}, \
434                                 New config: {:?}, \
435                                 Error: {}",
436                                cluster_id,
437                                cluster_config,
438                                new_config,
439                                e
440                            );
441                        }
442                    }
443                } else if !needs_replica && has_replica {
444                    // Turn the cluster Off. Drop the replicas by id rather
445                    // than altering the factor down: a replica handed over by
446                    // the controller exists while the factor is already 0 (an
447                    // alter to 0 would be a no-op there), and it may not sit
448                    // at the canonical `r<N>` name a factor-derived drop
449                    // would look for.
450                    altered_a_cluster = true;
451                    let drops = owned_replicas
452                        .into_iter()
453                        .map(|replica_id| {
454                            crate::catalog::DropObjectInfo::ClusterReplica((
455                                cluster_id,
456                                replica_id,
457                                reason.clone(),
458                            ))
459                        })
460                        .collect();
461                    managed_config.replication_factor = 0;
462                    let reconfiguration_audit = cancel_carried_reconfiguration(&mut new_config);
463                    let mut ops = vec![crate::catalog::Op::DropObjects(drops)];
464                    // After a controller handoff the factor is already 0 and
465                    // there is usually no record to retire, so the config
466                    // write would be a no-op. Push it only when something
467                    // actually changed.
468                    if new_config != cluster_config || reconfiguration_audit.is_some() {
469                        ops.push(crate::catalog::Op::UpdateClusterConfig {
470                            id: cluster_id,
471                            name: cluster_name,
472                            config: new_config.clone(),
473                            reconfiguration_audit,
474                            burst_audit: None,
475                        });
476                    }
477                    if let Err(e) = self.catalog_transact(None, ops).await {
478                        soft_panic_or_log!(
479                            "handle_scheduling_decisions couldn't turn off cluster {}. \
480                             Old config: {:?}, \
481                             New config: {:?}, \
482                             Error: {}",
483                            cluster_id,
484                            cluster_config,
485                            new_config,
486                            e
487                        );
488                    }
489                } else if needs_replica && replication_factor == 0 {
490                    // The controller left in-window replicas behind on
491                    // gate-off (`has_replica` is true here). Adopt exactly
492                    // one: the scheduled-cluster invariant caps the factor at
493                    // 1 (the planner refuses higher, and `unplan` asserts it),
494                    // so the lowest-id replica is kept, the factor is aligned
495                    // with it so later decisions and user `ALTER`s see a
496                    // consistent on-state, and any surplus is retired in the
497                    // same transaction. Surplus replicas are possible when a
498                    // pre-schedule reconfiguration's overlap replica was live
499                    // at gate-off, those are not marked pending. Nothing is
500                    // created.
501                    altered_a_cluster = true;
502                    let mut owned_replicas = owned_replicas;
503                    owned_replicas.sort_unstable();
504                    let surplus = owned_replicas.split_off(1);
505                    managed_config.replication_factor = 1;
506                    let reconfiguration_audit = cancel_carried_reconfiguration(&mut new_config);
507                    let mut ops = Vec::new();
508                    if !surplus.is_empty() {
509                        let drops = surplus
510                            .into_iter()
511                            .map(|replica_id| {
512                                crate::catalog::DropObjectInfo::ClusterReplica((
513                                    cluster_id,
514                                    replica_id,
515                                    reason.clone(),
516                                ))
517                            })
518                            .collect();
519                        ops.push(crate::catalog::Op::DropObjects(drops));
520                    }
521                    ops.push(crate::catalog::Op::UpdateClusterConfig {
522                        id: cluster_id,
523                        name: cluster_name,
524                        config: new_config.clone(),
525                        reconfiguration_audit,
526                        burst_audit: None,
527                    });
528                    if let Err(e) = self.catalog_transact(None, ops).await {
529                        soft_panic_or_log!(
530                            "handle_scheduling_decisions couldn't adopt replicas of cluster {}. \
531                             Old config: {:?}, \
532                             New config: {:?}, \
533                             Error: {}",
534                            cluster_id,
535                            cluster_config,
536                            new_config,
537                            e
538                        );
539                    }
540                }
541            } else {
542                debug!(
543                    "handle_scheduling_decisions: \
544                    Not all policies have made a decision about cluster {}. decisions: {:?}",
545                    cluster_id, decisions,
546                );
547            }
548        }
549
550        self.metrics
551            .handle_scheduling_decisions_seconds
552            .with_label_values(&[altered_a_cluster.to_string().as_str()])
553            .observe((Instant::now() - start_time).as_secs_f64());
554    }
555
556    /// Returns the managed config for a cluster. Returns None if the cluster doesn't exist or if
557    /// it's an unmanaged cluster.
558    fn get_managed_cluster_config(&self, cluster_id: ClusterId) -> Option<ClusterVariantManaged> {
559        let cluster = self.catalog().try_get_cluster(cluster_id)?;
560        if let ClusterVariant::Managed(managed_config) = cluster.config.variant.clone() {
561            Some(managed_config)
562        } else {
563            None
564        }
565    }
566}