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