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