Skip to main content

mz_compute_types/plan/
pinned.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//! The durable form of an LIR dataflow.
11//!
12//! A [`PinnedDataflow`] holds the durable plan for a dataflow. It is the
13//! root of the stable LIR schema traced in `tests/lir_schema.rs`, so every
14//! type reachable from it is part of the frozen serialization surface.
15//!
16//! We try to avoid pinning things that can be re-derived from the catalog
17//! when loading the plan. This means keeping track of assumptions about
18//! inputs but only the wiring for outputs. The rest can be recomputed.
19//!
20//! Loading a pinned plan is called _instantiation_, and it is done by means
21//! of an [`InstantiationContext`].
22//!
23//! Only dataflows behind catalog items can be pinned: indexes, materialized
24//! views, and metric sinks. Subscribes and one-shot copies have no durable
25//! identity to pin under, and the [`TryFrom`] conversion rejects them, along
26//! with any export under a transient id (a peek's index, a curated metric
27//! sink) and a dataflow that exports nothing.
28//!
29//! [`TryFrom`] also rejects a dataflow that imports or exports a builtin
30//! collection. Builtins keep their ids across releases while their schemas
31//! and definitions change, and the plan inlines the bodies of builtin views,
32//! which instantiation cannot check.
33//!
34//! Index keys are stored as [`LirScalarExpr`] and converted back to
35//! `MirScalarExpr` on instantiation. LIR scalars are a subset of MIR scalars,
36//! so that direction is total, and `DataflowDescription` keeps its MIR-typed
37//! index keys for the optimizer's benefit. Source operators are already an
38//! [`MfpPlan`] over LIR expressions in a lowered description and are stored
39//! as they are.
40//!
41//! The stored form is JSON, whose map keys must be strings. Maps keyed by
42//! `GlobalId` serialize their keys through [`GlobalIdKey`], the id's string
43//! form.
44
45use std::collections::BTreeMap;
46use std::fmt;
47
48use itertools::Itertools;
49use mz_expr::{Id, MfpPlan, MirScalarExpr};
50use mz_ore::soft_assert_or_log;
51use mz_repr::refresh_schedule::RefreshSchedule;
52use mz_repr::{GlobalId, RelationDesc, ReprRelationType, SqlRelationType};
53use serde::de::{self, Visitor};
54use serde::{Deserialize, Deserializer, Serialize, Serializer};
55use timely::progress::Antichain;
56
57use crate::dataflows::{
58    BuildDesc, DataflowDescription, IndexDesc, IndexImport, LirDataflowDescription, SourceImport,
59};
60use crate::plan::scalar::LirScalarExpr;
61use crate::plan::{LirRelationExpr, LirRelationNode};
62use crate::sinks::{
63    ComputeSinkConnection, ComputeSinkDesc, MaterializedViewSinkConnection, MetricSinkConnection,
64};
65use crate::sources::{SourceInstanceArguments, SourceInstanceDesc};
66
67/// A pinned LIR plan: the optimizer's decisions for one dataflow.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PinnedDataflow {
70    /// Sources the plan reads, with the operators pushed down onto each.
71    #[serde(with = "global_id_keys")]
72    pub source_imports: BTreeMap<GlobalId, PinnedSourceImport>,
73    /// Indexes the plan reads, keyed by index id.
74    #[serde(with = "global_id_keys")]
75    pub index_imports: BTreeMap<GlobalId, PinnedIndexImport>,
76    /// Objects to build, in dependency order.
77    pub objects_to_build: Vec<BuildDesc<LirRelationExpr>>,
78    /// Indexes the plan exports, mapping each index id to the id of the
79    /// object it arranges.
80    #[serde(with = "global_id_keys")]
81    pub index_exports: BTreeMap<GlobalId, GlobalId>,
82    /// Sinks the plan exports, keyed by sink id.
83    #[serde(with = "global_id_keys")]
84    pub sink_exports: BTreeMap<GlobalId, PinnedSink>,
85}
86
87/// A `GlobalId` in its string form (`u1`, `s2`, `t3`), for use as a map key.
88///
89/// `GlobalId` itself serializes as an enum, which JSON cannot use as a key.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
91pub struct GlobalIdKey(pub GlobalId);
92
93impl Serialize for GlobalIdKey {
94    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
95        serializer.serialize_newtype_struct("GlobalIdKey", &self.0.to_string())
96    }
97}
98
99impl<'de> Deserialize<'de> for GlobalIdKey {
100    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        struct KeyVisitor;
102
103        impl<'de> Visitor<'de> for KeyVisitor {
104            type Value = GlobalIdKey;
105
106            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
107                f.write_str("a global id such as u1")
108            }
109
110            fn visit_newtype_struct<D: Deserializer<'de>>(
111                self,
112                deserializer: D,
113            ) -> Result<Self::Value, D::Error> {
114                let id = String::deserialize(deserializer)?;
115                id.parse().map(GlobalIdKey).map_err(de::Error::custom)
116            }
117
118            fn visit_str<E: de::Error>(self, id: &str) -> Result<Self::Value, E> {
119                id.parse().map(GlobalIdKey).map_err(de::Error::custom)
120            }
121        }
122
123        deserializer.deserialize_newtype_struct("GlobalIdKey", KeyVisitor)
124    }
125}
126
127/// Serializes a `GlobalId`-keyed map with [`GlobalIdKey`] keys.
128mod global_id_keys {
129    use super::*;
130
131    pub fn serialize<V: Serialize, S: Serializer>(
132        map: &BTreeMap<GlobalId, V>,
133        serializer: S,
134    ) -> Result<S::Ok, S::Error> {
135        serializer.collect_map(map.iter().map(|(id, value)| (GlobalIdKey(*id), value)))
136    }
137
138    pub fn deserialize<'de, V: Deserialize<'de>, D: Deserializer<'de>>(
139        deserializer: D,
140    ) -> Result<BTreeMap<GlobalId, V>, D::Error> {
141        let map = BTreeMap::<GlobalIdKey, V>::deserialize(deserializer)?;
142        Ok(map.into_iter().map(|(key, value)| (key.0, value)).collect())
143    }
144}
145
146/// The plan's assumptions about an imported source.
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148pub struct PinnedSourceImport {
149    /// Operators pushed down onto the source read, with any temporal filter
150    /// held as the plan's bounds.
151    pub operators: Option<MfpPlan<LirScalarExpr>>,
152    /// The relation type the plan was compiled against.
153    pub typ: SqlRelationType,
154    /// Whether the plan relies on the source being monotonic.
155    pub monotonic: bool,
156    /// Whether the plan needs the source's snapshot.
157    pub with_snapshot: bool,
158}
159
160/// The plan's assumptions about an imported index.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct PinnedIndexImport {
163    /// The object the index arranges and the key it arranges by.
164    pub desc: IndexDesc<LirScalarExpr>,
165    /// The relation type of the arranged object.
166    pub typ: ReprRelationType,
167    /// Whether the plan relies on the index being monotonic.
168    pub monotonic: bool,
169    /// Whether the plan needs the index's snapshot.
170    pub with_snapshot: bool,
171}
172
173/// The wiring of an exported sink. Its descriptor is rebuilt from the catalog.
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
175pub struct PinnedSink {
176    /// The built object the sink reads.
177    pub from: GlobalId,
178    /// The kind of catalog item the sink was compiled for.
179    pub kind: PinnedSinkKind,
180    /// Which form of `from` the sink consumes, as lowering chose it. See
181    /// `ComputeSinkDesc::from_arrangement`.
182    pub from_arrangement: Option<(Vec<LirScalarExpr>, MfpPlan<LirScalarExpr>)>,
183}
184
185/// The kinds of sink a pinned dataflow may export.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187pub enum PinnedSinkKind {
188    /// A materialized view. Its column names, non-null assertions, and
189    /// refresh schedule come from the catalog item.
190    MaterializedView {
191        /// The sink's relation type as the optimizer built it: the plan's
192        /// inferred nullability and keys, with the non-null assertions
193        /// applied.
194        typ: SqlRelationType,
195    },
196    /// A metric sink. Its label and the shaped description of its input come
197    /// from the catalog item.
198    MetricSink,
199}
200
201///////////////////////////////////////////////////////////////////////////////
202// PINNING
203///////////////////////////////////////////////////////////////////////////////
204
205/// Why a `DataflowDescription` could not be pinned.
206///
207/// Lowering scalar expressions to LIR is not an error case: expression
208/// preparation rejects unmaterializable functions before lowering, and
209/// temporal extraction moves `mz_now()` into `MfpPlan` bounds, so a failure
210/// there is a lowering bug and panics like the other LIR conversions.
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub enum PinError {
213    /// The dataflow exports a sink kind that has no catalog identity.
214    UnpinnableSink(GlobalId),
215    /// The dataflow exports an index under a transient id, as a peek does.
216    TransientIndexExport(GlobalId),
217    /// The dataflow exports a sink under a transient id, as a curated
218    /// per-replica metric sink does.
219    TransientSinkExport(GlobalId),
220    /// The dataflow exports nothing, so no catalog item anchors it.
221    NoExports,
222    /// The dataflow imports or exports the builtin collection under this id.
223    BuiltinCollection(GlobalId),
224}
225
226impl fmt::Display for PinError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            PinError::UnpinnableSink(id) => {
230                write!(f, "sink {id} is a one-shot sink and cannot be pinned")
231            }
232            PinError::TransientIndexExport(id) => {
233                write!(
234                    f,
235                    "index {id} is a peek's transient result and cannot be pinned"
236                )
237            }
238            PinError::TransientSinkExport(id) => {
239                write!(
240                    f,
241                    "sink {id} has a transient id and no catalog item, so it cannot be pinned"
242                )
243            }
244            PinError::NoExports => write!(f, "a dataflow with no exports cannot be pinned"),
245            PinError::BuiltinCollection(id) => {
246                write!(
247                    f,
248                    "a dataflow that reads or writes builtin collection {id} cannot be pinned"
249                )
250            }
251        }
252    }
253}
254
255impl std::error::Error for PinError {}
256
257impl TryFrom<LirDataflowDescription> for PinnedDataflow {
258    type Error = PinError;
259
260    fn try_from(desc: LirDataflowDescription) -> Result<Self, PinError> {
261        // Destructured without `..` on purpose, so a new field on any of
262        // these types fails to compile here until it is either pinned or
263        // listed among the supplied fields.
264        let DataflowDescription {
265            source_imports,
266            index_imports,
267            objects_to_build,
268            index_exports,
269            sink_exports,
270            as_of: _,
271            until: _,
272            initial_storage_as_of: _,
273            refresh_schedule: _,
274            debug_name: _,
275            time_dependence: _,
276        } = desc;
277
278        // Every dataflow we run should have exports; missing exports indicate
279        // a serious error somewhere.
280        soft_assert_or_log!(
281            !(index_exports.is_empty() && sink_exports.is_empty()),
282            "pinning a dataflow with no exports"
283        );
284        if index_exports.is_empty() && sink_exports.is_empty() {
285            return Err(PinError::NoExports);
286        }
287        if let Some(id) = source_imports
288            .keys()
289            .chain(index_imports.keys())
290            .chain(index_exports.keys())
291            .chain(sink_exports.keys())
292            .find(|id| id.is_system())
293        {
294            return Err(PinError::BuiltinCollection(*id));
295        }
296
297        let source_imports = source_imports
298            .into_iter()
299            .map(|(id, import)| {
300                let SourceImport {
301                    desc:
302                        SourceInstanceDesc {
303                            arguments: SourceInstanceArguments { operators },
304                            storage_metadata: (),
305                            typ,
306                        },
307                    monotonic,
308                    with_snapshot,
309                    upper: _,
310                } = import;
311                (
312                    id,
313                    PinnedSourceImport {
314                        operators,
315                        typ,
316                        monotonic,
317                        with_snapshot,
318                    },
319                )
320            })
321            .collect();
322
323        let index_imports = index_imports
324            .into_iter()
325            .map(|(id, import)| {
326                let IndexImport {
327                    desc,
328                    typ,
329                    monotonic,
330                    with_snapshot,
331                } = import;
332                (
333                    id,
334                    PinnedIndexImport {
335                        desc: desc.as_lir(),
336                        typ,
337                        monotonic,
338                        with_snapshot,
339                    },
340                )
341            })
342            .collect();
343
344        let index_exports = index_exports
345            .into_iter()
346            .map(|(id, (IndexDesc { on_id, key: _ }, _typ))| {
347                if id.is_transient() {
348                    return Err(PinError::TransientIndexExport(id));
349                }
350                Ok((id, on_id))
351            })
352            .collect::<Result<_, _>>()?;
353
354        let sink_exports = sink_exports
355            .into_iter()
356            .map(|(id, sink)| {
357                let ComputeSinkDesc {
358                    from,
359                    from_desc,
360                    connection,
361                    with_snapshot: _,
362                    up_to: _,
363                    non_null_assertions: _,
364                    refresh_schedule: _,
365                    from_arrangement,
366                } = sink;
367                let kind = match connection {
368                    ComputeSinkConnection::MaterializedView(_) => {
369                        PinnedSinkKind::MaterializedView {
370                            typ: from_desc.into_typ(),
371                        }
372                    }
373                    ComputeSinkConnection::MetricSink(_) => PinnedSinkKind::MetricSink,
374                    ComputeSinkConnection::Subscribe(_)
375                    | ComputeSinkConnection::CopyToS3Oneshot(_) => {
376                        return Err(PinError::UnpinnableSink(id));
377                    }
378                };
379                if id.is_transient() {
380                    return Err(PinError::TransientSinkExport(id));
381                }
382                Ok((
383                    id,
384                    PinnedSink {
385                        from,
386                        kind,
387                        from_arrangement,
388                    },
389                ))
390            })
391            .collect::<Result<_, _>>()?;
392
393        Ok(PinnedDataflow {
394            source_imports,
395            index_imports,
396            objects_to_build,
397            index_exports,
398            sink_exports,
399        })
400    }
401}
402
403///////////////////////////////////////////////////////////////////////////////
404// INSTANTIATION
405///////////////////////////////////////////////////////////////////////////////
406
407/// What [`PinnedDataflow::instantiate`] needs from its environment: catalog
408/// facts about the plan's imports and exports, looked up by id, and fresh
409/// transient ids. Each lookup returns `None` when no item of that kind exists.
410pub trait InstantiationContext {
411    /// The relation type of the storage collection `id`, as the optimizer
412    /// imports it.
413    fn collection_type(&self, id: GlobalId) -> Option<SqlRelationType>;
414    /// The index under `id`.
415    fn index(&self, id: GlobalId) -> Option<IndexInfo>;
416    /// The materialized view whose sink is exported under `id`.
417    fn materialized_view(&self, id: GlobalId) -> Option<MaterializedViewInfo>;
418    /// The metric sink exported under `id`.
419    fn metric_sink(&self, id: GlobalId) -> Option<MetricSinkInfo>;
420    /// A transient id no other dataflow in this process uses.
421    fn allocate_transient_id(&self) -> GlobalId;
422}
423
424/// The catalog facts an index import is checked against and an index export
425/// is rebuilt from.
426#[derive(Clone, Debug, PartialEq, Eq)]
427pub struct IndexInfo {
428    /// The object the index is on.
429    pub on: GlobalId,
430    /// The key the index arranges by.
431    pub keys: Vec<MirScalarExpr>,
432    /// The relation type of the object the index is on.
433    pub typ: ReprRelationType,
434}
435
436/// The catalog facts an exported materialized view sink is rebuilt from.
437#[derive(Clone, Debug, PartialEq, Eq)]
438pub struct MaterializedViewInfo {
439    /// The view's description. Instantiation takes its column names from it
440    /// and checks its type against the pinned type.
441    pub desc: RelationDesc,
442    /// Columns asserted non-null.
443    pub non_null_assertions: Vec<usize>,
444    /// The view's refresh schedule.
445    pub refresh_schedule: Option<RefreshSchedule>,
446}
447
448/// The catalog facts an exported metric sink is rebuilt from.
449#[derive(Clone, Debug, PartialEq, Eq)]
450pub struct MetricSinkInfo {
451    /// The `sink` label on the sink's health gauges.
452    pub label: String,
453    /// The description of the shaped object the sink reads. This is what the
454    /// metric sink optimizer derives from the source description and prefix,
455    /// not the source description itself.
456    pub from_desc: RelationDesc,
457}
458
459/// Why a [`PinnedDataflow`] could not be instantiated.
460#[derive(Clone, Debug, PartialEq, Eq)]
461pub enum InstantiateError {
462    /// The catalog no longer has an item of the expected kind under this id.
463    MissingCatalogItem {
464        /// The import's or export's id.
465        id: GlobalId,
466        /// The kind of item the plan was compiled for.
467        kind: &'static str,
468    },
469    /// The catalog item and the pinned plan disagree about the import.
470    ImportDrift {
471        /// The import's id.
472        id: GlobalId,
473        /// What disagrees.
474        detail: String,
475    },
476    /// The catalog item and the pinned plan disagree about the export.
477    ExportDrift {
478        /// The export's id.
479        id: GlobalId,
480        /// What disagrees.
481        detail: String,
482    },
483}
484
485impl fmt::Display for InstantiateError {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        match self {
488            InstantiateError::MissingCatalogItem { id, kind } => {
489                write!(f, "no {kind} catalog item for {id}")
490            }
491            InstantiateError::ImportDrift { id, detail } => {
492                write!(f, "import {id} drifted from its pinned plan: {detail}")
493            }
494            InstantiateError::ExportDrift { id, detail } => {
495                write!(f, "export {id} drifted from its pinned plan: {detail}")
496            }
497        }
498    }
499}
500impl std::error::Error for InstantiateError {}
501
502impl PinnedDataflow {
503    /// Rebuilds the description the compute controller expects.
504    ///
505    /// The result has no `as_of`, `until`, `initial_storage_as_of`, or
506    /// `time_dependence`, and its source imports carry no upper frontier or
507    /// storage metadata. The caller supplies those the same way it does for a
508    /// freshly optimized dataflow. The dataflow-level refresh schedule is the
509    /// exported materialized view's, if there is one.
510    ///
511    /// The result equals the description that was pinned, up to transient
512    /// build ids, which are reallocated from `ctx`.
513    ///
514    /// Fails if a catalog item an import or export was compiled for is gone,
515    /// or if the catalog disagrees with the plan about what it is. Only the
516    /// first problem found is reported.
517    ///
518    /// NOTE: The plan also contains the bodies of the views it inlines, and
519    /// of SQL-defined builtin functions, and nothing here verifies those.
520    /// Pinning refuses builtin collections, which keeps out builtin views
521    /// that read one, but not a builtin view that reads no collection or a
522    /// SQL-defined function.
523    // TODO(mgree): the import and export checks are independent, so
524    // collecting every problem into a list instead of returning at the first
525    // one would be easy.
526    pub fn instantiate(
527        self,
528        ctx: &dyn InstantiationContext,
529        debug_name: String,
530    ) -> Result<LirDataflowDescription, InstantiateError> {
531        let PinnedDataflow {
532            source_imports,
533            index_imports,
534            mut objects_to_build,
535            index_exports,
536            sink_exports,
537        } = self;
538
539        // Transient ids come from a per-process counter, so the internal
540        // build ids of a plan pinned elsewhere can collide with a dataflow
541        // built in this process. Compute keys its LIR introspection by build
542        // id, and a collision merges two dataflows' operators there.
543        let mut renamed = BTreeMap::new();
544        for object in &mut objects_to_build {
545            if object.id.is_transient() {
546                let fresh = ctx.allocate_transient_id();
547                renamed.insert(object.id, fresh);
548                object.id = fresh;
549            }
550        }
551        let rename = |id: GlobalId| renamed.get(&id).copied().unwrap_or(id);
552        for object in &mut objects_to_build {
553            replace_global_ids(&mut object.plan, &rename);
554        }
555
556        // An import's id resolving is not evidence that the plan's assumptions
557        // about it hold: a materialized view's inferred nullability and keys
558        // can change between releases. So each import is checked against what
559        // the catalog says now.
560        let mut rebuilt_source_imports = BTreeMap::new();
561        for (id, import) in source_imports {
562            let PinnedSourceImport {
563                operators,
564                typ,
565                monotonic,
566                with_snapshot,
567            } = import;
568            let catalog_typ =
569                ctx.collection_type(id)
570                    .ok_or(InstantiateError::MissingCatalogItem {
571                        id,
572                        kind: "collection",
573                    })?;
574            if catalog_typ != typ {
575                return Err(InstantiateError::ImportDrift {
576                    id,
577                    detail: format!("pinned type {typ:?}, catalog says {catalog_typ:?}"),
578                });
579            }
580            let import = SourceImport {
581                desc: SourceInstanceDesc {
582                    arguments: SourceInstanceArguments { operators },
583                    storage_metadata: (),
584                    typ,
585                },
586                monotonic,
587                with_snapshot,
588                upper: Antichain::new(),
589            };
590            rebuilt_source_imports.insert(id, import);
591        }
592
593        let mut rebuilt_index_imports = BTreeMap::new();
594        for (id, import) in index_imports {
595            let PinnedIndexImport {
596                desc: IndexDesc { on_id, key },
597                typ,
598                monotonic,
599                with_snapshot,
600            } = import;
601            let info = ctx
602                .index(id)
603                .ok_or(InstantiateError::MissingCatalogItem { id, kind: "index" })?;
604            let key: Vec<MirScalarExpr> = key.iter().map(MirScalarExpr::from).collect();
605            if info.on != on_id {
606                return Err(InstantiateError::ImportDrift {
607                    id,
608                    detail: format!("pinned on {on_id}, catalog says {}", info.on),
609                });
610            }
611            if info.keys != key {
612                return Err(InstantiateError::ImportDrift {
613                    id,
614                    detail: format!("pinned key {key:?}, catalog says {:?}", info.keys),
615                });
616            }
617            if info.typ != typ {
618                return Err(InstantiateError::ImportDrift {
619                    id,
620                    detail: format!("pinned type {typ:?}, catalog says {:?}", info.typ),
621                });
622            }
623            let import = IndexImport {
624                desc: IndexDesc { on_id, key },
625                typ,
626                monotonic,
627                with_snapshot,
628            };
629            rebuilt_index_imports.insert(id, import);
630        }
631
632        let is_built = |id: GlobalId| objects_to_build.iter().any(|object| object.id == id);
633
634        let mut rebuilt_index_exports = BTreeMap::new();
635        for (id, on) in index_exports {
636            let info = ctx
637                .index(id)
638                .ok_or(InstantiateError::MissingCatalogItem { id, kind: "index" })?;
639            if info.on != on {
640                return Err(InstantiateError::ExportDrift {
641                    id,
642                    detail: format!("pinned on {on}, catalog says {}", info.on),
643                });
644            }
645            let Some(build) = objects_to_build.iter().find(|object| object.id == id) else {
646                return Err(InstantiateError::ExportDrift {
647                    id,
648                    detail: "the plan builds no object for the index".to_string(),
649                });
650            };
651            // The index's build arranges by the catalog key, either with an
652            // explicit `ArrangeBy` or, when lowering found the key already
653            // available on the object it reads, with a bare `Get` whose
654            // `keys` lists it.
655            let arranged = match &build.plan.node {
656                LirRelationNode::ArrangeBy { forms, .. } => &forms.arranged,
657                LirRelationNode::Get { keys, .. } => &keys.arranged,
658                node => {
659                    return Err(InstantiateError::ExportDrift {
660                        id,
661                        detail: format!(
662                            "the index's build is neither an arrangement nor a read: {node:?}"
663                        ),
664                    });
665                }
666            };
667            let lir_key: Vec<LirScalarExpr> = info
668                .keys
669                .iter()
670                .map(|expr| {
671                    LirScalarExpr::try_from(expr).expect("catalog index keys are materializable")
672                })
673                .collect();
674            if !arranged.iter().any(|(key, _, _)| *key == lir_key) {
675                return Err(InstantiateError::ExportDrift {
676                    id,
677                    detail: format!("catalog key {lir_key:?} is not among the plan's arrangements"),
678                });
679            }
680            rebuilt_index_exports.insert(
681                id,
682                (
683                    IndexDesc {
684                        on_id: on,
685                        key: info.keys,
686                    },
687                    info.typ,
688                ),
689            );
690        }
691
692        let mut refresh_schedule = None;
693        let mut rebuilt_sink_exports = BTreeMap::new();
694        for (
695            id,
696            PinnedSink {
697                from,
698                kind,
699                from_arrangement,
700            },
701        ) in sink_exports
702        {
703            let from = rename(from);
704            if !is_built(from) {
705                return Err(InstantiateError::ExportDrift {
706                    id,
707                    detail: format!("the plan builds no object {from} for the sink to read"),
708                });
709            }
710            let desc = match kind {
711                PinnedSinkKind::MaterializedView { typ } => {
712                    let info =
713                        ctx.materialized_view(id)
714                            .ok_or(InstantiateError::MissingCatalogItem {
715                                id,
716                                kind: "materialized view",
717                            })?;
718
719                    // TODO(mgree): once we're loading pinned plans, this check compares the
720                    // pinned type against itself.
721                    let catalog_typ = info.desc.typ();
722                    if typ.column_types.len() != catalog_typ.column_types.len() {
723                        return Err(InstantiateError::ExportDrift {
724                            id,
725                            detail: format!(
726                                "pinned {} columns, catalog says {}",
727                                typ.column_types.len(),
728                                catalog_typ.column_types.len()
729                            ),
730                        });
731                    }
732                    for (column, (pinned, catalog)) in typ
733                        .column_types
734                        .iter()
735                        .zip_eq(&catalog_typ.column_types)
736                        .enumerate()
737                    {
738                        if pinned.scalar_type != catalog.scalar_type {
739                            return Err(InstantiateError::ExportDrift {
740                                id,
741                                detail: format!(
742                                    "column {column}: pinned type {:?}, catalog says {:?}",
743                                    pinned.scalar_type, catalog.scalar_type
744                                ),
745                            });
746                        }
747                    }
748                    // Arity and scalar types are checked above, so this
749                    // compares nullability and keys.
750                    if typ != *catalog_typ {
751                        return Err(InstantiateError::ExportDrift {
752                            id,
753                            detail: format!("pinned type {typ:?}, catalog says {catalog_typ:?}"),
754                        });
755                    }
756                    let rel_desc = RelationDesc::new(typ, info.desc.iter_names().cloned());
757                    refresh_schedule.clone_from(&info.refresh_schedule);
758                    ComputeSinkDesc {
759                        from,
760                        from_desc: rel_desc.clone(),
761                        connection: ComputeSinkConnection::MaterializedView(
762                            MaterializedViewSinkConnection {
763                                value_desc: rel_desc,
764                                storage_metadata: (),
765                            },
766                        ),
767                        with_snapshot: true,
768                        up_to: Antichain::new(),
769                        non_null_assertions: info.non_null_assertions,
770                        refresh_schedule: info.refresh_schedule,
771                        from_arrangement,
772                    }
773                }
774                PinnedSinkKind::MetricSink => {
775                    let info = ctx
776                        .metric_sink(id)
777                        .ok_or(InstantiateError::MissingCatalogItem {
778                            id,
779                            kind: "metric sink",
780                        })?;
781                    ComputeSinkDesc {
782                        from,
783                        from_desc: info.from_desc,
784                        connection: ComputeSinkConnection::MetricSink(MetricSinkConnection {
785                            label: info.label,
786                        }),
787                        with_snapshot: true,
788                        up_to: Antichain::new(),
789                        non_null_assertions: Vec::new(),
790                        refresh_schedule: None,
791                        from_arrangement,
792                    }
793                }
794            };
795            rebuilt_sink_exports.insert(id, desc);
796        }
797
798        Ok(DataflowDescription {
799            source_imports: rebuilt_source_imports,
800            index_imports: rebuilt_index_imports,
801            objects_to_build,
802            index_exports: rebuilt_index_exports,
803            sink_exports: rebuilt_sink_exports,
804            as_of: None,
805            until: Antichain::new(),
806            initial_storage_as_of: None,
807            refresh_schedule,
808            debug_name,
809            time_dependence: None,
810        })
811    }
812}
813
814fn replace_global_ids(expr: &mut LirRelationExpr, rename: &impl Fn(GlobalId) -> GlobalId) {
815    let mut todo = vec![expr];
816    while let Some(expr) = todo.pop() {
817        if let LirRelationNode::Get {
818            id: Id::Global(id), ..
819        } = &mut expr.node
820        {
821            *id = rename(*id);
822        }
823        todo.extend(expr.node.children_mut());
824    }
825}
826
827#[cfg(test)]
828#[path = "pinned_tests.rs"]
829mod tests;