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