Skip to main content

mz_environmentd/deployment/
preflight.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
10//! Preflight checks for deployments.
11
12use std::collections::BTreeSet;
13use std::pin::pin;
14use std::sync::Arc;
15use std::time::Duration;
16
17use mz_adapter::ResultExt;
18use mz_catalog::durable::{
19    BootstrapArgs, CatalogError, DurableCatalogState, Metrics, OpenableDurableCatalogState,
20};
21use mz_controller_types::ReplicaId;
22use mz_ore::channel::trigger;
23use mz_ore::exit;
24use mz_ore::halt;
25use mz_ore::str::separated;
26use mz_persist_client::PersistClient;
27use mz_repr::{CatalogItemId, Timestamp};
28use mz_sql::catalog::EnvironmentId;
29use tokio::sync::oneshot;
30use tracing::info;
31
32use crate::BUILD_INFO;
33use crate::deployment::state::DeploymentState;
34
35/// Configuration for catching up and promoting a read-only deployment.
36pub struct CatchupConfig {
37    pub boot_ts: Timestamp,
38    pub environment_id: EnvironmentId,
39    pub persist_client: PersistClient,
40    pub deploy_generation: u64,
41    pub deployment_state: DeploymentState,
42    pub catalog_metrics: Arc<Metrics>,
43    pub caught_up_max_wait: Duration,
44    pub ddl_check_interval: Duration,
45    pub panic_after_timeout: bool,
46    pub bootstrap_args: BootstrapArgs,
47}
48
49/// Returns whether this deployment should boot in read-only mode.
50pub async fn preflight_0dt(
51    openable_adapter_storage: &mut dyn OpenableDurableCatalogState,
52    deploy_generation: u64,
53) -> Result<bool, CatalogError> {
54    if !openable_adapter_storage.is_initialized().await? {
55        info!("catalog not initialized; booting with writes allowed");
56        return Ok(false);
57    }
58
59    let catalog_generation = openable_adapter_storage.get_deployment_generation().await?;
60    info!(%catalog_generation, %deploy_generation, "catalog initialized");
61    if catalog_generation < deploy_generation {
62        info!("this deployment is a new generation; booting in read only mode");
63        Ok(true)
64    } else if catalog_generation == deploy_generation {
65        info!("this deployment is the current generation; booting with writes allowed");
66        Ok(false)
67    } else {
68        exit!(0, "this deployment has been fenced out");
69    }
70}
71
72/// Starts catching up and promoting a read-only deployment.
73///
74/// An administrative skip is accepted right away and promotes without waiting
75/// for bootstrap. Otherwise, catch-up checks and the catch-up timeout start
76/// once `bootstrapped` yields the ID baseline, which must come from the
77/// savepoint used to bootstrap the adapter. The task exits if `bootstrapped`
78/// is dropped.
79pub fn spawn_catchup(
80    CatchupConfig {
81        boot_ts,
82        environment_id,
83        persist_client,
84        deploy_generation,
85        deployment_state,
86        catalog_metrics,
87        caught_up_max_wait,
88        ddl_check_interval,
89        panic_after_timeout,
90        bootstrap_args,
91    }: CatchupConfig,
92    mut caught_up_receiver: trigger::Receiver,
93    bootstrapped: oneshot::Receiver<(BTreeSet<CatalogItemId>, BTreeSet<ReplicaId>)>,
94) {
95    mz_ore::task::spawn(|| "deployment_catchup", async move {
96        let mut skip_catchup = deployment_state.set_catching_up();
97
98        let initial_ids = tokio::select! {
99            biased;
100
101            () = &mut skip_catchup => None,
102            result = bootstrapped => match result {
103                Ok(ids) => Some(ids),
104                Err(_) => return,
105            },
106        };
107
108        if let Some((initial_user_items, initial_user_replicas)) = initial_ids {
109            info!(
110                user_items = initial_user_items.len(),
111                user_replicas = initial_user_replicas.len(),
112                ?caught_up_max_wait,
113                "waiting for deployment to be caught up"
114            );
115
116            let mut caught_up_max_wait_fut = pin!(tokio::time::sleep(caught_up_max_wait));
117
118            let mut check_ddl_changes_interval = tokio::time::interval(ddl_check_interval);
119            check_ddl_changes_interval
120                .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
121
122            let mut should_skip_catchup = false;
123            loop {
124                tokio::select! {
125                    biased;
126
127                    () = &mut skip_catchup => {
128                        info!("skipping waiting for deployment to catch up due to administrator request");
129                        should_skip_catchup = true;
130                        break;
131                    }
132                    () = &mut caught_up_receiver => {
133                        info!("deployment caught up");
134                        break;
135                    }
136                    () = &mut caught_up_max_wait_fut => {
137                        if panic_after_timeout {
138                            panic!("not caught up within {:?}", caught_up_max_wait);
139                        }
140                        info!("not caught up within {:?}, proceeding now", caught_up_max_wait);
141                        break;
142                    }
143                    _ = check_ddl_changes_interval.tick() => {
144                        check_ddl_changes(
145                            boot_ts,
146                            persist_client.clone(),
147                            environment_id.clone(),
148                            deploy_generation,
149                            Arc::clone(&catalog_metrics),
150                            bootstrap_args.clone(),
151                            &initial_user_items,
152                            &initial_user_replicas,
153                        )
154                        .await;
155                    }
156                }
157            }
158
159            // Check for DDL changes one last time before announcing as ready to
160            // promote.
161            if !should_skip_catchup {
162                check_ddl_changes(
163                    boot_ts,
164                    persist_client.clone(),
165                    environment_id.clone(),
166                    deploy_generation,
167                    Arc::clone(&catalog_metrics),
168                    bootstrap_args.clone(),
169                    &initial_user_items,
170                    &initial_user_replicas,
171                )
172                .await;
173            }
174        } else {
175            info!("skipping bootstrap and catch-up due to administrator request");
176        }
177
178        // Announce that we're ready to promote.
179        let promoted = deployment_state.set_ready_to_promote();
180        info!("announced as ready to promote; waiting for promotion");
181        promoted.await;
182
183        // Take over the catalog.
184        info!("promoted; attempting takeover");
185
186        // NOTE: There _is_ a window where DDL can happen in the old
187        // environment, between checking above, us announcing as ready to
188        // promote, and cloud giving us the go-ahead signal. Its size
189        // depends on how quickly cloud will trigger promotion once we
190        // report as ready.
191        //
192        // We could add another check here, right before cutting over, but I
193        // think this requires changes in Cloud: with this additional check,
194        // it can now happen that cloud gives us the promote signal but we
195        // then notice there were changes and restart. Could would have to
196        // notice this and give us the promote signal again, once we're
197        // ready again.
198
199        let openable_adapter_storage = mz_catalog::durable::persist_backed_catalog_state(
200            persist_client.clone(),
201            environment_id.organization_id(),
202            BUILD_INFO.semver_version(),
203            Some(deploy_generation),
204            Arc::clone(&catalog_metrics),
205        )
206        .await
207        .expect("incompatible catalog/persist version");
208
209        let _catalog = openable_adapter_storage
210            .open(boot_ts, &bootstrap_args)
211            .await
212            .unwrap_or_terminate("unexpected error while fencing out old deployment");
213
214        // Reboot as the leader.
215        halt!("fenced out old deployment; rebooting as leader")
216    });
217}
218
219/// Restart in read-only mode when user items or replicas have been created or
220/// dropped, so bootstrap hydrates new objects and releases dropped resources.
221async fn check_ddl_changes(
222    boot_ts: Timestamp,
223    persist_client: PersistClient,
224    environment_id: EnvironmentId,
225    deploy_generation: u64,
226    catalog_metrics: Arc<Metrics>,
227    bootstrap_args: BootstrapArgs,
228    initial_user_items: &BTreeSet<CatalogItemId>,
229    initial_user_replicas: &BTreeSet<ReplicaId>,
230) {
231    let openable_adapter_storage = mz_catalog::durable::persist_backed_catalog_state(
232        persist_client,
233        environment_id.organization_id(),
234        BUILD_INFO.semver_version(),
235        Some(deploy_generation),
236        catalog_metrics,
237    )
238    .await
239    .expect("incompatible catalog/persist version");
240
241    let mut catalog = openable_adapter_storage
242        .open_savepoint(boot_ts, &bootstrap_args)
243        .await
244        .unwrap_or_terminate("can open in savepoint mode");
245
246    // `transaction` rejects unapplied catalog content. This reader has no derived catalog to
247    // update, so discard the initial update stream before opening the transaction.
248    let _ = catalog
249        .sync_to_current_updates()
250        .await
251        .unwrap_or_terminate("unexpected error while draining initial catalog updates");
252    let tx = catalog
253        .transaction()
254        .await
255        .unwrap_or_terminate("unexpected error while getting transaction");
256
257    // We must explicitly check the catalog for these IDs since IDs can be
258    // allocated during sequencing/planning but not yet committed to the catalog.
259    // Furthermore, these IDs might never be committed to the catalog because
260    // their sequencing has been aborted.
261    let mut current_replicas = BTreeSet::new();
262    let mut new_replicas = Vec::new();
263    for replica in tx.get_cluster_replicas() {
264        current_replicas.insert(replica.replica_id);
265        if replica.replica_id.is_user() && !initial_user_replicas.contains(&replica.replica_id) {
266            new_replicas.push(replica);
267        }
268    }
269
270    let mut current_items = BTreeSet::new();
271    let mut new_objects = Vec::new();
272    for item in tx.get_items() {
273        current_items.insert(item.id);
274        if item.id.is_user() && !initial_user_items.contains(&item.id) {
275            new_objects.push(item);
276        }
277    }
278
279    let dropped_items = initial_user_items
280        .difference(&current_items)
281        .collect::<Vec<_>>();
282    let dropped_replicas = initial_user_replicas
283        .difference(&current_replicas)
284        .collect::<Vec<_>>();
285
286    if new_replicas.is_empty()
287        && new_objects.is_empty()
288        && dropped_items.is_empty()
289        && dropped_replicas.is_empty()
290    {
291        return;
292    }
293
294    let mut info_parts = Vec::new();
295
296    if !dropped_items.is_empty() {
297        info_parts.push(format!(
298            "Dropped objects: [{}]",
299            separated(", ", dropped_items)
300        ));
301    }
302    if !dropped_replicas.is_empty() {
303        info_parts.push(format!(
304            "Dropped replicas: [{}]",
305            separated(", ", dropped_replicas)
306        ));
307    }
308
309    if !new_replicas.is_empty() {
310        let replicas = new_replicas.iter().map(|r| {
311            format!(
312                "{{replica_id: {}, replica_name: {}, cluster_id: {}}}",
313                r.replica_id, r.name, r.cluster_id
314            )
315        });
316        info_parts.push(format!("New replicas: [{}]", separated(", ", replicas)));
317    }
318
319    if !new_objects.is_empty() {
320        let objects = new_objects
321            .iter()
322            .map(|o| format!("{{object_id: {}, object_name: {}}}", o.id, o.name));
323        info_parts.push(format!("New objects: [{}]", separated(", ", objects)));
324    }
325
326    let extra_info = separated(". ", info_parts);
327
328    halt!(
329        "there have been DDL that we need to react to; rebooting in read-only mode. {}",
330        extra_info
331    )
332}
333
334/// Snapshot committed user IDs, not allocator counters: IDs can be allocated in
335/// batches and committed out of order, or never committed at all.
336pub async fn get_user_ids(
337    catalog: &mut dyn DurableCatalogState,
338) -> Result<(BTreeSet<CatalogItemId>, BTreeSet<ReplicaId>), CatalogError> {
339    // Preserve the pending updates that adapter bootstrap must consume.
340    let snapshot = catalog.snapshot().await?;
341    let mut dry_run = catalog.transaction_from_snapshot(snapshot)?;
342    let tx = dry_run.transaction_mut();
343
344    let items = tx
345        .get_items()
346        .map(|item| item.id)
347        .filter(|id| id.is_user())
348        .collect();
349    let replicas = tx
350        .get_cluster_replicas()
351        .map(|replica| replica.replica_id)
352        .filter(|id| matches!(id, ReplicaId::User(_)))
353        .collect();
354    Ok((items, replicas))
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use mz_catalog::durable::{TestCatalogStateBuilder, test_bootstrap_args};
361    use mz_orchestratord::controller::materialize::generation::DeploymentStatus;
362    use mz_ore::metrics::MetricsRegistry;
363    use mz_ore::now::SYSTEM_TIME;
364    use mz_persist_client::PersistLocation;
365    use mz_persist_client::cache::PersistClientCache;
366    use mz_persist_client::cfg::PersistConfig;
367    use mz_persist_client::rpc::PubSubClientConnection;
368    use mz_repr::GlobalId;
369    use mz_sql::session::user::MZ_SYSTEM_ROLE_ID;
370
371    use crate::deployment::state::DeploymentStateHandle;
372
373    async fn setup() -> (
374        TestCatalogStateBuilder,
375        CatchupConfig,
376        DeploymentStateHandle,
377    ) {
378        let mut config = PersistConfig::new_for_tests();
379        config.build_version = BUILD_INFO.semver_version();
380        let cache = PersistClientCache::new(config, &MetricsRegistry::new(), |_, _| {
381            PubSubClientConnection::noop()
382        });
383        let persist_client = cache.open(PersistLocation::new_in_mem()).await.unwrap();
384        let environment_id = EnvironmentId::for_tests();
385        let metrics = Arc::new(Metrics::new(&MetricsRegistry::new()));
386        let builder = TestCatalogStateBuilder::new(persist_client.clone())
387            .with_organization_id(environment_id.organization_id())
388            .with_version(BUILD_INFO.semver_version())
389            .with_metrics(Arc::clone(&metrics))
390            .with_deploy_generation(0);
391        let boot_ts = SYSTEM_TIME().into();
392        let catalog = builder
393            .clone()
394            .unwrap_build()
395            .await
396            .open(boot_ts, &test_bootstrap_args())
397            .await
398            .unwrap();
399        catalog.expire().await;
400
401        let (deployment_state, handle) = DeploymentState::new();
402        let config = CatchupConfig {
403            boot_ts,
404            environment_id,
405            persist_client,
406            deploy_generation: 1,
407            deployment_state,
408            catalog_metrics: metrics,
409            caught_up_max_wait: Duration::from_secs(1),
410            ddl_check_interval: Duration::from_millis(10),
411            panic_after_timeout: false,
412            bootstrap_args: test_bootstrap_args(),
413        };
414        (builder, config, handle)
415    }
416
417    async fn wait_ready(handle: &DeploymentStateHandle) {
418        tokio::time::timeout(Duration::from_secs(10), async {
419            while handle.status() != DeploymentStatus::ReadyToPromote {
420                tokio::time::sleep(Duration::from_millis(10)).await;
421            }
422        })
423        .await
424        .unwrap();
425    }
426
427    #[mz_ore::test(tokio::test)]
428    async fn catchup_starts_after_bootstrap() {
429        let (builder, config, handle) = setup().await;
430        let mut openable = builder.with_deploy_generation(1).unwrap_build().await;
431        assert!(preflight_0dt(openable.as_mut(), 1).await.unwrap());
432        let (_trigger, receiver) = trigger::channel();
433        let (bootstrapped, bootstrapped_receiver) = oneshot::channel();
434        let metrics = Arc::clone(&config.catalog_metrics);
435        let before = metrics.transactions_started.get();
436        let caught_up_max_wait = config.caught_up_max_wait;
437        let (boot_ts, bootstrap_args) = (config.boot_ts, config.bootstrap_args.clone());
438
439        tokio::time::pause();
440        spawn_catchup(config, receiver, bootstrapped_receiver);
441        tokio::time::sleep(2 * caught_up_max_wait).await;
442        assert_eq!(metrics.transactions_started.get(), before);
443        assert_eq!(handle.status(), DeploymentStatus::Initializing);
444
445        let mut catalog = openable
446            .open_savepoint(boot_ts, &bootstrap_args)
447            .await
448            .unwrap();
449        bootstrapped
450            .send(get_user_ids(catalog.as_mut()).await.unwrap())
451            .unwrap();
452        wait_ready(&handle).await;
453        catalog.expire().await;
454    }
455
456    #[mz_ore::test(tokio::test)]
457    async fn skip_before_bootstrap() {
458        let (_, config, handle) = setup().await;
459        let (_trigger, receiver) = trigger::channel();
460        let (_bootstrapped, bootstrapped_receiver) = oneshot::channel();
461        spawn_catchup(config, receiver, bootstrapped_receiver);
462        tokio::time::timeout(Duration::from_secs(10), async {
463            while handle.try_skip_catchup().is_err() {
464                tokio::task::yield_now().await;
465            }
466        })
467        .await
468        .unwrap();
469        wait_ready(&handle).await;
470    }
471
472    #[mz_ore::test(tokio::test)]
473    async fn caught_up_before_task_starts() {
474        let (builder, mut config, handle) = setup().await;
475        let mut catalog = builder
476            .with_deploy_generation(1)
477            .unwrap_build()
478            .await
479            .open_savepoint(config.boot_ts, &config.bootstrap_args)
480            .await
481            .unwrap();
482        let (bootstrapped, bootstrapped_receiver) = oneshot::channel();
483        bootstrapped
484            .send(get_user_ids(catalog.as_mut()).await.unwrap())
485            .unwrap();
486        let (trigger, receiver) = trigger::channel();
487        drop(trigger);
488        config.caught_up_max_wait = Duration::ZERO;
489        config.panic_after_timeout = true;
490        spawn_catchup(config, receiver, bootstrapped_receiver);
491        wait_ready(&handle).await;
492        catalog.expire().await;
493    }
494
495    #[mz_ore::test(tokio::test)]
496    async fn baseline_uses_bootstrap_snapshot() {
497        let (builder, config, _) = setup().await;
498        let mut writer = builder
499            .clone()
500            .unwrap_build()
501            .await
502            .open(config.boot_ts, &config.bootstrap_args)
503            .await
504            .unwrap();
505        let mut catalog = builder
506            .with_deploy_generation(1)
507            .unwrap_build()
508            .await
509            .open_savepoint(config.boot_ts, &config.bootstrap_args)
510            .await
511            .unwrap();
512        let initial_ids = get_user_ids(catalog.as_mut()).await.unwrap();
513        assert!(!catalog.sync_to_current_updates().await.unwrap().is_empty());
514
515        writer.sync_to_current_updates().await.unwrap();
516        let mut tx = writer.transaction().await.unwrap();
517        let item_id = 1000;
518        let replica_id = 1000;
519        let schema_id = tx.get_schemas().find(|s| s.name == "public").unwrap().id;
520        tx.insert_item(
521            CatalogItemId::User(item_id),
522            20000,
523            GlobalId::User(item_id),
524            schema_id,
525            "new_item",
526            "CREATE TABLE new_item (a int)".into(),
527            MZ_SYSTEM_ROLE_ID,
528            Vec::new(),
529            Default::default(),
530            None,
531        )
532        .unwrap();
533        let replica = tx.get_cluster_replicas().next().unwrap();
534        tx.insert_cluster_replica_with_id(
535            replica.cluster_id,
536            ReplicaId::User(replica_id),
537            "new_replica",
538            replica.config,
539            replica.owner_id,
540        )
541        .unwrap();
542        let commit_ts = tx.upper();
543        let _ = tx.get_and_commit_op_updates();
544        tx.commit(commit_ts).await.unwrap();
545
546        assert_eq!(get_user_ids(catalog.as_mut()).await.unwrap(), initial_ids);
547        let mut expected = initial_ids;
548        expected.0.insert(CatalogItemId::User(item_id));
549        expected.1.insert(ReplicaId::User(replica_id));
550        assert_eq!(get_user_ids(writer.as_mut()).await.unwrap(), expected);
551        catalog.expire().await;
552        writer.expire().await;
553    }
554}