Skip to main content

mz_adapter/catalog/open/
builtin_schema_migration.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//! Support for migrating the schemas of builtin storage collections.
11//!
12//! If a version upgrade changes the schema of a builtin collection that's made durable in persist,
13//! that persist shard's schema must be migrated accordingly. The migration must happen in a way
14//! that's compatible with 0dt upgrades: Read-only environments need to be able to read the
15//! collections with the new schema, without interfering with the leader environment's continued
16//! use of the old schema.
17//!
18//! Two migration mechanisms are provided:
19//!
20//!  * [`Mechanism::Evolution`] uses persist's schema evolution support to evolve the persist
21//!    shard's schema in-place. Only works for backward-compatible changes.
22//!  * [`Mechanism::Replacement`] creates a new shard to serve the builtin collection in the new
23//!    version. Works for all schema changes but discards existing data.
24//!
25//! Which mechanism to use is selected through entries in the `MIGRATIONS` list. In general, the
26//! `Evolution` mechanism should be used when possible, as it avoids data loss.
27//!
28//! For more context and details on the implementation, see
29//! `doc/developer/design/20251015_builtin_schema_migration.md`.
30
31use std::collections::{BTreeMap, BTreeSet};
32use std::sync::{Arc, LazyLock};
33
34use anyhow::bail;
35use futures::FutureExt;
36use futures::future::BoxFuture;
37use mz_build_info::{BuildInfo, DUMMY_BUILD_INFO};
38use mz_catalog::builtin::{
39    BUILTIN_LOOKUP, Builtin, Fingerprint, MZ_CATALOG_RAW, MZ_CATALOG_RAW_DESCRIPTION,
40    MZ_CLUSTER_REPLICA_FRONTIERS_DESCRIPTION, MZ_OBJECT_ARRANGEMENT_SIZE_HISTORY_DESCRIPTION,
41    MZ_OBJECT_HYDRATION_HISTORY, MZ_OBJECT_HYDRATION_HISTORY_DESCRIPTION,
42    MZ_REPLICA_HYDRATION_HISTORY, MZ_REPLICA_HYDRATION_HISTORY_DESCRIPTION,
43    MZ_STORAGE_USAGE_BY_SHARD, MZ_STORAGE_USAGE_BY_SHARD_DESCRIPTION,
44    RUNTIME_ALTERABLE_FINGERPRINT_SENTINEL,
45};
46use mz_catalog::config::BuiltinItemMigrationConfig;
47use mz_catalog::durable::objects::SystemObjectUniqueIdentifier;
48use mz_catalog::durable::{SystemObjectDescription, SystemObjectMapping, Transaction};
49use mz_catalog::memory::error::{Error, ErrorKind};
50use mz_ore::soft_assert_or_log;
51use mz_persist_client::cfg::USE_CRITICAL_SINCE_CATALOG;
52use mz_persist_client::critical::{Opaque, SinceHandle};
53use mz_persist_client::read::ReadHandle;
54use mz_persist_client::schema::CaESchema;
55use mz_persist_client::write::WriteHandle;
56use mz_persist_client::{Diagnostics, PersistClient};
57use mz_persist_types::ShardId;
58use mz_persist_types::codec_impls::{ShardIdSchema, UnitSchema};
59use mz_persist_types::schema::backward_compatible;
60use mz_repr::namespaces::{MZ_CATALOG_SCHEMA, MZ_INTERNAL_SCHEMA};
61use mz_repr::{CatalogItemId, GlobalId, Timestamp};
62use mz_sql::catalog::{CatalogItemType, NameReference};
63use mz_storage_client::controller::StorageTxn;
64use mz_storage_types::StorageDiff;
65use mz_storage_types::sources::SourceData;
66use semver::Version;
67use timely::progress::Antichain;
68use tracing::{debug, info};
69
70use crate::catalog::migrate::get_migration_version;
71
72/// Builtin schema migrations required to upgrade to the current build version.
73///
74/// Migration steps for old versions must be retained around according to the upgrade policy. For
75/// example, if we support upgrading one major version at a time, the release of version `N.0.0`
76/// can delete all migration steps with versions before `(N-1).0.0`.
77///
78/// Exception: when a builtin's `SystemObjectDescription` changes — e.g. a builtin table is
79/// converted to a materialized view (see `migrate_builtin_tables_to_mvs`), or a builtin is
80/// renamed or removed — existing steps naming the old description must be removed regardless
81/// of version, because `validate_migration_steps` panics on steps that don't resolve to a
82/// current builtin. This is safe only if a `Replacement` step for the new description is added
83/// at the conversion version: every environment that needed the removed steps upgrades from an
84/// even older version, so the new replacement subsumes them.
85///
86/// Smallest supported version: 0.147.0
87static MIGRATIONS: LazyLock<Vec<MigrationStep>> = LazyLock::new(|| {
88    vec![
89        MigrationStep::replacement(
90            "0.149.0",
91            CatalogItemType::Source,
92            MZ_INTERNAL_SCHEMA,
93            "mz_sink_statistics_raw",
94        ),
95        MigrationStep::replacement(
96            "0.149.0",
97            CatalogItemType::Source,
98            MZ_INTERNAL_SCHEMA,
99            "mz_source_statistics_raw",
100        ),
101        MigrationStep::evolution(
102            "0.159.0",
103            CatalogItemType::Source,
104            MZ_INTERNAL_SCHEMA,
105            "mz_cluster_replica_metrics_history",
106        ),
107        MigrationStep::replacement(
108            "26.18.0-dev.0",
109            CatalogItemType::MaterializedView,
110            MZ_CATALOG_SCHEMA,
111            "mz_databases",
112        ),
113        MigrationStep::replacement(
114            "26.19.0-dev.0",
115            CatalogItemType::MaterializedView,
116            MZ_CATALOG_SCHEMA,
117            "mz_schemas",
118        ),
119        MigrationStep::replacement(
120            "26.19.0-dev.0",
121            CatalogItemType::MaterializedView,
122            MZ_CATALOG_SCHEMA,
123            "mz_role_members",
124        ),
125        MigrationStep::replacement(
126            "26.19.0-dev.0",
127            CatalogItemType::MaterializedView,
128            MZ_INTERNAL_SCHEMA,
129            "mz_network_policies",
130        ),
131        MigrationStep::replacement(
132            "26.19.0-dev.0",
133            CatalogItemType::MaterializedView,
134            MZ_INTERNAL_SCHEMA,
135            "mz_network_policy_rules",
136        ),
137        MigrationStep::replacement(
138            "26.19.0-dev.0",
139            CatalogItemType::MaterializedView,
140            MZ_INTERNAL_SCHEMA,
141            "mz_cluster_workload_classes",
142        ),
143        MigrationStep::replacement(
144            "26.19.0-dev.0",
145            CatalogItemType::MaterializedView,
146            MZ_INTERNAL_SCHEMA,
147            "mz_internal_cluster_replicas",
148        ),
149        MigrationStep::replacement(
150            "26.19.0-dev.0",
151            CatalogItemType::MaterializedView,
152            MZ_INTERNAL_SCHEMA,
153            "mz_pending_cluster_replicas",
154        ),
155        MigrationStep::replacement(
156            "26.20.0-dev.0",
157            CatalogItemType::MaterializedView,
158            MZ_CATALOG_SCHEMA,
159            "mz_materialized_views",
160        ),
161        MigrationStep::replacement(
162            "26.22.0-dev.0",
163            CatalogItemType::MaterializedView,
164            MZ_CATALOG_SCHEMA,
165            "mz_connections",
166        ),
167        MigrationStep::replacement(
168            "26.22.0-dev.0",
169            CatalogItemType::MaterializedView,
170            MZ_CATALOG_SCHEMA,
171            "mz_secrets",
172        ),
173        MigrationStep::replacement(
174            "26.27.0-dev.0",
175            CatalogItemType::MaterializedView,
176            MZ_CATALOG_SCHEMA,
177            "mz_sources",
178        ),
179        MigrationStep::replacement(
180            "26.29.0-dev.0",
181            CatalogItemType::MaterializedView,
182            MZ_CATALOG_SCHEMA,
183            "mz_indexes",
184        ),
185        MigrationStep::replacement(
186            "26.29.0-dev.0",
187            CatalogItemType::MaterializedView,
188            MZ_CATALOG_SCHEMA,
189            "mz_roles",
190        ),
191        MigrationStep::replacement(
192            "26.29.0-dev.0",
193            CatalogItemType::MaterializedView,
194            MZ_CATALOG_SCHEMA,
195            "mz_role_parameters",
196        ),
197        // Required because we added `mz_cluster_replica_size_internal_ind` builtin
198        // index without bumping mz_indexes. make_mz_indexes inlines the builtin-index
199        // set as VALUES, so any add/remove changes its SQL fingerprint and requires
200        // an explicit replacement step.
201        MigrationStep::replacement(
202            "26.30.0-dev.0",
203            CatalogItemType::MaterializedView,
204            MZ_CATALOG_SCHEMA,
205            "mz_indexes",
206        ),
207        MigrationStep::replacement(
208            "26.30.0-dev.0",
209            CatalogItemType::MaterializedView,
210            MZ_CATALOG_SCHEMA,
211            "mz_clusters",
212        ),
213        MigrationStep::replacement(
214            "26.30.0-dev.0",
215            CatalogItemType::MaterializedView,
216            MZ_CATALOG_SCHEMA,
217            "mz_cluster_replicas",
218        ),
219        MigrationStep::replacement(
220            "26.30.0-dev.0",
221            CatalogItemType::MaterializedView,
222            MZ_INTERNAL_SCHEMA,
223            "mz_cluster_schedules",
224        ),
225        MigrationStep::replacement(
226            "26.30.0-dev.0",
227            CatalogItemType::MaterializedView,
228            MZ_CATALOG_SCHEMA,
229            "mz_default_privileges",
230        ),
231        MigrationStep::replacement(
232            "26.30.0-dev.0",
233            CatalogItemType::MaterializedView,
234            MZ_CATALOG_SCHEMA,
235            "mz_system_privileges",
236        ),
237        // The mz_cluster_replicas MV definition changed in 26.31.0-dev (the
238        // `availability_zone` column now aggregates the durable
239        // `availability_zones` list).
240        MigrationStep::replacement(
241            "26.31.0-dev.0",
242            CatalogItemType::MaterializedView,
243            MZ_CATALOG_SCHEMA,
244            "mz_cluster_replicas",
245        ),
246        MigrationStep::replacement(
247            "26.32.0-dev.0",
248            CatalogItemType::MaterializedView,
249            MZ_INTERNAL_SCHEMA,
250            "mz_comments",
251        ),
252        // Required because we added the console cluster-utilization overview builtin
253        // indexes (overview/_3h/_24h). make_mz_indexes inlines the builtin-index set
254        // as VALUES, so any add/remove changes its SQL fingerprint and requires an
255        // explicit replacement step.
256        MigrationStep::replacement(
257            "26.32.0-dev.0",
258            CatalogItemType::MaterializedView,
259            MZ_CATALOG_SCHEMA,
260            "mz_indexes",
261        ),
262        MigrationStep::replacement(
263            "26.33.0-dev.0",
264            CatalogItemType::MaterializedView,
265            MZ_CATALOG_SCHEMA,
266            "mz_audit_events",
267        ),
268        // Required because we added the `mz_cluster_reconfigurations_ind` and
269        // `mz_cluster_auto_scaling_strategies_ind` builtin indexes without
270        // bumping mz_indexes. make_mz_indexes inlines the builtin-index set as
271        // VALUES, so any add/remove changes its SQL fingerprint and requires an
272        // explicit replacement step.
273        //
274        // NOTE: this version must stay at the workspace's current dev version
275        // until this change ships in a release. A dev version orders below its
276        // release, so a step pinned to an older dev version is skipped when
277        // upgrading from that release onward, and the fingerprint check then
278        // panics at catalog open.
279        MigrationStep::replacement(
280            "26.34.0-dev.0",
281            CatalogItemType::MaterializedView,
282            MZ_CATALOG_SCHEMA,
283            "mz_indexes",
284        ),
285        // Converting mz_postgres_sources / mz_kafka_sources from builtin tables
286        // to materialized views changes their catalog fingerprint, so both need
287        // an explicit replacement step. See the NOTE above: this version must
288        // stay at the workspace's current dev version until the change ships.
289        MigrationStep::replacement(
290            "26.34.0-dev.0",
291            CatalogItemType::MaterializedView,
292            MZ_INTERNAL_SCHEMA,
293            "mz_postgres_sources",
294        ),
295        MigrationStep::replacement(
296            "26.34.0-dev.0",
297            CatalogItemType::MaterializedView,
298            MZ_CATALOG_SCHEMA,
299            "mz_kafka_sources",
300        ),
301        // Converting the four mz_*_source_tables from builtin tables to
302        // materialized views changes their catalog fingerprint, so each needs an
303        // explicit replacement step.
304        MigrationStep::replacement(
305            "26.37.0-dev.0",
306            CatalogItemType::MaterializedView,
307            MZ_INTERNAL_SCHEMA,
308            "mz_postgres_source_tables",
309        ),
310        MigrationStep::replacement(
311            "26.37.0-dev.0",
312            CatalogItemType::MaterializedView,
313            MZ_INTERNAL_SCHEMA,
314            "mz_mysql_source_tables",
315        ),
316        MigrationStep::replacement(
317            "26.37.0-dev.0",
318            CatalogItemType::MaterializedView,
319            MZ_INTERNAL_SCHEMA,
320            "mz_sql_server_source_tables",
321        ),
322        MigrationStep::replacement(
323            "26.37.0-dev.0",
324            CatalogItemType::MaterializedView,
325            MZ_INTERNAL_SCHEMA,
326            "mz_kafka_source_tables",
327        ),
328        // `mz_type_pg_metadata` gained a trailing `typsend` column. See the NOTE
329        // above: this version must stay at the workspace's current dev version
330        // until the change ships.
331        MigrationStep::replacement(
332            "26.40.0-dev.0",
333            CatalogItemType::Table,
334            MZ_INTERNAL_SCHEMA,
335            "mz_type_pg_metadata",
336        ),
337        // Converting the connection-detail builtin tables to materialized views
338        // changes their catalog fingerprint, so each needs an explicit
339        // replacement step.
340        MigrationStep::replacement(
341            "26.37.0-dev.0",
342            CatalogItemType::MaterializedView,
343            MZ_CATALOG_SCHEMA,
344            "mz_kafka_connections",
345        ),
346        MigrationStep::replacement(
347            "26.37.0-dev.0",
348            CatalogItemType::MaterializedView,
349            MZ_CATALOG_SCHEMA,
350            "mz_ssh_tunnel_connections",
351        ),
352        MigrationStep::replacement(
353            "26.37.0-dev.0",
354            CatalogItemType::MaterializedView,
355            MZ_INTERNAL_SCHEMA,
356            "mz_aws_connections",
357        ),
358        MigrationStep::replacement(
359            "26.37.0-dev.0",
360            CatalogItemType::MaterializedView,
361            MZ_CATALOG_SCHEMA,
362            "mz_aws_privatelink_connections",
363        ),
364        // The three sink tables became materialized views, which moves their
365        // fingerprints. The old `0.160.0` `mz_sinks` step had to go at the same
366        // time, because it names the `Table` description and that no longer
367        // resolves. Nothing is lost: anything that needed the old step upgrades
368        // from further back than this one, so this one covers it too.
369        MigrationStep::replacement(
370            "26.38.0-dev.0",
371            CatalogItemType::MaterializedView,
372            MZ_CATALOG_SCHEMA,
373            "mz_sinks",
374        ),
375        MigrationStep::replacement(
376            "26.38.0-dev.0",
377            CatalogItemType::MaterializedView,
378            MZ_CATALOG_SCHEMA,
379            "mz_kafka_sinks",
380        ),
381        MigrationStep::replacement(
382            "26.38.0-dev.0",
383            CatalogItemType::MaterializedView,
384            MZ_CATALOG_SCHEMA,
385            "mz_iceberg_sinks",
386        ),
387        // The mz_cluster_reconfigurations MV definition changed (the `changes`
388        // diff now includes the `arrangement_compression` dimension).
389        MigrationStep::replacement(
390            "26.38.0-rc.2",
391            CatalogItemType::MaterializedView,
392            MZ_INTERNAL_SCHEMA,
393            "mz_cluster_reconfigurations",
394        ),
395        // The mz_audit_events MV gained a `metric-sink` arm in its object_type
396        // CASE, changing its SQL fingerprint, so it needs an explicit
397        // replacement step. See the NOTE above: this version must stay at the
398        // workspace's current dev version until the change ships.
399        MigrationStep::replacement(
400            "26.39.0-dev.0",
401            CatalogItemType::MaterializedView,
402            MZ_CATALOG_SCHEMA,
403            "mz_audit_events",
404        ),
405        // Required because we added the `mz_metric_sinks_ind` builtin index.
406        // make_mz_indexes inlines the builtin-index set as VALUES, so any add or
407        // remove changes its SQL fingerprint and requires an explicit
408        // replacement. See the NOTE above: this version must stay at the
409        // workspace's current dev version until the change ships. The addition
410        // is unshipped, so this step supersedes the earlier `26.39.0-dev.0`
411        // `mz_indexes` step (which covered `mz_object_graph_edges_ind`): a
412        // replacement recreates `mz_indexes` from its current definition, so a
413        // single step at the current dev version covers every earlier change too.
414        MigrationStep::replacement(
415            "26.40.0-dev.0",
416            CatalogItemType::MaterializedView,
417            MZ_CATALOG_SCHEMA,
418            "mz_indexes",
419        ),
420        // Converting mz_tables and mz_views from builtin tables to
421        // materialized views over mz_catalog_raw changes their catalog
422        // fingerprints, so each needs an explicit replacement step. See the
423        // NOTE above: this version must stay at the workspace's current dev
424        // version until the change ships.
425        MigrationStep::replacement(
426            "26.39.0-dev.0",
427            CatalogItemType::MaterializedView,
428            MZ_CATALOG_SCHEMA,
429            "mz_tables",
430        ),
431        MigrationStep::replacement(
432            "26.39.0-dev.0",
433            CatalogItemType::MaterializedView,
434            MZ_CATALOG_SCHEMA,
435            "mz_views",
436        ),
437        // Required because we added the `mz_cluster_replica_resource_usage` builtin log.
438        // make_mz_indexes and make_mz_sources inline the builtin-log set as
439        // VALUES, so adding one changes both MVs' SQL fingerprints. See the NOTE
440        // above: this version must stay at the workspace's current dev version
441        // until the change ships.
442        MigrationStep::replacement(
443            "26.40.0-dev.0",
444            CatalogItemType::MaterializedView,
445            MZ_CATALOG_SCHEMA,
446            "mz_indexes",
447        ),
448        MigrationStep::replacement(
449            "26.40.0-dev.0",
450            CatalogItemType::MaterializedView,
451            MZ_CATALOG_SCHEMA,
452            "mz_sources",
453        ),
454        // Converting mz_object_dependencies from a builtin table to a
455        // materialized view over mz_catalog_raw changes its catalog
456        // fingerprint, so it needs an explicit replacement step. See the
457        // NOTE above: this version must stay at the workspace's current dev
458        // version until the change ships.
459        MigrationStep::replacement(
460            "26.41.0-dev.0",
461            CatalogItemType::MaterializedView,
462            MZ_INTERNAL_SCHEMA,
463            "mz_object_dependencies",
464        ),
465        // Adding the `swap_bytes` column to `mz_cluster_replica_metrics_history`
466        // is a backward-compatible append, so the shard schema can evolve in
467        // place. See the NOTE above: this version must stay at the workspace's
468        // current dev version until the change ships.
469        MigrationStep::evolution(
470            "26.43.0-dev.0",
471            CatalogItemType::Source,
472            MZ_INTERNAL_SCHEMA,
473            "mz_cluster_replica_metrics_history",
474        ),
475    ]
476});
477
478/// A migration required to upgrade past a specific version.
479#[derive(Clone, Debug)]
480struct MigrationStep {
481    /// The build version that requires this migration.
482    version: Version,
483    /// The object that requires migration.
484    object: SystemObjectDescription,
485    /// The migration mechanism to be used.
486    mechanism: Mechanism,
487}
488
489impl MigrationStep {
490    /// Helper to construct an `Evolution` migration step.
491    fn evolution(version: &str, type_: CatalogItemType, schema: &str, name: &str) -> Self {
492        Self {
493            version: Version::parse(version).expect("valid"),
494            object: SystemObjectDescription {
495                schema_name: schema.into(),
496                object_type: type_,
497                object_name: name.into(),
498            },
499            mechanism: Mechanism::Evolution,
500        }
501    }
502
503    /// Helper to construct a `Replacement` migration step.
504    fn replacement(version: &str, type_: CatalogItemType, schema: &str, name: &str) -> Self {
505        Self {
506            version: Version::parse(version).expect("valid"),
507            object: SystemObjectDescription {
508                schema_name: schema.into(),
509                object_type: type_,
510                object_name: name.into(),
511            },
512            mechanism: Mechanism::Replacement,
513        }
514    }
515}
516
517/// The mechanism to use to migrate the schema of a builtin collection.
518#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
519#[allow(dead_code)]
520enum Mechanism {
521    /// Persist schema evolution.
522    ///
523    /// Keeps existing contents but only works for schema changes that are backward compatible
524    /// according to [`backward_compatible`].
525    Evolution,
526    /// Shard replacement.
527    ///
528    /// Works for arbitrary schema changes but loses existing contents.
529    Replacement,
530}
531
532/// The result of a builtin schema migration.
533pub(super) struct MigrationResult {
534    /// IDs of items whose shards have been replaced using the `Replacement` mechanism.
535    pub replaced_items: BTreeSet<CatalogItemId>,
536    /// A cleanup action to take once the migration has been made durable.
537    pub cleanup_action: BoxFuture<'static, ()>,
538}
539
540impl Default for MigrationResult {
541    fn default() -> Self {
542        Self {
543            replaced_items: Default::default(),
544            cleanup_action: async {}.boxed(),
545        }
546    }
547}
548
549/// Run builtin schema migrations.
550///
551/// This is the entry point used by adapter when opening the catalog. It uses the hardcoded
552/// `BUILTINS` and `MIGRATIONS` lists to initialize the lists of available builtins and required
553/// migrations, respectively.
554pub(super) async fn run(
555    build_info: &BuildInfo,
556    deploy_generation: u64,
557    txn: &mut Transaction<'_>,
558    config: BuiltinItemMigrationConfig,
559) -> Result<MigrationResult, Error> {
560    // Sanity check to ensure we're not touching durable state in read-only mode.
561    assert_eq!(config.read_only, txn.is_savepoint());
562
563    // Tests may provide a dummy build info that confuses the migration step selection logic. Skip
564    // migrations if we observe this build info.
565    if *build_info == DUMMY_BUILD_INFO {
566        return Ok(MigrationResult::default());
567    }
568
569    let Some(durable_version) = get_migration_version(txn) else {
570        // New catalog; nothing to do.
571        return Ok(MigrationResult::default());
572    };
573    let build_version = build_info.semver_version();
574
575    let collection_metadata = txn.get_collection_metadata();
576    let system_objects = txn
577        .get_system_object_mappings()
578        .map(|m| {
579            let object = m.description;
580            let global_id = m.unique_identifier.global_id;
581            let shard_id = collection_metadata.get(&global_id).copied();
582            let Some((_, builtin)) = BUILTIN_LOOKUP.get(&object) else {
583                panic!("missing builtin {object:?}");
584            };
585            let info = ObjectInfo {
586                global_id,
587                shard_id,
588                builtin,
589                fingerprint: m.unique_identifier.fingerprint,
590            };
591            (object, info)
592        })
593        .collect();
594
595    let migration_shard = txn.get_builtin_migration_shard().expect("must exist");
596
597    let migration = Migration {
598        source_version: durable_version.clone(),
599        target_version: build_version.clone(),
600        deploy_generation,
601        system_objects,
602        migration_shard,
603        config,
604    };
605
606    let result = migration.run(&MIGRATIONS).await.map_err(|e| {
607        Error::new(ErrorKind::FailedBuiltinSchemaMigration {
608            last_seen_version: durable_version.to_string(),
609            this_version: build_version.to_string(),
610            cause: e.to_string(),
611        })
612    })?;
613
614    result.apply(txn);
615
616    let replaced_items = txn
617        .get_system_object_mappings()
618        .map(|m| m.unique_identifier)
619        .filter(|ids| result.new_shards.contains_key(&ids.global_id))
620        .map(|ids| ids.catalog_id)
621        .collect();
622
623    Ok(MigrationResult {
624        replaced_items,
625        cleanup_action: result.cleanup_action,
626    })
627}
628
629/// Result produced by `Migration::run`.
630struct MigrationRunResult {
631    new_shards: BTreeMap<GlobalId, ShardId>,
632    new_fingerprints: BTreeMap<SystemObjectDescription, String>,
633    shards_to_finalize: BTreeSet<ShardId>,
634    cleanup_action: BoxFuture<'static, ()>,
635}
636
637impl Default for MigrationRunResult {
638    fn default() -> Self {
639        Self {
640            new_shards: BTreeMap::new(),
641            new_fingerprints: BTreeMap::new(),
642            shards_to_finalize: BTreeSet::new(),
643            cleanup_action: async {}.boxed(),
644        }
645    }
646}
647
648impl MigrationRunResult {
649    /// Apply this migration result to the given transaction.
650    fn apply(&self, txn: &mut Transaction<'_>) {
651        // Update collection metadata.
652        let replaced_ids = self.new_shards.keys().copied().collect();
653        let old_metadata = txn.delete_collection_metadata(replaced_ids);
654        txn.insert_collection_metadata(self.new_shards.clone())
655            .expect("inserting unique shards IDs after deleting existing entries");
656
657        // Register shards for finalization.
658        let mut unfinalized_shards: BTreeSet<_> =
659            old_metadata.into_iter().map(|(_, sid)| sid).collect();
660        unfinalized_shards.extend(self.shards_to_finalize.iter().copied());
661        txn.insert_unfinalized_shards(unfinalized_shards)
662            .expect("cannot fail");
663
664        // Update fingerprints.
665        let mappings = txn
666            .get_system_object_mappings()
667            .filter_map(|m| {
668                let fingerprint = self.new_fingerprints.get(&m.description)?;
669                Some(SystemObjectMapping {
670                    description: m.description,
671                    unique_identifier: SystemObjectUniqueIdentifier {
672                        catalog_id: m.unique_identifier.catalog_id,
673                        global_id: m.unique_identifier.global_id,
674                        fingerprint: fingerprint.clone(),
675                    },
676                })
677            })
678            .collect();
679        txn.set_system_object_mappings(mappings)
680            .expect("filtered existing mappings remain unique");
681    }
682}
683
684/// Information about a system object required to run a `Migration`.
685#[derive(Clone, Debug)]
686struct ObjectInfo {
687    global_id: GlobalId,
688    shard_id: Option<ShardId>,
689    builtin: &'static Builtin<NameReference>,
690    fingerprint: String,
691}
692
693/// Context of a builtin schema migration.
694struct Migration {
695    /// The version we are migrating from.
696    ///
697    /// Same as the build version of the most recent leader process that successfully performed
698    /// migrations.
699    source_version: Version,
700    /// The version we are migration to.
701    ///
702    /// Same as the build version of this process.
703    target_version: Version,
704    /// The deploy generation of this process.
705    deploy_generation: u64,
706    /// Information about all objects in the system.
707    system_objects: BTreeMap<SystemObjectDescription, ObjectInfo>,
708    /// The ID of the migration shard.
709    migration_shard: ShardId,
710    /// Additional configuration.
711    config: BuiltinItemMigrationConfig,
712}
713
714/// Whether `builtin` participates in a forced migration using `mechanism`.
715fn participates_in_forced_migration(
716    builtin: &Builtin<NameReference>,
717    mechanism: Mechanism,
718) -> bool {
719    use Builtin::*;
720    match builtin {
721        // A forced replacement allocates a fresh shard, which discards the
722        // table's contents. Exclude the tables whose contents are the point:
723        // storage usage is retained for billing, and hydration histories cannot
724        // be rebuilt from any other source.
725        //
726        // Hydration history tables take part in a forced `Evolution`, which
727        // keeps the rows. They have to: dev upgrades force one for every object,
728        // and a table left out of the plan never gets its new schema registered.
729        // `update_fingerprints` then panics at open as soon as the desc changes.
730        // See the tripwire in `validate_migration_steps` for how to give up the
731        // replacement exemption deliberately.
732        Table(table) => {
733            **table != *MZ_STORAGE_USAGE_BY_SHARD
734                && (mechanism != Mechanism::Replacement
735                    || (**table != *MZ_OBJECT_HYDRATION_HISTORY
736                        && **table != *MZ_REPLICA_HYDRATION_HISTORY))
737        }
738        MaterializedView(..) => true,
739        Source(source) => **source != *MZ_CATALOG_RAW,
740        Log(..) | View(..) | Type(..) | Func(..) | Index(..) | Connection(..) => false,
741    }
742}
743
744impl Migration {
745    async fn run(self, steps: &[MigrationStep]) -> anyhow::Result<MigrationRunResult> {
746        info!(
747            deploy_generation = %self.deploy_generation,
748            "running builtin schema migration: {} -> {}",
749            self.source_version, self.target_version
750        );
751
752        self.validate_migration_steps(steps);
753
754        // Version-based migration filter fails for dev versions, see for example
755        // https://github.com/MaterializeInc/database-issues/issues/11335
756        let force_migration = if self.source_version != self.target_version
757            && self.source_version.pre.as_str().starts_with("dev")
758            && self.config.force_migration.is_none()
759        {
760            Some("evolution".to_string())
761        } else {
762            self.config.force_migration.clone()
763        };
764
765        let (force, plan) = match force_migration.as_deref() {
766            None => (false, self.plan_migration(steps)),
767            Some("evolution") => (true, self.plan_forced_migration(Mechanism::Evolution)),
768            Some("replacement") => (true, self.plan_forced_migration(Mechanism::Replacement)),
769            Some(other) => panic!("unknown force migration mechanism: {other}"),
770        };
771
772        if self.source_version == self.target_version && !force {
773            info!("skipping migration: already at target version");
774            return Ok(MigrationRunResult::default());
775        } else if self.source_version > self.target_version {
776            bail!("downgrade not supported");
777        }
778
779        // In leader mode, upgrade the version of the migration shard to the target version.
780        // This fences out any readers at lower versions.
781        if !self.config.read_only {
782            self.upgrade_migration_shard_version().await;
783        }
784
785        info!("executing migration plan: {plan:?}");
786
787        self.migrate_evolve(&plan.evolve).await?;
788        let new_shards = self.migrate_replace(&plan.replace).await?;
789
790        let mut migrated_objects = BTreeSet::new();
791        migrated_objects.extend(plan.evolve);
792        migrated_objects.extend(plan.replace);
793
794        let new_fingerprints = self.update_fingerprints(&migrated_objects)?;
795
796        let (shards_to_finalize, cleanup_action) = self.cleanup().await?;
797
798        Ok(MigrationRunResult {
799            new_shards,
800            new_fingerprints,
801            shards_to_finalize,
802            cleanup_action,
803        })
804    }
805
806    /// Sanity check the given migration steps.
807    ///
808    /// If any of these checks fail, that's a bug in Materialize, and we panic immediately.
809    fn validate_migration_steps(&self, steps: &[MigrationStep]) {
810        for step in steps {
811            assert!(
812                step.version <= self.target_version,
813                "migration step version greater than target version: {} > {}",
814                step.version,
815                self.target_version,
816            );
817
818            let object = &step.object;
819
820            // `mz_storage_usage_by_shard` cannot be migrated for multiple reasons. Firstly, it would
821            // cause the table to be truncated because the contents are not also stored in the durable
822            // catalog. Secondly, we prune `mz_storage_usage_by_shard` of old events in the background
823            // on startup. The correctness of that pruning relies on there being no other retractions
824            // to `mz_storage_usage_by_shard`.
825            //
826            // TODO: Confirm the above reasoning, it might be outdated?
827            assert_ne!(
828                &*MZ_STORAGE_USAGE_BY_SHARD_DESCRIPTION, object,
829                "mz_storage_usage_by_shard cannot be migrated or else the table will be truncated"
830            );
831
832            // Same hazard as `mz_storage_usage_by_shard`: the startup pruner
833            // (`Coordinator::prune_arrangement_sizes_history_on_startup`) assumes it is
834            // the only source of retractions, so migration-driven truncation would break it.
835            assert_ne!(
836                &*MZ_OBJECT_ARRANGEMENT_SIZE_HISTORY_DESCRIPTION, object,
837                "mz_object_arrangement_size_history cannot be migrated or else the table will be truncated"
838            );
839
840            // Unlike the two tables above, there is no correctness hazard here, a
841            // truncation would only lose history. This is a tripwire so that the
842            // loss is chosen rather than stumbled into: if a schema change to
843            // this table is worth clearing it for, remove this assert along with
844            // the exemption in `plan_forced_migration`, and say in the release
845            // notes that the history restarts.
846            //
847            // Only `Replacement` loses the rows. An `Evolution` keeps the shard
848            // and its contents, so it is the path a schema change to this table
849            // should take and we let it through.
850            if step.mechanism == Mechanism::Replacement {
851                assert_ne!(
852                    &*MZ_OBJECT_HYDRATION_HISTORY_DESCRIPTION, object,
853                    "replacing mz_object_hydration_history clears it, see the comment above"
854                );
855                assert_ne!(
856                    &*MZ_REPLICA_HYDRATION_HISTORY_DESCRIPTION, object,
857                    "replacing mz_replica_hydration_history clears it, see the comment above"
858                );
859            }
860
861            // `mz_catalog_raw` cannot be migrated because it contains the durable catalog and it
862            // wouldn't be very durable if we allowed it to be truncated.
863            assert_ne!(
864                &*MZ_CATALOG_RAW_DESCRIPTION, object,
865                "mz_catalog_raw cannot be migrated"
866            );
867
868            // The 0dt caught-up gate reads the leader's `mz_cluster_replica_frontiers` shard for
869            // the live frontiers it checks every collection against. Migrating it via `Replacement`
870            // hands us a fresh shard we write ourselves, so the gate would compare us against
871            // ourselves instead of against the leader.
872            assert_ne!(
873                &*MZ_CLUSTER_REPLICA_FRONTIERS_DESCRIPTION, object,
874                "mz_cluster_replica_frontiers cannot be migrated or else the 0dt caught-up gate loses its live-frontier reference"
875            );
876
877            let Some(object_info) = self.system_objects.get(object) else {
878                panic!("migration step for non-existent builtin: {object:?}");
879            };
880
881            let builtin = object_info.builtin;
882            use Builtin::*;
883            assert!(
884                matches!(builtin, Table(..) | Source(..) | MaterializedView(..)),
885                "schema migration not supported for builtin: {builtin:?}",
886            );
887        }
888    }
889
890    /// Select for each object to migrate the appropriate migration mechanism.
891    fn plan_migration(&self, steps: &[MigrationStep]) -> Plan {
892        // Ignore any steps at versions before `source_version`.
893        let steps = steps.iter().filter(|s| s.version > self.source_version);
894
895        // Select a mechanism for each object, according to the requested migrations:
896        //  * If any `Replacement` was requested, use `Replacement`.
897        //  * Otherwise, (i.e. only `Evolution` was requested), use `Evolution`.
898        let mut by_object = BTreeMap::new();
899        for step in steps {
900            if let Some(entry) = by_object.get_mut(&step.object) {
901                *entry = match (step.mechanism, *entry) {
902                    (Mechanism::Evolution, Mechanism::Evolution) => Mechanism::Evolution,
903                    (Mechanism::Replacement, _) | (_, Mechanism::Replacement) => {
904                        Mechanism::Replacement
905                    }
906                };
907            } else {
908                by_object.insert(step.object.clone(), step.mechanism);
909            }
910        }
911
912        let mut plan = Plan::default();
913        for (object, mechanism) in by_object {
914            match mechanism {
915                Mechanism::Evolution => plan.evolve.push(object),
916                Mechanism::Replacement => plan.replace.push(object),
917            }
918        }
919
920        plan
921    }
922
923    /// Plan a forced migration of all objects using the given mechanism.
924    fn plan_forced_migration(&self, mechanism: Mechanism) -> Plan {
925        let objects = self
926            .system_objects
927            .iter()
928            // Skip objects that don't yet have a shard registered. These are brand-new builtins
929            // added in this version; the leader will allocate their shards during bootstrap, and
930            // there is nothing to evolve or replace.
931            .filter(|(_, info)| info.shard_id.is_some())
932            .filter(|(_, info)| participates_in_forced_migration(info.builtin, mechanism))
933            .map(|(object, _)| object.clone())
934            .collect();
935
936        let mut plan = Plan::default();
937        match mechanism {
938            Mechanism::Evolution => plan.evolve = objects,
939            Mechanism::Replacement => plan.replace = objects,
940        }
941
942        plan
943    }
944
945    /// Upgrade the migration shard to the target version.
946    async fn upgrade_migration_shard_version(&self) {
947        let persist = &self.config.persist_client;
948        let diagnostics = Diagnostics {
949            shard_name: "builtin_migration".to_string(),
950            handle_purpose: format!("migration shard upgrade @ {}", self.target_version),
951        };
952
953        persist
954            .upgrade_version::<migration_shard::Key, ShardId, Timestamp, StorageDiff>(
955                self.migration_shard,
956                diagnostics,
957            )
958            .await
959            .expect("valid usage");
960    }
961
962    /// Migrate the given objects using the `Evolution` mechanism.
963    async fn migrate_evolve(&self, objects: &[SystemObjectDescription]) -> anyhow::Result<()> {
964        for object in objects {
965            self.migrate_evolve_one(object).await?;
966        }
967        Ok(())
968    }
969
970    async fn migrate_evolve_one(&self, object: &SystemObjectDescription) -> anyhow::Result<()> {
971        let persist = &self.config.persist_client;
972
973        let Some(object_info) = self.system_objects.get(object) else {
974            bail!("missing builtin {object:?}");
975        };
976        let id = object_info.global_id;
977
978        let Some(shard_id) = object_info.shard_id else {
979            // No shard is registered for this builtin. In leader mode, this is fine, we'll
980            // register the shard during bootstrap. In read-only mode, we might be racing with the
981            // leader to register the shard and it's unclear what sort of confusion can arise from
982            // that -- better to bail out in this case.
983            if self.config.read_only {
984                bail!("missing shard ID for builtin {object:?} ({id})");
985            } else {
986                return Ok(());
987            }
988        };
989
990        let target_desc = match object_info.builtin {
991            Builtin::Table(table) => &table.desc,
992            Builtin::Source(source) => &source.desc,
993            Builtin::MaterializedView(mv) => &mv.desc,
994            _ => bail!("not a storage collection: {object:?}"),
995        };
996
997        let diagnostics = Diagnostics {
998            shard_name: id.to_string(),
999            handle_purpose: format!("builtin schema migration @ {}", self.target_version),
1000        };
1001        let source_schema = persist
1002            .latest_schema::<SourceData, (), Timestamp, StorageDiff>(shard_id, diagnostics.clone())
1003            .await
1004            .expect("valid usage");
1005
1006        info!(?object, %id, %shard_id, ?source_schema, ?target_desc, "migrating by evolution");
1007
1008        if self.config.read_only {
1009            // In read-only mode, only check that the new schema is backward compatible.
1010            // We'll register it when/if we restart in leader mode.
1011            if let Some((_, source_desc, _)) = &source_schema {
1012                let old = mz_persist_types::columnar::data_type::<SourceData>(source_desc)?;
1013                let new = mz_persist_types::columnar::data_type::<SourceData>(target_desc)?;
1014                if backward_compatible(&old, &new).is_none() {
1015                    bail!(
1016                        "incompatible schema evolution for {object:?}: \
1017                         {source_desc:?} -> {target_desc:?}"
1018                    );
1019                }
1020            }
1021
1022            return Ok(());
1023        }
1024
1025        let (mut schema_id, mut source_desc) = match source_schema {
1026            Some((schema_id, source_desc, _)) => (schema_id, source_desc),
1027            None => {
1028                // If no schema was previously registered, simply try to register the new one. This
1029                // might fail due to a concurrent registration, in which case we'll fall back to
1030                // `compare_and_evolve_schema`.
1031
1032                debug!(%id, %shard_id, "no previous schema found; registering initial one");
1033                let schema_id = persist
1034                    .register_schema::<SourceData, (), Timestamp, StorageDiff>(
1035                        shard_id,
1036                        target_desc,
1037                        &UnitSchema,
1038                        diagnostics.clone(),
1039                    )
1040                    .await
1041                    .expect("valid usage");
1042                if schema_id.is_some() {
1043                    return Ok(());
1044                }
1045
1046                debug!(%id, %shard_id, "schema registration failed; falling back to CaES");
1047                let (schema_id, source_desc, _) = persist
1048                    .latest_schema::<SourceData, (), Timestamp, StorageDiff>(
1049                        shard_id,
1050                        diagnostics.clone(),
1051                    )
1052                    .await
1053                    .expect("valid usage")
1054                    .expect("known to exist");
1055
1056                (schema_id, source_desc)
1057            }
1058        };
1059
1060        loop {
1061            // Evolving the schema might fail if another process evolved the schema concurrently,
1062            // in which case we need to retry. Most likely the other process evolved the schema to
1063            // our own target schema and the second try is a no-op.
1064
1065            debug!(%id, %shard_id, %schema_id, ?source_desc, ?target_desc, "attempting CaES");
1066            let result = persist
1067                .compare_and_evolve_schema::<SourceData, (), Timestamp, StorageDiff>(
1068                    shard_id,
1069                    schema_id,
1070                    target_desc,
1071                    &UnitSchema,
1072                    diagnostics.clone(),
1073                )
1074                .await
1075                .expect("valid usage");
1076
1077            match result {
1078                CaESchema::Ok(schema_id) => {
1079                    debug!(%id, %shard_id, %schema_id, "schema evolved successfully");
1080                    break;
1081                }
1082                CaESchema::Incompatible => bail!(
1083                    "incompatible schema evolution for {object:?}: \
1084                     {source_desc:?} -> {target_desc:?}"
1085                ),
1086                CaESchema::ExpectedMismatch {
1087                    schema_id: new_id,
1088                    key,
1089                    val: UnitSchema,
1090                } => {
1091                    schema_id = new_id;
1092                    source_desc = key;
1093                }
1094            }
1095        }
1096
1097        Ok(())
1098    }
1099
1100    /// Migrate the given objects using the `Replacement` mechanism.
1101    async fn migrate_replace(
1102        &self,
1103        objects: &[SystemObjectDescription],
1104    ) -> anyhow::Result<BTreeMap<GlobalId, ShardId>> {
1105        if objects.is_empty() {
1106            return Ok(Default::default());
1107        }
1108
1109        let diagnostics = Diagnostics {
1110            shard_name: "builtin_migration".to_string(),
1111            handle_purpose: format!("builtin schema migration @ {}", self.target_version),
1112        };
1113        let (mut persist_write, mut persist_read) =
1114            self.open_migration_shard(diagnostics.clone()).await;
1115
1116        let mut ids_to_replace = BTreeSet::new();
1117        for object in objects {
1118            if let Some(info) = self.system_objects.get(object) {
1119                ids_to_replace.insert(info.global_id);
1120            } else {
1121                bail!("missing id for builtin {object:?}");
1122            }
1123        }
1124
1125        info!(?objects, ?ids_to_replace, "migrating by replacement");
1126
1127        // Fetch replacement shard IDs from the migration shard, or insert new ones if none exist.
1128        // This can fail due to writes by concurrent processes, so we need to retry.
1129        let replaced_shards = loop {
1130            if let Some(shards) = self
1131                .try_get_or_insert_replacement_shards(
1132                    &ids_to_replace,
1133                    &mut persist_write,
1134                    &mut persist_read,
1135                )
1136                .await?
1137            {
1138                break shards;
1139            }
1140        };
1141
1142        Ok(replaced_shards)
1143    }
1144
1145    /// Try to get or insert replacement shards for the given IDs into the migration shard, at
1146    /// `target_version` and `deploy_generation`.
1147    ///
1148    /// This method looks for existing entries in the migration shards and returns those if they
1149    /// are present. Otherwise it generates new shard IDs and tries to insert them.
1150    ///
1151    /// The result of this call is `None` if no existing entries were found and inserting new ones
1152    /// failed because of a concurrent write to the migration shard. In this case, the caller is
1153    /// expected to retry.
1154    async fn try_get_or_insert_replacement_shards(
1155        &self,
1156        ids_to_replace: &BTreeSet<GlobalId>,
1157        persist_write: &mut WriteHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1158        persist_read: &mut ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1159    ) -> anyhow::Result<Option<BTreeMap<GlobalId, ShardId>>> {
1160        let upper = persist_write.fetch_recent_upper().await;
1161        let write_ts = *upper.as_option().expect("migration shard not sealed");
1162
1163        let mut ids_to_replace = ids_to_replace.clone();
1164        let mut replaced_shards = BTreeMap::new();
1165
1166        // Another process might already have done a shard replacement at our version and
1167        // generation, in which case we can directly reuse the replacement shards.
1168        //
1169        // Note that we can't assume that the previous process had the same `ids_to_replace` as we
1170        // do. The set of migrations to run depends on both the source and the target version, and
1171        // the migration shard is not keyed by source version. The previous writer might have seen
1172        // a different source version, if there was a concurrent migration by a leader process.
1173        if let Some(read_ts) = write_ts.step_back() {
1174            let pred = |key: &migration_shard::Key| {
1175                key.build_version == self.target_version
1176                    && key.deploy_generation == Some(self.deploy_generation)
1177            };
1178            if let Some(entries) = read_migration_shard(persist_read, read_ts, pred).await {
1179                for (key, shard_id) in entries {
1180                    let id = GlobalId::System(key.global_id);
1181                    if ids_to_replace.remove(&id) {
1182                        replaced_shards.insert(id, shard_id);
1183                    }
1184                }
1185
1186                debug!(
1187                    %read_ts, ?replaced_shards, ?ids_to_replace,
1188                    "found existing entries in migration shard",
1189                );
1190            }
1191
1192            if ids_to_replace.is_empty() {
1193                return Ok(Some(replaced_shards));
1194            }
1195        }
1196
1197        // Generate new shard IDs and attempt to insert them into the migration shard. If we get a
1198        // CaA failure at `write_ts` that means a concurrent process has inserted in the meantime
1199        // and we need to re-check the migration shard contents.
1200        let mut updates = Vec::new();
1201        for id in ids_to_replace {
1202            let shard_id = ShardId::new();
1203            replaced_shards.insert(id, shard_id);
1204
1205            let GlobalId::System(global_id) = id else {
1206                bail!("attempt to migrate a non-system collection: {id}");
1207            };
1208            let key = migration_shard::Key {
1209                global_id,
1210                build_version: self.target_version.clone(),
1211                deploy_generation: Some(self.deploy_generation),
1212            };
1213            updates.push(((key, shard_id), write_ts, 1));
1214        }
1215
1216        let upper = Antichain::from_elem(write_ts);
1217        let new_upper = Antichain::from_elem(write_ts.step_forward());
1218        debug!(%write_ts, "attempting insert into migration shard");
1219        let result = persist_write
1220            .compare_and_append(updates, upper, new_upper)
1221            .await
1222            .expect("valid usage");
1223
1224        match result {
1225            Ok(()) => {
1226                debug!(
1227                    %write_ts, ?replaced_shards,
1228                    "successfully inserted into migration shard"
1229                );
1230                Ok(Some(replaced_shards))
1231            }
1232            Err(_mismatch) => Ok(None),
1233        }
1234    }
1235
1236    /// Open writer and reader for the migration shard.
1237    async fn open_migration_shard(
1238        &self,
1239        diagnostics: Diagnostics,
1240    ) -> (
1241        WriteHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1242        ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1243    ) {
1244        let persist = &self.config.persist_client;
1245
1246        persist
1247            .open(
1248                self.migration_shard,
1249                Arc::new(migration_shard::KeySchema),
1250                Arc::new(ShardIdSchema),
1251                diagnostics,
1252                USE_CRITICAL_SINCE_CATALOG.get(persist.dyncfgs()),
1253            )
1254            .await
1255            .expect("valid usage")
1256    }
1257
1258    /// Open a [`SinceHandle`] for the migration shard.
1259    async fn open_migration_shard_since(
1260        &self,
1261        diagnostics: Diagnostics,
1262    ) -> SinceHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff> {
1263        self.config
1264            .persist_client
1265            .open_critical_since(
1266                self.migration_shard,
1267                // TODO: We may need to use a different critical reader
1268                // id for this if we want to be able to introspect it via SQL.
1269                PersistClient::CONTROLLER_CRITICAL_SINCE,
1270                Opaque::encode(&i64::MIN),
1271                diagnostics.clone(),
1272            )
1273            .await
1274            .expect("valid usage")
1275    }
1276
1277    /// Update the fingerprints for `migrated_items`.
1278    ///
1279    /// Returns the new fingerprints. Also asserts that the current fingerprints of all other
1280    /// system items match their builtin definitions.
1281    fn update_fingerprints(
1282        &self,
1283        migrated_items: &BTreeSet<SystemObjectDescription>,
1284    ) -> anyhow::Result<BTreeMap<SystemObjectDescription, String>> {
1285        let mut new_fingerprints = BTreeMap::new();
1286        for (object, object_info) in &self.system_objects {
1287            let id = object_info.global_id;
1288            let builtin = object_info.builtin;
1289
1290            let fingerprint = builtin.fingerprint();
1291            if fingerprint == object_info.fingerprint {
1292                continue; // fingerprint unchanged, nothing to do
1293            }
1294
1295            // Fingerprint mismatch is expected for a migrated item.
1296            let migrated = migrated_items.contains(object);
1297            // Some builtin types have schemas but no durable state. No migration needed for those.
1298            let ephemeral = matches!(
1299                builtin,
1300                Builtin::Log(_) | Builtin::View(_) | Builtin::Index(_),
1301            );
1302
1303            if migrated || ephemeral {
1304                new_fingerprints.insert(object.clone(), fingerprint);
1305            } else if builtin.runtime_alterable() {
1306                // Runtime alterable builtins have no meaningful builtin fingerprint, and a
1307                // sentinel value stored in the catalog.
1308                assert_eq!(
1309                    object_info.fingerprint, RUNTIME_ALTERABLE_FINGERPRINT_SENTINEL,
1310                    "fingerprint mismatch for runtime-alterable builtin {object:?} ({id})",
1311                );
1312            } else {
1313                panic!(
1314                    "fingerprint mismatch for builtin {builtin:?} ({id}): {} != {}",
1315                    fingerprint, object_info.fingerprint,
1316                );
1317            }
1318        }
1319
1320        Ok(new_fingerprints)
1321    }
1322
1323    /// Perform cleanup of migration state, i.e. the migration shard.
1324    ///
1325    /// Returns a list of shards to finalize, and a `Future` that must be run after the shard
1326    /// finalization has been durably enqueued. The `Future` is used to remove entries from the
1327    /// migration shard only after we know the respective shards will be finalized. Removing
1328    /// entries immediately would risk leaking the shards.
1329    ///
1330    /// We only perform cleanup in leader mode, to keep the durable state changes made by read-only
1331    /// processes a minimal as possible. Given that Materialize doesn't support version downgrades,
1332    /// it is safe to assume that any state for versions below the `target_version` is not needed
1333    /// anymore and can be cleaned up.
1334    ///
1335    /// Note that it is fine for cleanup to sometimes fail or be skipped. The size of the migration
1336    /// shard should always be pretty small, so keeping migration state around for longer isn't a
1337    /// concern. As a result, we can keep the logic simple here and skip doing cleanup in response
1338    /// to transient failures, instead of retrying.
1339    async fn cleanup(&self) -> anyhow::Result<(BTreeSet<ShardId>, BoxFuture<'static, ()>)> {
1340        let noop_action = async {}.boxed();
1341        let noop_result = (BTreeSet::new(), noop_action);
1342
1343        if self.config.read_only {
1344            return Ok(noop_result);
1345        }
1346
1347        let diagnostics = Diagnostics {
1348            shard_name: "builtin_migration".to_string(),
1349            handle_purpose: "builtin schema migration cleanup".into(),
1350        };
1351        let (mut persist_write, mut persist_read) =
1352            self.open_migration_shard(diagnostics.clone()).await;
1353        let mut persist_since = self.open_migration_shard_since(diagnostics.clone()).await;
1354
1355        let upper = persist_write.fetch_recent_upper().await.clone();
1356        let write_ts = *upper.as_option().expect("migration shard not sealed");
1357        let Some(read_ts) = write_ts.step_back() else {
1358            return Ok(noop_result);
1359        };
1360
1361        // Collect old entries to remove.
1362        let pred = |key: &migration_shard::Key| key.build_version < self.target_version;
1363        let Some(stale_entries) = read_migration_shard(&mut persist_read, read_ts, pred).await
1364        else {
1365            return Ok(noop_result);
1366        };
1367
1368        debug!(
1369            ?stale_entries,
1370            "cleaning migration shard up to version {}", self.target_version,
1371        );
1372
1373        let current_shards: BTreeMap<_, _> = self
1374            .system_objects
1375            .values()
1376            .filter_map(|o| o.shard_id.map(|shard_id| (o.global_id, shard_id)))
1377            .collect();
1378
1379        let mut shards_to_finalize = BTreeSet::new();
1380        let mut retractions = Vec::new();
1381        for (key, shard_id) in stale_entries {
1382            // The migration shard contains both shards created during aborted upgrades and shards
1383            // created during successful upgrades. The latter may still be in use, so we have to
1384            // check and only finalize those that aren't anymore.
1385            let gid = GlobalId::System(key.global_id);
1386            if current_shards.get(&gid) != Some(&shard_id) {
1387                shards_to_finalize.insert(shard_id);
1388            }
1389
1390            retractions.push(((key, shard_id), write_ts, -1));
1391        }
1392
1393        let cleanup_action = async move {
1394            if !retractions.is_empty() {
1395                let new_upper = Antichain::from_elem(write_ts.step_forward());
1396                let result = persist_write
1397                    .compare_and_append(retractions, upper, new_upper)
1398                    .await
1399                    .expect("valid usage");
1400                match result {
1401                    Ok(()) => debug!("cleaned up migration shard"),
1402                    Err(mismatch) => debug!(?mismatch, "migration shard cleanup failed"),
1403                }
1404            }
1405        }
1406        .boxed();
1407
1408        // Downgrade the since, to enable some compaction.
1409        let o = persist_since.opaque().clone();
1410        let new_since = Antichain::from_elem(read_ts);
1411        let result = persist_since
1412            .maybe_compare_and_downgrade_since(&o, (&o, &new_since))
1413            .await;
1414        soft_assert_or_log!(result.is_none_or(|r| r.is_ok()), "opaque mismatch");
1415
1416        Ok((shards_to_finalize, cleanup_action))
1417    }
1418}
1419
1420/// Read the migration shard at the given timestamp, returning all entries that match the given
1421/// predicate.
1422///
1423/// Returns `None` if the migration shard contains no matching entries, or if it isn't readable at
1424/// `read_ts`.
1425async fn read_migration_shard<P>(
1426    persist_read: &mut ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1427    read_ts: Timestamp,
1428    predicate: P,
1429) -> Option<Vec<(migration_shard::Key, ShardId)>>
1430where
1431    P: Fn(&migration_shard::Key) -> bool,
1432{
1433    let as_of = Antichain::from_elem(read_ts);
1434    let updates = persist_read.snapshot_and_fetch(as_of).await.ok()?;
1435
1436    assert!(
1437        updates.iter().all(|(_, _, diff)| *diff == 1),
1438        "migration shard contains invalid diffs: {updates:?}",
1439    );
1440
1441    let entries: Vec<_> = updates
1442        .into_iter()
1443        .map(|(data, _, _)| data)
1444        .filter(move |(key, _)| predicate(key))
1445        .collect();
1446
1447    (!entries.is_empty()).then_some(entries)
1448}
1449
1450/// A plan to migrate between two versions.
1451#[derive(Debug, Default)]
1452struct Plan {
1453    /// Objects to migrate using the `Evolution` mechanism.
1454    evolve: Vec<SystemObjectDescription>,
1455    /// Objects to migrate using the `Replacement` mechanism.
1456    replace: Vec<SystemObjectDescription>,
1457}
1458
1459/// Types and persist codec impls for the migration shard used by the `Replacement` mechanism.
1460mod migration_shard {
1461    use std::fmt;
1462    use std::str::FromStr;
1463
1464    use arrow::array::{StringArray, StringBuilder};
1465    use bytes::{BufMut, Bytes};
1466    use mz_persist_types::Codec;
1467    use mz_persist_types::codec_impls::{
1468        SimpleColumnarData, SimpleColumnarDecoder, SimpleColumnarEncoder,
1469    };
1470    use mz_persist_types::columnar::Schema;
1471    use mz_persist_types::stats::NoneStats;
1472    use semver::Version;
1473    use serde::{Deserialize, Serialize};
1474
1475    #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1476    pub(super) struct Key {
1477        pub(super) global_id: u64,
1478        pub(super) build_version: Version,
1479        // Versions < 26.0 didn't include the deploy generation. As long as we still might
1480        // encounter migration shard entries that don't have it, we need to keep this an `Option`
1481        // and keep supporting both key formats.
1482        pub(super) deploy_generation: Option<u64>,
1483    }
1484
1485    impl fmt::Display for Key {
1486        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1487            if self.deploy_generation.is_some() {
1488                // current format
1489                let s = serde_json::to_string(self).expect("JSON serializable");
1490                f.write_str(&s)
1491            } else {
1492                // pre-26.0 format
1493                write!(f, "{}-{}", self.global_id, self.build_version)
1494            }
1495        }
1496    }
1497
1498    impl FromStr for Key {
1499        type Err = String;
1500
1501        fn from_str(s: &str) -> Result<Self, String> {
1502            // current format
1503            if let Ok(key) = serde_json::from_str(s) {
1504                return Ok(key);
1505            };
1506
1507            // pre-26.0 format
1508            let parts: Vec<_> = s.splitn(2, '-').collect();
1509            let &[global_id, build_version] = parts.as_slice() else {
1510                return Err(format!("invalid Key '{s}'"));
1511            };
1512            let global_id = global_id.parse::<u64>().map_err(|e| e.to_string())?;
1513            let build_version = build_version
1514                .parse::<Version>()
1515                .map_err(|e| e.to_string())?;
1516            Ok(Key {
1517                global_id,
1518                build_version,
1519                deploy_generation: None,
1520            })
1521        }
1522    }
1523
1524    impl Default for Key {
1525        fn default() -> Self {
1526            Self {
1527                global_id: Default::default(),
1528                build_version: Version::new(0, 0, 0),
1529                deploy_generation: Some(0),
1530            }
1531        }
1532    }
1533
1534    impl Codec for Key {
1535        type Schema = KeySchema;
1536        type Storage = ();
1537
1538        fn codec_name() -> String {
1539            "TableKey".into()
1540        }
1541
1542        fn encode<B: BufMut>(&self, buf: &mut B) {
1543            buf.put(self.to_string().as_bytes())
1544        }
1545
1546        fn decode<'a>(buf: &'a [u8], _schema: &KeySchema) -> Result<Self, String> {
1547            let s = str::from_utf8(buf).map_err(|e| e.to_string())?;
1548            s.parse()
1549        }
1550
1551        fn encode_schema(_schema: &KeySchema) -> Bytes {
1552            Bytes::new()
1553        }
1554
1555        fn decode_schema(buf: &Bytes) -> Self::Schema {
1556            assert_eq!(*buf, Bytes::new());
1557            KeySchema
1558        }
1559    }
1560
1561    impl SimpleColumnarData for Key {
1562        type ArrowBuilder = StringBuilder;
1563        type ArrowColumn = StringArray;
1564
1565        fn goodbytes(builder: &Self::ArrowBuilder) -> usize {
1566            builder.values_slice().len()
1567        }
1568
1569        fn push(&self, builder: &mut Self::ArrowBuilder) {
1570            builder.append_value(&self.to_string());
1571        }
1572
1573        fn push_null(builder: &mut Self::ArrowBuilder) {
1574            builder.append_null();
1575        }
1576
1577        fn read(&mut self, idx: usize, column: &Self::ArrowColumn) {
1578            *self = column.value(idx).parse().expect("valid Key");
1579        }
1580    }
1581
1582    #[derive(Debug, PartialEq)]
1583    pub(super) struct KeySchema;
1584
1585    impl Schema<Key> for KeySchema {
1586        type ArrowColumn = StringArray;
1587        type Statistics = NoneStats;
1588        type Decoder = SimpleColumnarDecoder<Key>;
1589        type Encoder = SimpleColumnarEncoder<Key>;
1590
1591        fn encoder(&self) -> anyhow::Result<SimpleColumnarEncoder<Key>> {
1592            Ok(SimpleColumnarEncoder::default())
1593        }
1594
1595        fn decoder(&self, col: StringArray) -> anyhow::Result<SimpleColumnarDecoder<Key>> {
1596            Ok(SimpleColumnarDecoder::new(col))
1597        }
1598    }
1599}
1600
1601#[cfg(test)]
1602#[path = "builtin_schema_migration_tests.rs"]
1603mod tests;