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}