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::{Coordinator, Message};
26
27const POLICIES: &[&str] = &[REFRESH_POLICY_NAME];
28
29const REFRESH_POLICY_NAME: &str = "refresh";
30
31/// A policy's decision for whether it wants a certain cluster to be On, along with its reason.
32/// (Among the reasons there can be settings of the policy as well as other information about the
33/// state of the system.)
34#[derive(Clone, Debug)]
35pub enum SchedulingDecision {
36    /// The reason for the refresh policy for wanting to turn a cluster On or Off.
37    Refresh(RefreshDecision),
38}
39
40impl SchedulingDecision {
41    /// Extract the On/Off decision from the policy-specific structs.
42    pub fn cluster_on(&self) -> bool {
43        match &self {
44            SchedulingDecision::Refresh(RefreshDecision { cluster_on, .. }) => cluster_on.clone(),
45        }
46    }
47}
48
49#[derive(Clone, Debug)]
50pub struct RefreshDecision {
51    /// Whether the ON REFRESH policy wants a certain cluster to be On.
52    cluster_on: bool,
53    /// Objects that currently need a refresh on the cluster (taking into account the rehydration
54    /// time estimate), and therefore should keep the cluster On.
55    objects_needing_refresh: Vec<GlobalId>,
56    /// Objects for which we estimate that they currently need Persist compaction, and therefore
57    /// should keep the cluster On.
58    objects_needing_compaction: Vec<GlobalId>,
59    /// The HYDRATION TIME ESTIMATE setting of the cluster.
60    hydration_time_estimate: Duration,
61}
62
63impl SchedulingDecision {
64    pub fn reasons_to_audit_log_reasons<'a, I>(reasons: I) -> SchedulingDecisionsWithReasonsV2
65    where
66        I: IntoIterator<Item = &'a SchedulingDecision>,
67    {
68        SchedulingDecisionsWithReasonsV2 {
69            on_refresh: reasons
70                .into_iter()
71                .filter_map(|r| match r {
72                    SchedulingDecision::Refresh(RefreshDecision {
73                        cluster_on,
74                        objects_needing_refresh,
75                        objects_needing_compaction,
76                        hydration_time_estimate,
77                    }) => {
78                        soft_assert_or_log!(
79                            !cluster_on
80                                || !objects_needing_refresh.is_empty()
81                                || !objects_needing_compaction.is_empty(),
82                            "`cluster_on = true` should have an explanation"
83                        );
84                        let mut hydration_time_estimate_str = String::new();
85                        mz_repr::strconv::format_interval(
86                            &mut hydration_time_estimate_str,
87                            Interval::from_duration(hydration_time_estimate).expect(
88                                "planning ensured that this is convertible back to Interval",
89                            ),
90                        );
91                        Some(mz_audit_log::RefreshDecisionWithReasonV2 {
92                            decision: (*cluster_on).into(),
93                            objects_needing_refresh: objects_needing_refresh
94                                .iter()
95                                .map(|id| id.to_string())
96                                .collect(),
97                            objects_needing_compaction: objects_needing_compaction
98                                .iter()
99                                .map(|id| id.to_string())
100                                .collect(),
101                            hydration_time_estimate: hydration_time_estimate_str,
102                        })
103                    }
104                })
105                .into_element(), // Each policy should have exactly one opinion on each cluster.
106        }
107    }
108}
109
110impl Coordinator {
111    #[mz_ore::instrument(level = "debug")]
112    /// Call each scheduling policy.
113    pub(crate) async fn check_scheduling_policies(&self) {
114        // (So far, we have only this one policy.)
115        self.check_refresh_policy();
116    }
117
118    /// Runs the `SCHEDULE = ON REFRESH` cluster scheduling policy, which makes cluster On/Off
119    /// decisions based on REFRESH materialized view write frontiers and the current time (the local
120    /// oracle read ts), and sends `Message::SchedulingDecisions` with these decisions.
121    /// (Queries the timestamp oracle on a background task.)
122    fn check_refresh_policy(&self) {
123        let start_time = Instant::now();
124
125        // Collect information about REFRESH MVs:
126        // - cluster
127        // - hydration_time_estimate of the cluster
128        // - MV's id
129        // - MV's write frontier
130        // - MV's refresh schedule
131        let mut refresh_mv_infos = Vec::new();
132        for cluster in self.catalog().clusters() {
133            if let ClusterVariant::Managed(ref config) = cluster.config.variant {
134                match config.schedule {
135                    ClusterSchedule::Manual => {
136                        // Nothing to do, user manages this cluster manually.
137                    }
138                    ClusterSchedule::Refresh {
139                        hydration_time_estimate,
140                    } => {
141                        let mvs = cluster
142                            .bound_objects()
143                            .iter()
144                            .filter_map(|id| {
145                                if let CatalogItem::MaterializedView(mv) =
146                                    self.catalog().get_entry(id).item()
147                                {
148                                    mv.refresh_schedule.clone().map(|refresh_schedule| {
149                                        let (_since, write_frontier) = self
150                                            .controller
151                                            .storage
152                                            .collection_frontiers(mv.global_id_writes())
153                                            .expect("the storage controller should know about MVs that exist in the catalog");
154                                        (mv.global_id_writes(), write_frontier, refresh_schedule)
155                                    })
156                                } else {
157                                    None
158                                }
159                            })
160                            .collect_vec();
161                        debug!(%cluster.id, ?refresh_mv_infos, "check_refresh_policy");
162                        refresh_mv_infos.push((cluster.id, hydration_time_estimate, mvs));
163                    }
164                }
165            }
166        }
167
168        // Spawn a background task that queries the timestamp oracle for the current read timestamp,
169        // compares this ts with the REFRESH MV write frontiers, thus making On/Off decisions per
170        // cluster, and sends a `Message::SchedulingDecisions` with these decisions.
171        let ts_oracle = self.get_local_timestamp_oracle();
172        let internal_cmd_tx = self.internal_cmd_tx.clone();
173        let check_scheduling_policies_seconds_cloned =
174            self.metrics.check_scheduling_policies_seconds.clone();
175        let compaction_estimate = self
176            .catalog()
177            .system_config()
178            .cluster_refresh_mv_compaction_estimate()
179            .try_into()
180            .expect("should be configured to a reasonable value");
181        mz_ore::task::spawn(|| "refresh policy get ts and make decisions", async move {
182            let task_start_time = Instant::now();
183            let local_read_ts = ts_oracle.read_ts().await;
184            debug!(%local_read_ts, ?refresh_mv_infos, "check_refresh_policy background task");
185            let decisions = refresh_mv_infos
186                .into_iter()
187                .map(|(cluster_id, hydration_time_estimate, refresh_mv_info)| {
188                    // 1. check that
189                    // write_frontier < local_read_ts + hydration_time_estimate
190                    let hydration_estimate = &hydration_time_estimate
191                        .try_into()
192                        .expect("checked during planning");
193                    let local_read_ts_adjusted = local_read_ts.step_forward_by(hydration_estimate);
194                    let mvs_needing_refresh = refresh_mv_info
195                        .iter()
196                        .cloned()
197                        .filter_map(|(id, frontier, _refresh_schedule)| {
198                            if frontier.less_than(&local_read_ts_adjusted) {
199                                Some(id)
200                            } else {
201                                None
202                            }
203                        })
204                        .collect_vec();
205
206                    // 2. check that
207                    // prev_refresh + compaction_estimate > local_read_ts
208                    let mvs_needing_compaction = refresh_mv_info
209                        .into_iter()
210                        .filter_map(|(id, frontier, refresh_schedule)| {
211                            let frontier = frontier.as_option();
212                            // `prev_refresh` will be None in two cases:
213                            // 1. When there is no previous refresh, because we haven't yet had
214                            // the first refresh. In this case, there is no need to schedule
215                            // time now for compaction.
216                            // 2. In the niche case where a `REFRESH EVERY` MV's write frontier
217                            // is empty. In this case, it's not impossible that there would be a
218                            // need for compaction. But I can't see any easy way to correctly
219                            // handle this case, because we don't have any info handy about when
220                            // the last refresh happened in wall clock time, because the
221                            // frontiers have no relation to wall clock time. So, we'll not
222                            // schedule any compaction time.
223                            // (Note that `REFRESH AT` MVs with empty frontiers, which is a more
224                            // common case, are fine, because `last_refresh` will return
225                            // Some(...) for them.)
226                            let prev_refresh = match frontier {
227                                Some(frontier) => frontier.round_down_minus_1(&refresh_schedule),
228                                None => refresh_schedule.last_refresh(),
229                            };
230                            prev_refresh
231                                .map(|prev_refresh| {
232                                    if prev_refresh.step_forward_by(&compaction_estimate)
233                                        > local_read_ts
234                                    {
235                                        Some(id)
236                                    } else {
237                                        None
238                                    }
239                                })
240                                .flatten()
241                        })
242                        .collect_vec();
243
244                    let cluster_on =
245                        !mvs_needing_refresh.is_empty() || !mvs_needing_compaction.is_empty();
246                    (
247                        cluster_id,
248                        SchedulingDecision::Refresh(RefreshDecision {
249                            cluster_on,
250                            objects_needing_refresh: mvs_needing_refresh,
251                            objects_needing_compaction: mvs_needing_compaction,
252                            hydration_time_estimate,
253                        }),
254                    )
255                })
256                .collect();
257            if let Err(e) = internal_cmd_tx.send(Message::SchedulingDecisions(vec![(
258                REFRESH_POLICY_NAME,
259                decisions,
260            )])) {
261                // It is not an error for this task to be running after `internal_cmd_rx` is dropped.
262                warn!("internal_cmd_rx dropped before we could send: {:?}", e);
263            }
264            check_scheduling_policies_seconds_cloned
265                .with_label_values(&[REFRESH_POLICY_NAME, "background"])
266                .observe((Instant::now() - task_start_time).as_secs_f64());
267        });
268
269        self.metrics
270            .check_scheduling_policies_seconds
271            .with_label_values(&[REFRESH_POLICY_NAME, "main"])
272            .observe((Instant::now() - start_time).as_secs_f64());
273    }
274
275    /// Handles `SchedulingDecisions`:
276    /// 1. Adds the newly made decisions to `cluster_scheduling_decisions`.
277    /// 2. Cleans up old decisions that are for clusters no longer in scope of automated scheduling
278    ///   decisions.
279    /// 3. For each cluster, it sums up `cluster_scheduling_decisions`, checks the summed up decision
280    ///   against the cluster state, and turns cluster On/Off if needed.
281    #[mz_ore::instrument(level = "debug")]
282    pub(crate) async fn handle_scheduling_decisions(
283        &mut self,
284        decisions: Vec<(&'static str, Vec<(ClusterId, SchedulingDecision)>)>,
285    ) {
286        let start_time = Instant::now();
287
288        // 1. Add the received decisions to `cluster_scheduling_decisions`.
289        for (policy_name, decisions) in decisions.iter() {
290            for (cluster_id, decision) in decisions {
291                self.cluster_scheduling_decisions
292                    .entry(*cluster_id)
293                    .or_insert_with(Default::default)
294                    .insert(policy_name, decision.clone());
295            }
296        }
297
298        // 2. Clean up those clusters from `scheduling_decisions` that
299        // - have been dropped, or
300        // - were switched to unmanaged, or
301        // - were switched to `SCHEDULE = MANUAL`.
302        for cluster_id in self
303            .cluster_scheduling_decisions
304            .keys()
305            .cloned()
306            .collect_vec()
307        {
308            match self.get_managed_cluster_config(cluster_id) {
309                None => {
310                    // Cluster have been dropped or switched to unmanaged.
311                    debug!(
312                        "handle_scheduling_decisions: \
313                        Removing cluster {} from cluster_scheduling_decisions, \
314                        because get_managed_cluster_config returned None",
315                        cluster_id
316                    );
317                    self.cluster_scheduling_decisions.remove(&cluster_id);
318                }
319                Some(managed_config) => {
320                    if matches!(managed_config.schedule, ClusterSchedule::Manual) {
321                        debug!(
322                            "handle_scheduling_decisions: \
323                            Removing cluster {} from cluster_scheduling_decisions, \
324                            because schedule is Manual",
325                            cluster_id
326                        );
327                        self.cluster_scheduling_decisions.remove(&cluster_id);
328                    }
329                }
330            }
331        }
332
333        // 3. Act on `scheduling_decisions` where needed.
334        let mut altered_a_cluster = false;
335        for (cluster_id, decisions) in self.cluster_scheduling_decisions.clone() {
336            // We touch a cluster only when all policies have made a decision about it. This is
337            // to ensure that after an envd restart all policies have a chance to run at least once
338            // before we turn off a cluster, to avoid spuriously turning off a cluster and possibly
339            // losing a hydrated state.
340            if POLICIES.iter().all(|policy| decisions.contains_key(policy)) {
341                // A reconfiguration in flight owns the replication-factor
342                // transition, and this path bypasses the sequencer's guard
343                // against racing it, so it defers itself: the decision
344                // reapplies once the record settles. Only while the controller
345                // owns the cluster, though. With the gate off nothing settles
346                // the record, and the legacy write below retains it as
347                // cancelled instead of deferring forever behind an orphan.
348                let controller_owns = ENABLE_CLUSTER_CONTROLLER
349                    .get(self.catalog().system_config().dyncfgs())
350                    && cluster_id.is_user();
351                let reconfiguration_in_flight = self
352                    .get_managed_cluster_config(cluster_id)
353                    .is_some_and(|managed| {
354                        managed
355                            .reconfiguration
356                            .as_ref()
357                            .is_some_and(|record| record.is_in_progress())
358                    });
359                if controller_owns && reconfiguration_in_flight {
360                    debug!(
361                        "handle_scheduling_decisions: deferring cluster {}, \
362                        a graceful reconfiguration is in flight",
363                        cluster_id
364                    );
365                    continue;
366                }
367                // Check whether the cluster's state matches the needed state.
368                // If any policy says On, then we need a replica.
369                let needs_replica = decisions
370                    .values()
371                    .map(|decision| decision.cluster_on())
372                    .contains(&true);
373                let cluster_config = self.catalog().get_cluster(cluster_id).config.clone();
374                let mut new_config = cluster_config.clone();
375                let ClusterVariant::Managed(managed_config) = &mut new_config.variant else {
376                    panic!("cleaned up unmanaged clusters above");
377                };
378                let has_replica = managed_config.replication_factor > 0; // Is it On?
379                if needs_replica != has_replica {
380                    // Turn the cluster On or Off.
381                    altered_a_cluster = true;
382                    managed_config.replication_factor = if needs_replica { 1 } else { 0 };
383                    if let Err(e) = self
384                        .sequence_alter_cluster_managed_to_managed(
385                            None,
386                            cluster_id,
387                            new_config.clone(),
388                            crate::catalog::ReplicaCreateDropReason::ClusterScheduling(
389                                decisions.values().cloned().collect(),
390                            ),
391                            AlterClusterPlanStrategy::None,
392                        )
393                        .await
394                    {
395                        if let AdapterError::AlterClusterWhilePendingReplicas = e {
396                            debug!(
397                                "handle_scheduling_decisions tried to alter a cluster that is undergoing a graceful reconfiguration"
398                            );
399                        } else {
400                            soft_panic_or_log!(
401                                "handle_scheduling_decisions couldn't alter cluster {}. \
402                                 Old config: {:?}, \
403                                 New config: {:?}, \
404                                 Error: {}",
405                                cluster_id,
406                                cluster_config,
407                                new_config,
408                                e
409                            );
410                        }
411                    }
412                }
413            } else {
414                debug!(
415                    "handle_scheduling_decisions: \
416                    Not all policies have made a decision about cluster {}. decisions: {:?}",
417                    cluster_id, decisions,
418                );
419            }
420        }
421
422        self.metrics
423            .handle_scheduling_decisions_seconds
424            .with_label_values(&[altered_a_cluster.to_string().as_str()])
425            .observe((Instant::now() - start_time).as_secs_f64());
426    }
427
428    /// Returns the managed config for a cluster. Returns None if the cluster doesn't exist or if
429    /// it's an unmanaged cluster.
430    fn get_managed_cluster_config(&self, cluster_id: ClusterId) -> Option<ClusterVariantManaged> {
431        let cluster = self.catalog().try_get_cluster(cluster_id)?;
432        if let ClusterVariant::Managed(managed_config) = cluster.config.variant.clone() {
433            Some(managed_config)
434        } else {
435            None
436        }
437    }
438}