Skip to main content

mz_adapter/catalog/open/
builtin_schema_migration.rs

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