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_COMPUTE_HYDRATION_TIMES_DESCRIPTION,
41    MZ_OBJECT_ARRANGEMENT_SIZE_HISTORY_DESCRIPTION, MZ_OBJECT_HYDRATION_HISTORY,
42    MZ_OBJECT_HYDRATION_HISTORY_DESCRIPTION, MZ_REPLICA_HYDRATION_HISTORY,
43    MZ_REPLICA_HYDRATION_HISTORY_DESCRIPTION, MZ_STORAGE_USAGE_BY_SHARD,
44    MZ_STORAGE_USAGE_BY_SHARD_DESCRIPTION, 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        // `mz_type_pg_metadata` gained a trailing `typlen` column. See the NOTE
338        // above: this version must stay at the workspace's current dev version
339        // until the change ships.
340        MigrationStep::replacement(
341            "26.47.0-dev.0",
342            CatalogItemType::Table,
343            MZ_INTERNAL_SCHEMA,
344            "mz_type_pg_metadata",
345        ),
346        // Converting the connection-detail builtin tables to materialized views
347        // changes their catalog fingerprint, so each needs an explicit
348        // replacement step.
349        MigrationStep::replacement(
350            "26.37.0-dev.0",
351            CatalogItemType::MaterializedView,
352            MZ_CATALOG_SCHEMA,
353            "mz_kafka_connections",
354        ),
355        MigrationStep::replacement(
356            "26.37.0-dev.0",
357            CatalogItemType::MaterializedView,
358            MZ_CATALOG_SCHEMA,
359            "mz_ssh_tunnel_connections",
360        ),
361        MigrationStep::replacement(
362            "26.37.0-dev.0",
363            CatalogItemType::MaterializedView,
364            MZ_INTERNAL_SCHEMA,
365            "mz_aws_connections",
366        ),
367        MigrationStep::replacement(
368            "26.37.0-dev.0",
369            CatalogItemType::MaterializedView,
370            MZ_CATALOG_SCHEMA,
371            "mz_aws_privatelink_connections",
372        ),
373        // The three sink tables became materialized views, which moves their
374        // fingerprints. The old `0.160.0` `mz_sinks` step had to go at the same
375        // time, because it names the `Table` description and that no longer
376        // resolves. Nothing is lost: anything that needed the old step upgrades
377        // from further back than this one, so this one covers it too.
378        MigrationStep::replacement(
379            "26.38.0-dev.0",
380            CatalogItemType::MaterializedView,
381            MZ_CATALOG_SCHEMA,
382            "mz_sinks",
383        ),
384        MigrationStep::replacement(
385            "26.38.0-dev.0",
386            CatalogItemType::MaterializedView,
387            MZ_CATALOG_SCHEMA,
388            "mz_kafka_sinks",
389        ),
390        MigrationStep::replacement(
391            "26.38.0-dev.0",
392            CatalogItemType::MaterializedView,
393            MZ_CATALOG_SCHEMA,
394            "mz_iceberg_sinks",
395        ),
396        // The mz_cluster_reconfigurations MV definition changed (the `changes`
397        // diff now includes the `arrangement_compression` dimension).
398        MigrationStep::replacement(
399            "26.38.0-rc.2",
400            CatalogItemType::MaterializedView,
401            MZ_INTERNAL_SCHEMA,
402            "mz_cluster_reconfigurations",
403        ),
404        // The mz_audit_events MV gained a `metric-sink` arm in its object_type
405        // CASE, changing its SQL fingerprint, so it needs an explicit
406        // replacement step. See the NOTE above: this version must stay at the
407        // workspace's current dev version until the change ships.
408        MigrationStep::replacement(
409            "26.39.0-dev.0",
410            CatalogItemType::MaterializedView,
411            MZ_CATALOG_SCHEMA,
412            "mz_audit_events",
413        ),
414        // Required because we added the `mz_metric_sinks_ind` builtin index.
415        // make_mz_indexes inlines the builtin-index set as VALUES, so any add or
416        // remove changes its SQL fingerprint and requires an explicit
417        // replacement. See the NOTE above: this version must stay at the
418        // workspace's current dev version until the change ships. The addition
419        // is unshipped, so this step supersedes the earlier `26.39.0-dev.0`
420        // `mz_indexes` step (which covered `mz_object_graph_edges_ind`): a
421        // replacement recreates `mz_indexes` from its current definition, so a
422        // single step at the current dev version covers every earlier change too.
423        MigrationStep::replacement(
424            "26.40.0-dev.0",
425            CatalogItemType::MaterializedView,
426            MZ_CATALOG_SCHEMA,
427            "mz_indexes",
428        ),
429        // Converting mz_tables and mz_views from builtin tables to
430        // materialized views over mz_catalog_raw changes their catalog
431        // fingerprints, so each needs an explicit replacement step. See the
432        // NOTE above: this version must stay at the workspace's current dev
433        // version until the change ships.
434        MigrationStep::replacement(
435            "26.39.0-dev.0",
436            CatalogItemType::MaterializedView,
437            MZ_CATALOG_SCHEMA,
438            "mz_tables",
439        ),
440        MigrationStep::replacement(
441            "26.39.0-dev.0",
442            CatalogItemType::MaterializedView,
443            MZ_CATALOG_SCHEMA,
444            "mz_views",
445        ),
446        // Required because we added the `mz_cluster_replica_resource_usage` builtin log.
447        // make_mz_indexes and make_mz_sources inline the builtin-log set as
448        // VALUES, so adding one changes both MVs' SQL fingerprints. See the NOTE
449        // above: this version must stay at the workspace's current dev version
450        // until the change ships.
451        MigrationStep::replacement(
452            "26.40.0-dev.0",
453            CatalogItemType::MaterializedView,
454            MZ_CATALOG_SCHEMA,
455            "mz_indexes",
456        ),
457        MigrationStep::replacement(
458            "26.40.0-dev.0",
459            CatalogItemType::MaterializedView,
460            MZ_CATALOG_SCHEMA,
461            "mz_sources",
462        ),
463        // Converting mz_object_dependencies from a builtin table to a
464        // materialized view over mz_catalog_raw changes its catalog
465        // fingerprint, so it needs an explicit replacement step. See the
466        // NOTE above: this version must stay at the workspace's current dev
467        // version until the change ships.
468        MigrationStep::replacement(
469            "26.41.0-dev.0",
470            CatalogItemType::MaterializedView,
471            MZ_INTERNAL_SCHEMA,
472            "mz_object_dependencies",
473        ),
474        // Adding the `swap_bytes` column to `mz_cluster_replica_metrics_history`
475        // is a backward-compatible append, so the shard schema can evolve in
476        // place. See the NOTE above: this version must stay at the workspace's
477        // current dev version until the change ships.
478        MigrationStep::evolution(
479            "26.43.0-dev.0",
480            CatalogItemType::Source,
481            MZ_INTERNAL_SCHEMA,
482            "mz_cluster_replica_metrics_history",
483        ),
484        MigrationStep::evolution(
485            "26.43.0-dev.0",
486            CatalogItemType::Table,
487            MZ_INTERNAL_SCHEMA,
488            "mz_replica_hydration_history",
489        ),
490        // Converting mz_object_global_ids from a builtin table to a
491        // materialized view over mz_catalog_raw changes its catalog
492        // fingerprint.
493        MigrationStep::replacement(
494            "26.44.0-dev.0",
495            CatalogItemType::MaterializedView,
496            MZ_INTERNAL_SCHEMA,
497            "mz_object_global_ids",
498        ),
499        // Converting mz_source_references from a builtin table to a
500        // materialized view over mz_catalog_raw changes its catalog
501        // fingerprint.
502        MigrationStep::replacement(
503            "26.45.0-dev.0",
504            CatalogItemType::MaterializedView,
505            MZ_INTERNAL_SCHEMA,
506            "mz_source_references",
507        ),
508        // Converting mz_replacements from a builtin table to a
509        // materialized view over mz_catalog_raw changes its catalog
510        // fingerprint.
511        MigrationStep::replacement(
512            "26.46.0-dev.0",
513            CatalogItemType::MaterializedView,
514            MZ_INTERNAL_SCHEMA,
515            "mz_replacements",
516        ),
517        // mz_indexes reads builtin indexes from mz_builtin_indexes
518        // and builtin logs from mz_builtin_log_indexes instead of
519        // inlining them, which changes its SQL fingerprint.
520        MigrationStep::replacement(
521            "26.47.0-dev.0",
522            CatalogItemType::MaterializedView,
523            MZ_CATALOG_SCHEMA,
524            "mz_indexes",
525        ),
526        // mz_sources reads builtin sources from mz_builtin_sources
527        // and builtin logs from mz_builtin_log_indexes
528        // instead of inlining them, which changes its SQL fingerprint.
529        MigrationStep::replacement(
530            "26.47.0-dev.0",
531            CatalogItemType::MaterializedView,
532            MZ_CATALOG_SCHEMA,
533            "mz_sources",
534        ),
535        // Converting mz_history_retention_strategies from a builtin table to a
536        // materialized view over mz_catalog_raw changes its catalog
537        // fingerprint.
538        MigrationStep::replacement(
539            "26.47.0-dev.0",
540            CatalogItemType::MaterializedView,
541            MZ_INTERNAL_SCHEMA,
542            "mz_history_retention_strategies",
543        ),
544        // Converting mz_materialized_view_refresh_strategies from a builtin table to a
545        // materialized view over mz_catalog_raw changes its catalog
546        // fingerprint. We also change columns:
547        // - aligned_at (timestamptz) -> aligned_to_expression (text)
548        // - at (timestamptz)-> at_expression (text)
549        MigrationStep::replacement(
550            "26.47.0-dev.0",
551            CatalogItemType::MaterializedView,
552            MZ_INTERNAL_SCHEMA,
553            "mz_materialized_view_refresh_strategies",
554        ),
555    ]
556});
557
558/// A migration required to upgrade past a specific version.
559#[derive(Clone, Debug)]
560struct MigrationStep {
561    /// The build version that requires this migration.
562    version: Version,
563    /// The object that requires migration.
564    object: SystemObjectDescription,
565    /// The migration mechanism to be used.
566    mechanism: Mechanism,
567}
568
569impl MigrationStep {
570    /// Helper to construct an `Evolution` migration step.
571    fn evolution(version: &str, type_: CatalogItemType, schema: &str, name: &str) -> Self {
572        Self {
573            version: Version::parse(version).expect("valid"),
574            object: SystemObjectDescription {
575                schema_name: schema.into(),
576                object_type: type_,
577                object_name: name.into(),
578            },
579            mechanism: Mechanism::Evolution,
580        }
581    }
582
583    /// Helper to construct a `Replacement` migration step.
584    fn replacement(version: &str, type_: CatalogItemType, schema: &str, name: &str) -> Self {
585        Self {
586            version: Version::parse(version).expect("valid"),
587            object: SystemObjectDescription {
588                schema_name: schema.into(),
589                object_type: type_,
590                object_name: name.into(),
591            },
592            mechanism: Mechanism::Replacement,
593        }
594    }
595}
596
597/// The mechanism to use to migrate the schema of a builtin collection.
598#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
599#[allow(dead_code)]
600enum Mechanism {
601    /// Persist schema evolution.
602    ///
603    /// Keeps existing contents but only works for schema changes that are backward compatible
604    /// according to [`backward_compatible`].
605    Evolution,
606    /// Shard replacement.
607    ///
608    /// Works for arbitrary schema changes but loses existing contents.
609    Replacement,
610}
611
612/// The result of a builtin schema migration.
613pub(super) struct MigrationResult {
614    /// IDs of items whose shards have been replaced using the `Replacement` mechanism.
615    pub replaced_items: BTreeSet<CatalogItemId>,
616    /// A cleanup action to take once the migration has been made durable.
617    pub cleanup_action: BoxFuture<'static, ()>,
618}
619
620impl Default for MigrationResult {
621    fn default() -> Self {
622        Self {
623            replaced_items: Default::default(),
624            cleanup_action: async {}.boxed(),
625        }
626    }
627}
628
629/// Run builtin schema migrations.
630///
631/// This is the entry point used by adapter when opening the catalog. It uses the hardcoded
632/// `BUILTINS` and `MIGRATIONS` lists to initialize the lists of available builtins and required
633/// migrations, respectively.
634pub(super) async fn run(
635    build_info: &BuildInfo,
636    deploy_generation: u64,
637    txn: &mut Transaction<'_>,
638    config: BuiltinItemMigrationConfig,
639) -> Result<MigrationResult, Error> {
640    // Sanity check to ensure we're not touching durable state in read-only mode.
641    assert_eq!(config.read_only, txn.is_savepoint());
642
643    // Tests may provide a dummy build info that confuses the migration step selection logic. Skip
644    // migrations if we observe this build info.
645    if *build_info == DUMMY_BUILD_INFO {
646        return Ok(MigrationResult::default());
647    }
648
649    let Some(durable_version) = get_migration_version(txn) else {
650        // New catalog; nothing to do.
651        return Ok(MigrationResult::default());
652    };
653    let build_version = build_info.semver_version();
654
655    let collection_metadata = txn.get_collection_metadata();
656    let system_objects = txn
657        .get_system_object_mappings()
658        .map(|m| {
659            let object = m.description;
660            let global_id = m.unique_identifier.global_id;
661            let shard_id = collection_metadata.get(&global_id).copied();
662            let Some((_, builtin)) = BUILTIN_LOOKUP.get(&object) else {
663                panic!("missing builtin {object:?}");
664            };
665            let info = ObjectInfo {
666                global_id,
667                shard_id,
668                builtin,
669                fingerprint: m.unique_identifier.fingerprint,
670            };
671            (object, info)
672        })
673        .collect();
674
675    let migration_shard = txn.get_builtin_migration_shard().expect("must exist");
676
677    let migration = Migration {
678        source_version: durable_version.clone(),
679        target_version: build_version.clone(),
680        deploy_generation,
681        system_objects,
682        migration_shard,
683        config,
684    };
685
686    let result = migration.run(&MIGRATIONS).await.map_err(|e| {
687        Error::new(ErrorKind::FailedBuiltinSchemaMigration {
688            last_seen_version: durable_version.to_string(),
689            this_version: build_version.to_string(),
690            cause: e.to_string(),
691        })
692    })?;
693
694    result.apply(txn);
695
696    let replaced_items = txn
697        .get_system_object_mappings()
698        .map(|m| m.unique_identifier)
699        .filter(|ids| result.new_shards.contains_key(&ids.global_id))
700        .map(|ids| ids.catalog_id)
701        .collect();
702
703    Ok(MigrationResult {
704        replaced_items,
705        cleanup_action: result.cleanup_action,
706    })
707}
708
709/// Result produced by `Migration::run`.
710struct MigrationRunResult {
711    new_shards: BTreeMap<GlobalId, ShardId>,
712    new_fingerprints: BTreeMap<SystemObjectDescription, String>,
713    shards_to_finalize: BTreeSet<ShardId>,
714    cleanup_action: BoxFuture<'static, ()>,
715}
716
717impl Default for MigrationRunResult {
718    fn default() -> Self {
719        Self {
720            new_shards: BTreeMap::new(),
721            new_fingerprints: BTreeMap::new(),
722            shards_to_finalize: BTreeSet::new(),
723            cleanup_action: async {}.boxed(),
724        }
725    }
726}
727
728impl MigrationRunResult {
729    /// Apply this migration result to the given transaction.
730    fn apply(&self, txn: &mut Transaction<'_>) {
731        // Update collection metadata.
732        let replaced_ids = self.new_shards.keys().copied().collect();
733        let old_metadata = txn.delete_collection_metadata(replaced_ids);
734        txn.insert_collection_metadata(self.new_shards.clone())
735            .expect("inserting unique shards IDs after deleting existing entries");
736
737        // Register shards for finalization.
738        let mut unfinalized_shards: BTreeSet<_> =
739            old_metadata.into_iter().map(|(_, sid)| sid).collect();
740        unfinalized_shards.extend(self.shards_to_finalize.iter().copied());
741        txn.insert_unfinalized_shards(unfinalized_shards)
742            .expect("cannot fail");
743
744        // Update fingerprints.
745        let mappings = txn
746            .get_system_object_mappings()
747            .filter_map(|m| {
748                let fingerprint = self.new_fingerprints.get(&m.description)?;
749                Some(SystemObjectMapping {
750                    description: m.description,
751                    unique_identifier: SystemObjectUniqueIdentifier {
752                        catalog_id: m.unique_identifier.catalog_id,
753                        global_id: m.unique_identifier.global_id,
754                        fingerprint: fingerprint.clone(),
755                    },
756                })
757            })
758            .collect();
759        txn.set_system_object_mappings(mappings)
760            .expect("filtered existing mappings remain unique");
761    }
762}
763
764/// Information about a system object required to run a `Migration`.
765#[derive(Clone, Debug)]
766struct ObjectInfo {
767    global_id: GlobalId,
768    shard_id: Option<ShardId>,
769    builtin: &'static Builtin<NameReference>,
770    fingerprint: String,
771}
772
773/// Context of a builtin schema migration.
774struct Migration {
775    /// The version we are migrating from.
776    ///
777    /// Same as the build version of the most recent leader process that successfully performed
778    /// migrations.
779    source_version: Version,
780    /// The version we are migration to.
781    ///
782    /// Same as the build version of this process.
783    target_version: Version,
784    /// The deploy generation of this process.
785    deploy_generation: u64,
786    /// Information about all objects in the system.
787    system_objects: BTreeMap<SystemObjectDescription, ObjectInfo>,
788    /// The ID of the migration shard.
789    migration_shard: ShardId,
790    /// Additional configuration.
791    config: BuiltinItemMigrationConfig,
792}
793
794/// Whether `builtin` participates in a forced migration using `mechanism`.
795fn participates_in_forced_migration(
796    builtin: &Builtin<NameReference>,
797    mechanism: Mechanism,
798) -> bool {
799    use Builtin::*;
800    match builtin {
801        // A forced replacement allocates a fresh shard, which discards the
802        // table's contents. Exclude the tables whose contents are the point:
803        // storage usage is retained for billing, and hydration histories cannot
804        // be rebuilt from any other source.
805        //
806        // Hydration history tables take part in a forced `Evolution`, which
807        // keeps the rows. They have to: dev upgrades force one for every object,
808        // and a table left out of the plan never gets its new schema registered.
809        // `update_fingerprints` then panics at open as soon as the desc changes.
810        // See the tripwire in `validate_migration_steps` for how to give up the
811        // replacement exemption deliberately.
812        Table(table) => {
813            **table != *MZ_STORAGE_USAGE_BY_SHARD
814                && (mechanism != Mechanism::Replacement
815                    || (**table != *MZ_OBJECT_HYDRATION_HISTORY
816                        && **table != *MZ_REPLICA_HYDRATION_HISTORY))
817        }
818        MaterializedView(..) => true,
819        Source(source) => **source != *MZ_CATALOG_RAW,
820        Log(..) | View(..) | Type(..) | Func(..) | Index(..) | Connection(..) => false,
821    }
822}
823
824impl Migration {
825    async fn run(self, steps: &[MigrationStep]) -> anyhow::Result<MigrationRunResult> {
826        info!(
827            deploy_generation = %self.deploy_generation,
828            "running builtin schema migration: {} -> {}",
829            self.source_version, self.target_version
830        );
831
832        self.validate_migration_steps(steps);
833
834        // Version-based migration filter fails for dev versions, see for example
835        // https://github.com/MaterializeInc/database-issues/issues/11335
836        let force_migration = if self.source_version != self.target_version
837            && self.source_version.pre.as_str().starts_with("dev")
838            && self.config.force_migration.is_none()
839        {
840            Some("evolution".to_string())
841        } else {
842            self.config.force_migration.clone()
843        };
844
845        let (force, plan) = match force_migration.as_deref() {
846            None => (false, self.plan_migration(steps)),
847            Some("evolution") => (true, self.plan_forced_migration(Mechanism::Evolution)),
848            Some("replacement") => (true, self.plan_forced_migration(Mechanism::Replacement)),
849            Some(other) => panic!("unknown force migration mechanism: {other}"),
850        };
851        let plan = self.drop_shardless(plan);
852
853        if self.source_version == self.target_version && !force {
854            info!("skipping migration: already at target version");
855            return Ok(MigrationRunResult::default());
856        } else if self.source_version > self.target_version {
857            bail!("downgrade not supported");
858        }
859
860        // In leader mode, upgrade the version of the migration shard to the target version.
861        // This fences out any readers at lower versions.
862        if !self.config.read_only {
863            self.upgrade_migration_shard_version().await;
864        }
865
866        info!("executing migration plan: {plan:?}");
867
868        self.migrate_evolve(&plan.evolve).await?;
869        let new_shards = self.migrate_replace(&plan.replace).await?;
870
871        let mut migrated_objects = BTreeSet::new();
872        migrated_objects.extend(plan.evolve);
873        migrated_objects.extend(plan.replace);
874
875        let new_fingerprints = self.update_fingerprints(&migrated_objects)?;
876
877        let (shards_to_finalize, cleanup_action) = self.cleanup().await?;
878
879        Ok(MigrationRunResult {
880            new_shards,
881            new_fingerprints,
882            shards_to_finalize,
883            cleanup_action,
884        })
885    }
886
887    /// Sanity check the given migration steps.
888    ///
889    /// If any of these checks fail, that's a bug in Materialize, and we panic immediately.
890    fn validate_migration_steps(&self, steps: &[MigrationStep]) {
891        for step in steps {
892            assert!(
893                step.version <= self.target_version,
894                "migration step version greater than target version: {} > {}",
895                step.version,
896                self.target_version,
897            );
898
899            let object = &step.object;
900
901            // `mz_storage_usage_by_shard` cannot be migrated for multiple reasons. Firstly, it would
902            // cause the table to be truncated because the contents are not also stored in the durable
903            // catalog. Secondly, we prune `mz_storage_usage_by_shard` of old events in the background
904            // on startup. The correctness of that pruning relies on there being no other retractions
905            // to `mz_storage_usage_by_shard`.
906            //
907            // TODO: Confirm the above reasoning, it might be outdated?
908            assert_ne!(
909                &*MZ_STORAGE_USAGE_BY_SHARD_DESCRIPTION, object,
910                "mz_storage_usage_by_shard cannot be migrated or else the table will be truncated"
911            );
912
913            // Same hazard as `mz_storage_usage_by_shard`: the startup pruner
914            // (`Coordinator::prune_arrangement_sizes_history_on_startup`) assumes it is
915            // the only source of retractions, so migration-driven truncation would break it.
916            assert_ne!(
917                &*MZ_OBJECT_ARRANGEMENT_SIZE_HISTORY_DESCRIPTION, object,
918                "mz_object_arrangement_size_history cannot be migrated or else the table will be truncated"
919            );
920
921            // Unlike the two tables above, there is no correctness hazard here, a
922            // truncation would only lose history. This is a tripwire so that the
923            // loss is chosen rather than stumbled into: if a schema change to
924            // this table is worth clearing it for, remove this assert along with
925            // the exemption in `plan_forced_migration`, and say in the release
926            // notes that the history restarts.
927            //
928            // Only `Replacement` loses the rows. An `Evolution` keeps the shard
929            // and its contents, so it is the path a schema change to this table
930            // should take and we let it through.
931            if step.mechanism == Mechanism::Replacement {
932                assert_ne!(
933                    &*MZ_OBJECT_HYDRATION_HISTORY_DESCRIPTION, object,
934                    "replacing mz_object_hydration_history clears it, see the comment above"
935                );
936                assert_ne!(
937                    &*MZ_REPLICA_HYDRATION_HISTORY_DESCRIPTION, object,
938                    "replacing mz_replica_hydration_history clears it, see the comment above"
939                );
940            }
941
942            // `mz_catalog_raw` cannot be migrated because it contains the durable catalog and it
943            // wouldn't be very durable if we allowed it to be truncated.
944            assert_ne!(
945                &*MZ_CATALOG_RAW_DESCRIPTION, object,
946                "mz_catalog_raw cannot be migrated"
947            );
948
949            // The 0dt caught-up gate reads the leader's `mz_cluster_replica_frontiers` shard for
950            // the live frontiers it checks every collection against. Migrating it via `Replacement`
951            // hands us a fresh shard we write ourselves, so the gate would compare us against
952            // ourselves instead of against the leader.
953            assert_ne!(
954                &*MZ_CLUSTER_REPLICA_FRONTIERS_DESCRIPTION, object,
955                "mz_cluster_replica_frontiers cannot be migrated or else the 0dt caught-up gate loses its live-frontier reference"
956            );
957            assert_ne!(
958                &*MZ_COMPUTE_HYDRATION_TIMES_DESCRIPTION, object,
959                "mz_compute_hydration_times cannot be migrated or else the 0dt caught-up gate loses its leader-hydration reference"
960            );
961
962            let Some(object_info) = self.system_objects.get(object) else {
963                panic!("migration step for non-existent builtin: {object:?}");
964            };
965
966            let builtin = object_info.builtin;
967            use Builtin::*;
968            assert!(
969                matches!(builtin, Table(..) | Source(..) | MaterializedView(..)),
970                "schema migration not supported for builtin: {builtin:?}",
971            );
972        }
973    }
974
975    /// Select for each object to migrate the appropriate migration mechanism.
976    fn plan_migration(&self, steps: &[MigrationStep]) -> Plan {
977        // Ignore any steps at versions before `source_version`.
978        let steps = steps.iter().filter(|s| s.version > self.source_version);
979
980        // Select a mechanism for each object, according to the requested migrations:
981        //  * If any `Replacement` was requested, use `Replacement`.
982        //  * Otherwise, (i.e. only `Evolution` was requested), use `Evolution`.
983        let mut by_object = BTreeMap::new();
984        for step in steps {
985            if let Some(entry) = by_object.get_mut(&step.object) {
986                *entry = match (step.mechanism, *entry) {
987                    (Mechanism::Evolution, Mechanism::Evolution) => Mechanism::Evolution,
988                    (Mechanism::Replacement, _) | (_, Mechanism::Replacement) => {
989                        Mechanism::Replacement
990                    }
991                };
992            } else {
993                by_object.insert(step.object.clone(), step.mechanism);
994            }
995        }
996
997        let mut plan = Plan::default();
998        for (object, mechanism) in by_object {
999            match mechanism {
1000                Mechanism::Evolution => plan.evolve.push(object),
1001                Mechanism::Replacement => plan.replace.push(object),
1002            }
1003        }
1004
1005        plan
1006    }
1007
1008    /// Plan a forced migration of all objects using the given mechanism.
1009    fn plan_forced_migration(&self, mechanism: Mechanism) -> Plan {
1010        let objects = self
1011            .system_objects
1012            .iter()
1013            .filter(|(_, info)| participates_in_forced_migration(info.builtin, mechanism))
1014            .map(|(object, _)| object.clone())
1015            .collect();
1016
1017        let mut plan = Plan::default();
1018        match mechanism {
1019            Mechanism::Evolution => plan.evolve = objects,
1020            Mechanism::Replacement => plan.replace = objects,
1021        }
1022
1023        plan
1024    }
1025
1026    /// Drop objects that have no shard registered from the plan.
1027    ///
1028    /// A builtin without a shard does not exist in persist yet: either it was added in the target
1029    /// version, or the source version predates it entirely (an upgrade from before the builtin
1030    /// was introduced can reach an `Evolution` step registered for a later version). In both
1031    /// cases the leader allocates the shard during bootstrap, at the target schema, and there is
1032    /// nothing to evolve or replace. Applied to every plan, forced or versioned, so that both
1033    /// paths agree; a read-only process that reached `migrate_evolve_one` with such an object
1034    /// would otherwise bail out and crash-loop.
1035    fn drop_shardless(&self, mut plan: Plan) -> Plan {
1036        let has_shard = |object: &SystemObjectDescription| {
1037            self.system_objects
1038                .get(object)
1039                .is_none_or(|info| info.shard_id.is_some())
1040        };
1041        plan.evolve.retain(has_shard);
1042        plan.replace.retain(has_shard);
1043        plan
1044    }
1045
1046    /// Upgrade the migration shard to the target version.
1047    async fn upgrade_migration_shard_version(&self) {
1048        let persist = &self.config.persist_client;
1049        let diagnostics = Diagnostics {
1050            shard_name: "builtin_migration".to_string(),
1051            handle_purpose: format!("migration shard upgrade @ {}", self.target_version),
1052        };
1053
1054        persist
1055            .upgrade_version::<migration_shard::Key, ShardId, Timestamp, StorageDiff>(
1056                self.migration_shard,
1057                diagnostics,
1058            )
1059            .await
1060            .expect("valid usage");
1061    }
1062
1063    /// Migrate the given objects using the `Evolution` mechanism.
1064    async fn migrate_evolve(&self, objects: &[SystemObjectDescription]) -> anyhow::Result<()> {
1065        for object in objects {
1066            self.migrate_evolve_one(object).await?;
1067        }
1068        Ok(())
1069    }
1070
1071    async fn migrate_evolve_one(&self, object: &SystemObjectDescription) -> anyhow::Result<()> {
1072        let persist = &self.config.persist_client;
1073
1074        let Some(object_info) = self.system_objects.get(object) else {
1075            bail!("missing builtin {object:?}");
1076        };
1077        let id = object_info.global_id;
1078
1079        let Some(shard_id) = object_info.shard_id else {
1080            // No shard is registered for this builtin. In leader mode, this is fine, we'll
1081            // register the shard during bootstrap. In read-only mode, we might be racing with the
1082            // leader to register the shard and it's unclear what sort of confusion can arise from
1083            // that -- better to bail out in this case.
1084            if self.config.read_only {
1085                bail!("missing shard ID for builtin {object:?} ({id})");
1086            } else {
1087                return Ok(());
1088            }
1089        };
1090
1091        let target_desc = match object_info.builtin {
1092            Builtin::Table(table) => &table.desc,
1093            Builtin::Source(source) => &source.desc,
1094            Builtin::MaterializedView(mv) => &mv.desc,
1095            _ => bail!("not a storage collection: {object:?}"),
1096        };
1097
1098        let diagnostics = Diagnostics {
1099            shard_name: id.to_string(),
1100            handle_purpose: format!("builtin schema migration @ {}", self.target_version),
1101        };
1102        let source_schema = persist
1103            .latest_schema::<SourceData, (), Timestamp, StorageDiff>(shard_id, diagnostics.clone())
1104            .await
1105            .expect("valid usage");
1106
1107        info!(?object, %id, %shard_id, ?source_schema, ?target_desc, "migrating by evolution");
1108
1109        if self.config.read_only {
1110            // In read-only mode, only check that the new schema is backward compatible.
1111            // We'll register it when/if we restart in leader mode.
1112            if let Some((_, source_desc, _)) = &source_schema {
1113                let old = mz_persist_types::columnar::data_type::<SourceData>(source_desc)?;
1114                let new = mz_persist_types::columnar::data_type::<SourceData>(target_desc)?;
1115                if backward_compatible(&old, &new).is_none() {
1116                    bail!(
1117                        "incompatible schema evolution for {object:?}: \
1118                         {source_desc:?} -> {target_desc:?}"
1119                    );
1120                }
1121            }
1122
1123            return Ok(());
1124        }
1125
1126        let (mut schema_id, mut source_desc) = match source_schema {
1127            Some((schema_id, source_desc, _)) => (schema_id, source_desc),
1128            None => {
1129                // If no schema was previously registered, simply try to register the new one. This
1130                // might fail due to a concurrent registration, in which case we'll fall back to
1131                // `compare_and_evolve_schema`.
1132
1133                debug!(%id, %shard_id, "no previous schema found; registering initial one");
1134                let schema_id = persist
1135                    .register_schema::<SourceData, (), Timestamp, StorageDiff>(
1136                        shard_id,
1137                        target_desc,
1138                        &UnitSchema,
1139                        diagnostics.clone(),
1140                    )
1141                    .await
1142                    .expect("valid usage");
1143                if schema_id.is_some() {
1144                    return Ok(());
1145                }
1146
1147                debug!(%id, %shard_id, "schema registration failed; falling back to CaES");
1148                let (schema_id, source_desc, _) = persist
1149                    .latest_schema::<SourceData, (), Timestamp, StorageDiff>(
1150                        shard_id,
1151                        diagnostics.clone(),
1152                    )
1153                    .await
1154                    .expect("valid usage")
1155                    .expect("known to exist");
1156
1157                (schema_id, source_desc)
1158            }
1159        };
1160
1161        loop {
1162            // Evolving the schema might fail if another process evolved the schema concurrently,
1163            // in which case we need to retry. Most likely the other process evolved the schema to
1164            // our own target schema and the second try is a no-op.
1165
1166            debug!(%id, %shard_id, %schema_id, ?source_desc, ?target_desc, "attempting CaES");
1167            let result = persist
1168                .compare_and_evolve_schema::<SourceData, (), Timestamp, StorageDiff>(
1169                    shard_id,
1170                    schema_id,
1171                    target_desc,
1172                    &UnitSchema,
1173                    diagnostics.clone(),
1174                )
1175                .await
1176                .expect("valid usage");
1177
1178            match result {
1179                CaESchema::Ok(schema_id) => {
1180                    debug!(%id, %shard_id, %schema_id, "schema evolved successfully");
1181                    break;
1182                }
1183                CaESchema::Incompatible => bail!(
1184                    "incompatible schema evolution for {object:?}: \
1185                     {source_desc:?} -> {target_desc:?}"
1186                ),
1187                CaESchema::ExpectedMismatch {
1188                    schema_id: new_id,
1189                    key,
1190                    val: UnitSchema,
1191                } => {
1192                    schema_id = new_id;
1193                    source_desc = key;
1194                }
1195            }
1196        }
1197
1198        Ok(())
1199    }
1200
1201    /// Migrate the given objects using the `Replacement` mechanism.
1202    async fn migrate_replace(
1203        &self,
1204        objects: &[SystemObjectDescription],
1205    ) -> anyhow::Result<BTreeMap<GlobalId, ShardId>> {
1206        if objects.is_empty() {
1207            return Ok(Default::default());
1208        }
1209
1210        let diagnostics = Diagnostics {
1211            shard_name: "builtin_migration".to_string(),
1212            handle_purpose: format!("builtin schema migration @ {}", self.target_version),
1213        };
1214        let (mut persist_write, mut persist_read) =
1215            self.open_migration_shard(diagnostics.clone()).await;
1216
1217        let mut ids_to_replace = BTreeSet::new();
1218        for object in objects {
1219            if let Some(info) = self.system_objects.get(object) {
1220                ids_to_replace.insert(info.global_id);
1221            } else {
1222                bail!("missing id for builtin {object:?}");
1223            }
1224        }
1225
1226        info!(?objects, ?ids_to_replace, "migrating by replacement");
1227
1228        // Fetch replacement shard IDs from the migration shard, or insert new ones if none exist.
1229        // This can fail due to writes by concurrent processes, so we need to retry.
1230        let replaced_shards = loop {
1231            if let Some(shards) = self
1232                .try_get_or_insert_replacement_shards(
1233                    &ids_to_replace,
1234                    &mut persist_write,
1235                    &mut persist_read,
1236                )
1237                .await?
1238            {
1239                break shards;
1240            }
1241        };
1242
1243        Ok(replaced_shards)
1244    }
1245
1246    /// Try to get or insert replacement shards for the given IDs into the migration shard, at
1247    /// `target_version` and `deploy_generation`.
1248    ///
1249    /// This method looks for existing entries in the migration shards and returns those if they
1250    /// are present. Otherwise it generates new shard IDs and tries to insert them.
1251    ///
1252    /// The result of this call is `None` if no existing entries were found and inserting new ones
1253    /// failed because of a concurrent write to the migration shard. In this case, the caller is
1254    /// expected to retry.
1255    async fn try_get_or_insert_replacement_shards(
1256        &self,
1257        ids_to_replace: &BTreeSet<GlobalId>,
1258        persist_write: &mut WriteHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1259        persist_read: &mut ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1260    ) -> anyhow::Result<Option<BTreeMap<GlobalId, ShardId>>> {
1261        let upper = persist_write.fetch_recent_upper().await;
1262        let write_ts = *upper.as_option().expect("migration shard not sealed");
1263
1264        let mut ids_to_replace = ids_to_replace.clone();
1265        let mut replaced_shards = BTreeMap::new();
1266
1267        // Another process might already have done a shard replacement at our version and
1268        // generation, in which case we can directly reuse the replacement shards.
1269        //
1270        // Note that we can't assume that the previous process had the same `ids_to_replace` as we
1271        // do. The set of migrations to run depends on both the source and the target version, and
1272        // the migration shard is not keyed by source version. The previous writer might have seen
1273        // a different source version, if there was a concurrent migration by a leader process.
1274        if let Some(read_ts) = write_ts.step_back() {
1275            let pred = |key: &migration_shard::Key| {
1276                key.build_version == self.target_version
1277                    && key.deploy_generation == Some(self.deploy_generation)
1278            };
1279            if let Some(entries) = read_migration_shard(persist_read, read_ts, pred).await {
1280                for (key, shard_id) in entries {
1281                    let id = GlobalId::System(key.global_id);
1282                    if ids_to_replace.remove(&id) {
1283                        replaced_shards.insert(id, shard_id);
1284                    }
1285                }
1286
1287                debug!(
1288                    %read_ts, ?replaced_shards, ?ids_to_replace,
1289                    "found existing entries in migration shard",
1290                );
1291            }
1292
1293            if ids_to_replace.is_empty() {
1294                return Ok(Some(replaced_shards));
1295            }
1296        }
1297
1298        // Generate new shard IDs and attempt to insert them into the migration shard. If we get a
1299        // CaA failure at `write_ts` that means a concurrent process has inserted in the meantime
1300        // and we need to re-check the migration shard contents.
1301        let mut updates = Vec::new();
1302        for id in ids_to_replace {
1303            let shard_id = ShardId::new();
1304            replaced_shards.insert(id, shard_id);
1305
1306            let GlobalId::System(global_id) = id else {
1307                bail!("attempt to migrate a non-system collection: {id}");
1308            };
1309            let key = migration_shard::Key {
1310                global_id,
1311                build_version: self.target_version.clone(),
1312                deploy_generation: Some(self.deploy_generation),
1313            };
1314            updates.push(((key, shard_id), write_ts, 1));
1315        }
1316
1317        let upper = Antichain::from_elem(write_ts);
1318        let new_upper = Antichain::from_elem(write_ts.step_forward());
1319        debug!(%write_ts, "attempting insert into migration shard");
1320        let result = persist_write
1321            .compare_and_append(updates, upper, new_upper)
1322            .await
1323            .expect("valid usage");
1324
1325        match result {
1326            Ok(()) => {
1327                debug!(
1328                    %write_ts, ?replaced_shards,
1329                    "successfully inserted into migration shard"
1330                );
1331                Ok(Some(replaced_shards))
1332            }
1333            Err(_mismatch) => Ok(None),
1334        }
1335    }
1336
1337    /// Open writer and reader for the migration shard.
1338    async fn open_migration_shard(
1339        &self,
1340        diagnostics: Diagnostics,
1341    ) -> (
1342        WriteHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1343        ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1344    ) {
1345        let persist = &self.config.persist_client;
1346
1347        persist
1348            .open(
1349                self.migration_shard,
1350                Arc::new(migration_shard::KeySchema),
1351                Arc::new(ShardIdSchema),
1352                diagnostics,
1353                USE_CRITICAL_SINCE_CATALOG.get(persist.dyncfgs()),
1354            )
1355            .await
1356            .expect("valid usage")
1357    }
1358
1359    /// Open a [`SinceHandle`] for the migration shard.
1360    async fn open_migration_shard_since(
1361        &self,
1362        diagnostics: Diagnostics,
1363    ) -> SinceHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff> {
1364        self.config
1365            .persist_client
1366            .open_critical_since(
1367                self.migration_shard,
1368                // TODO: We may need to use a different critical reader
1369                // id for this if we want to be able to introspect it via SQL.
1370                PersistClient::CONTROLLER_CRITICAL_SINCE,
1371                Opaque::encode(&i64::MIN),
1372                diagnostics.clone(),
1373            )
1374            .await
1375            .expect("valid usage")
1376    }
1377
1378    /// Update the fingerprints for `migrated_items`.
1379    ///
1380    /// Returns the new fingerprints. Also asserts that the current fingerprints of all other
1381    /// system items match their builtin definitions.
1382    fn update_fingerprints(
1383        &self,
1384        migrated_items: &BTreeSet<SystemObjectDescription>,
1385    ) -> anyhow::Result<BTreeMap<SystemObjectDescription, String>> {
1386        let mut new_fingerprints = BTreeMap::new();
1387        for (object, object_info) in &self.system_objects {
1388            let id = object_info.global_id;
1389            let builtin = object_info.builtin;
1390
1391            let fingerprint = builtin.fingerprint();
1392            if fingerprint == object_info.fingerprint {
1393                continue; // fingerprint unchanged, nothing to do
1394            }
1395
1396            // Fingerprint mismatch is expected for a migrated item.
1397            let migrated = migrated_items.contains(object);
1398            // Some builtin types have schemas but no durable state. No migration needed for those.
1399            let ephemeral = matches!(
1400                builtin,
1401                Builtin::Log(_) | Builtin::View(_) | Builtin::Index(_),
1402            );
1403
1404            if migrated || ephemeral {
1405                new_fingerprints.insert(object.clone(), fingerprint);
1406            } else if builtin.runtime_alterable() {
1407                // Runtime alterable builtins have no meaningful builtin fingerprint, and a
1408                // sentinel value stored in the catalog.
1409                assert_eq!(
1410                    object_info.fingerprint, RUNTIME_ALTERABLE_FINGERPRINT_SENTINEL,
1411                    "fingerprint mismatch for runtime-alterable builtin {object:?} ({id})",
1412                );
1413            } else {
1414                panic!(
1415                    "fingerprint mismatch for builtin {builtin:?} ({id}): {} != {}",
1416                    fingerprint, object_info.fingerprint,
1417                );
1418            }
1419        }
1420
1421        Ok(new_fingerprints)
1422    }
1423
1424    /// Perform cleanup of migration state, i.e. the migration shard.
1425    ///
1426    /// Returns a list of shards to finalize, and a `Future` that must be run after the shard
1427    /// finalization has been durably enqueued. The `Future` is used to remove entries from the
1428    /// migration shard only after we know the respective shards will be finalized. Removing
1429    /// entries immediately would risk leaking the shards.
1430    ///
1431    /// We only perform cleanup in leader mode, to keep the durable state changes made by read-only
1432    /// processes a minimal as possible. Given that Materialize doesn't support version downgrades,
1433    /// it is safe to assume that any state for versions below the `target_version` is not needed
1434    /// anymore and can be cleaned up.
1435    ///
1436    /// Note that it is fine for cleanup to sometimes fail or be skipped. The size of the migration
1437    /// shard should always be pretty small, so keeping migration state around for longer isn't a
1438    /// concern. As a result, we can keep the logic simple here and skip doing cleanup in response
1439    /// to transient failures, instead of retrying.
1440    async fn cleanup(&self) -> anyhow::Result<(BTreeSet<ShardId>, BoxFuture<'static, ()>)> {
1441        let noop_action = async {}.boxed();
1442        let noop_result = (BTreeSet::new(), noop_action);
1443
1444        if self.config.read_only {
1445            return Ok(noop_result);
1446        }
1447
1448        let diagnostics = Diagnostics {
1449            shard_name: "builtin_migration".to_string(),
1450            handle_purpose: "builtin schema migration cleanup".into(),
1451        };
1452        let (mut persist_write, mut persist_read) =
1453            self.open_migration_shard(diagnostics.clone()).await;
1454        let mut persist_since = self.open_migration_shard_since(diagnostics.clone()).await;
1455
1456        let upper = persist_write.fetch_recent_upper().await.clone();
1457        let write_ts = *upper.as_option().expect("migration shard not sealed");
1458        let Some(read_ts) = write_ts.step_back() else {
1459            return Ok(noop_result);
1460        };
1461
1462        // Collect old entries to remove.
1463        let pred = |key: &migration_shard::Key| key.build_version < self.target_version;
1464        let Some(stale_entries) = read_migration_shard(&mut persist_read, read_ts, pred).await
1465        else {
1466            return Ok(noop_result);
1467        };
1468
1469        debug!(
1470            ?stale_entries,
1471            "cleaning migration shard up to version {}", self.target_version,
1472        );
1473
1474        let current_shards: BTreeMap<_, _> = self
1475            .system_objects
1476            .values()
1477            .filter_map(|o| o.shard_id.map(|shard_id| (o.global_id, shard_id)))
1478            .collect();
1479
1480        let mut shards_to_finalize = BTreeSet::new();
1481        let mut retractions = Vec::new();
1482        for (key, shard_id) in stale_entries {
1483            // The migration shard contains both shards created during aborted upgrades and shards
1484            // created during successful upgrades. The latter may still be in use, so we have to
1485            // check and only finalize those that aren't anymore.
1486            let gid = GlobalId::System(key.global_id);
1487            if current_shards.get(&gid) != Some(&shard_id) {
1488                shards_to_finalize.insert(shard_id);
1489            }
1490
1491            retractions.push(((key, shard_id), write_ts, -1));
1492        }
1493
1494        let cleanup_action = async move {
1495            if !retractions.is_empty() {
1496                let new_upper = Antichain::from_elem(write_ts.step_forward());
1497                let result = persist_write
1498                    .compare_and_append(retractions, upper, new_upper)
1499                    .await
1500                    .expect("valid usage");
1501                match result {
1502                    Ok(()) => debug!("cleaned up migration shard"),
1503                    Err(mismatch) => debug!(?mismatch, "migration shard cleanup failed"),
1504                }
1505            }
1506        }
1507        .boxed();
1508
1509        // Downgrade the since, to enable some compaction.
1510        let o = persist_since.opaque().clone();
1511        let new_since = Antichain::from_elem(read_ts);
1512        let result = persist_since
1513            .maybe_compare_and_downgrade_since(&o, (&o, &new_since))
1514            .await;
1515        soft_assert_or_log!(result.is_none_or(|r| r.is_ok()), "opaque mismatch");
1516
1517        Ok((shards_to_finalize, cleanup_action))
1518    }
1519}
1520
1521/// Read the migration shard at the given timestamp, returning all entries that match the given
1522/// predicate.
1523///
1524/// Returns `None` if the migration shard contains no matching entries, or if it isn't readable at
1525/// `read_ts`.
1526async fn read_migration_shard<P>(
1527    persist_read: &mut ReadHandle<migration_shard::Key, ShardId, Timestamp, StorageDiff>,
1528    read_ts: Timestamp,
1529    predicate: P,
1530) -> Option<Vec<(migration_shard::Key, ShardId)>>
1531where
1532    P: Fn(&migration_shard::Key) -> bool,
1533{
1534    let as_of = Antichain::from_elem(read_ts);
1535    let updates = persist_read.snapshot_and_fetch(as_of).await.ok()?;
1536
1537    assert!(
1538        updates.iter().all(|(_, _, diff)| *diff == 1),
1539        "migration shard contains invalid diffs: {updates:?}",
1540    );
1541
1542    let entries: Vec<_> = updates
1543        .into_iter()
1544        .map(|(data, _, _)| data)
1545        .filter(move |(key, _)| predicate(key))
1546        .collect();
1547
1548    (!entries.is_empty()).then_some(entries)
1549}
1550
1551/// A plan to migrate between two versions.
1552#[derive(Debug, Default)]
1553struct Plan {
1554    /// Objects to migrate using the `Evolution` mechanism.
1555    evolve: Vec<SystemObjectDescription>,
1556    /// Objects to migrate using the `Replacement` mechanism.
1557    replace: Vec<SystemObjectDescription>,
1558}
1559
1560/// Types and persist codec impls for the migration shard used by the `Replacement` mechanism.
1561mod migration_shard {
1562    use std::fmt;
1563    use std::str::FromStr;
1564
1565    use arrow::array::{StringArray, StringBuilder};
1566    use bytes::{BufMut, Bytes};
1567    use mz_persist_types::Codec;
1568    use mz_persist_types::codec_impls::{
1569        SimpleColumnarData, SimpleColumnarDecoder, SimpleColumnarEncoder,
1570    };
1571    use mz_persist_types::columnar::Schema;
1572    use mz_persist_types::stats::NoneStats;
1573    use semver::Version;
1574    use serde::{Deserialize, Serialize};
1575
1576    #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1577    pub(super) struct Key {
1578        pub(super) global_id: u64,
1579        pub(super) build_version: Version,
1580        // Versions < 26.0 didn't include the deploy generation. As long as we still might
1581        // encounter migration shard entries that don't have it, we need to keep this an `Option`
1582        // and keep supporting both key formats.
1583        pub(super) deploy_generation: Option<u64>,
1584    }
1585
1586    impl fmt::Display for Key {
1587        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1588            if self.deploy_generation.is_some() {
1589                // current format
1590                let s = serde_json::to_string(self).expect("JSON serializable");
1591                f.write_str(&s)
1592            } else {
1593                // pre-26.0 format
1594                write!(f, "{}-{}", self.global_id, self.build_version)
1595            }
1596        }
1597    }
1598
1599    impl FromStr for Key {
1600        type Err = String;
1601
1602        fn from_str(s: &str) -> Result<Self, String> {
1603            // current format
1604            if let Ok(key) = serde_json::from_str(s) {
1605                return Ok(key);
1606            };
1607
1608            // pre-26.0 format
1609            let parts: Vec<_> = s.splitn(2, '-').collect();
1610            let &[global_id, build_version] = parts.as_slice() else {
1611                return Err(format!("invalid Key '{s}'"));
1612            };
1613            let global_id = global_id.parse::<u64>().map_err(|e| e.to_string())?;
1614            let build_version = build_version
1615                .parse::<Version>()
1616                .map_err(|e| e.to_string())?;
1617            Ok(Key {
1618                global_id,
1619                build_version,
1620                deploy_generation: None,
1621            })
1622        }
1623    }
1624
1625    impl Default for Key {
1626        fn default() -> Self {
1627            Self {
1628                global_id: Default::default(),
1629                build_version: Version::new(0, 0, 0),
1630                deploy_generation: Some(0),
1631            }
1632        }
1633    }
1634
1635    impl Codec for Key {
1636        type Schema = KeySchema;
1637        type Storage = ();
1638
1639        fn codec_name() -> String {
1640            "TableKey".into()
1641        }
1642
1643        fn encode<B: BufMut>(&self, buf: &mut B) {
1644            buf.put(self.to_string().as_bytes())
1645        }
1646
1647        fn decode<'a>(buf: &'a [u8], _schema: &KeySchema) -> Result<Self, String> {
1648            let s = str::from_utf8(buf).map_err(|e| e.to_string())?;
1649            s.parse()
1650        }
1651
1652        fn encode_schema(_schema: &KeySchema) -> Bytes {
1653            Bytes::new()
1654        }
1655
1656        fn decode_schema(buf: &Bytes) -> Self::Schema {
1657            assert_eq!(*buf, Bytes::new());
1658            KeySchema
1659        }
1660    }
1661
1662    impl SimpleColumnarData for Key {
1663        type ArrowBuilder = StringBuilder;
1664        type ArrowColumn = StringArray;
1665
1666        fn goodbytes(builder: &Self::ArrowBuilder) -> usize {
1667            builder.values_slice().len()
1668        }
1669
1670        fn push(&self, builder: &mut Self::ArrowBuilder) {
1671            builder.append_value(&self.to_string());
1672        }
1673
1674        fn push_null(builder: &mut Self::ArrowBuilder) {
1675            builder.append_null();
1676        }
1677
1678        fn read(&mut self, idx: usize, column: &Self::ArrowColumn) {
1679            *self = column.value(idx).parse().expect("valid Key");
1680        }
1681    }
1682
1683    #[derive(Debug, PartialEq)]
1684    pub(super) struct KeySchema;
1685
1686    impl Schema<Key> for KeySchema {
1687        type ArrowColumn = StringArray;
1688        type Statistics = NoneStats;
1689        type Decoder = SimpleColumnarDecoder<Key>;
1690        type Encoder = SimpleColumnarEncoder<Key>;
1691
1692        fn encoder(&self) -> anyhow::Result<SimpleColumnarEncoder<Key>> {
1693            Ok(SimpleColumnarEncoder::default())
1694        }
1695
1696        fn decoder(&self, col: StringArray) -> anyhow::Result<SimpleColumnarDecoder<Key>> {
1697            Ok(SimpleColumnarDecoder::new(col))
1698        }
1699    }
1700}
1701
1702#[cfg(test)]
1703#[path = "builtin_schema_migration_tests.rs"]
1704mod tests;