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}