Skip to main content

mz_adapter/coord/
catalog_implications.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//! Logic related to deriving and applying implications from [catalog
11//! changes](ParsedStateUpdate).
12//!
13//! The flow from "raw" catalog changes to [CatalogImplication] works like this:
14//!
15//! StateUpdateKind -> ParsedStateUpdate -> CatalogImplication
16//!
17//! [ParsedStateUpdate] adds context to a "raw" catalog change
18//! ([StateUpdateKind](mz_catalog::memory::objects::StateUpdateKind)). It
19//! includes an in-memory representation of the updated object, which can in
20//! theory be derived from the raw change but only when we have access to all
21//! the other raw changes or to an in-memory Catalog, which represents a
22//! "rollup" of all the raw changes.
23//!
24//! [CatalogImplication] is both the state machine that we use for absorbing
25//! multiple state updates for the same object and the final command that has to
26//! be applied to in-memory state and or the controller(s) after absorbing all
27//! the state updates in a given batch of updates.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::sync::Arc;
31use std::time::{Duration, Instant};
32
33use fail::fail_point;
34use itertools::Itertools;
35use mz_adapter_types::compaction::CompactionWindow;
36use mz_catalog::memory::objects::{
37    CatalogItem, Cluster, ClusterReplica, Connection, DataSourceDesc, Index, MaterializedView,
38    MetricSink, Secret, Sink, Source, StateDiff, Table, TableDataSource, View,
39};
40use mz_cloud_resources::VpcEndpointConfig;
41use mz_compute_client::logging::LogVariant;
42use mz_compute_client::protocol::response::PeekResponse;
43use mz_controller::clusters::{ClusterRole, ReplicaConfig};
44use mz_controller_types::{ClusterId, ReplicaId};
45use mz_ore::collections::CollectionExt;
46use mz_ore::error::ErrorExt;
47use mz_ore::future::InTask;
48use mz_ore::instrument;
49use mz_ore::retry::Retry;
50use mz_ore::task;
51use mz_repr::{CatalogItemId, GlobalId, RelationVersion, RelationVersionSelector};
52use mz_sql::plan::ConnectionDetails;
53use mz_storage_client::controller::{CollectionDescription, DataSource};
54use mz_storage_types::connections::PostgresConnection;
55use mz_storage_types::connections::inline::{InlinedConnection, IntoInlineConnection};
56use mz_storage_types::sinks::StorageSinkConnection;
57use mz_storage_types::sources::{
58    GenericSourceConnection, SourceDesc, SourceExport, SourceExportDataConfig,
59};
60use tracing::{Instrument, info_span, warn};
61
62use crate::active_compute_sink::ActiveComputeSinkRetireReason;
63use crate::coord::Coordinator;
64use crate::coord::catalog_implications::parsed_state_updates::{
65    ParsedStateUpdate, ParsedStateUpdateKind,
66};
67use crate::coord::peek::DroppedDependency;
68use crate::coord::timeline::TimelineState;
69use crate::statement_logging::{StatementEndedExecutionReason, StatementLoggingId};
70use crate::{AdapterError, CollectionIdBundle, ExecuteContext, ResultExt};
71
72pub mod parsed_state_updates;
73
74impl Coordinator {
75    /// Applies implications from the given bucket of [ParsedStateUpdate] to our
76    /// in-memory state and our controllers. This also applies transitive
77    /// implications, for example, peeks and subscribes will be cancelled when
78    /// referenced objects are dropped.
79    ///
80    /// This _requires_ that the given updates are consolidated. There must be
81    /// at most one addition and/or one retraction for a given item, as
82    /// identified by that items ID type.
83    #[instrument(level = "debug")]
84    pub async fn apply_catalog_implications(
85        &mut self,
86        ctx: Option<&mut ExecuteContext>,
87        catalog_updates: Vec<ParsedStateUpdate>,
88    ) -> Result<(), AdapterError> {
89        let start = Instant::now();
90
91        let mut catalog_implications: BTreeMap<CatalogItemId, CatalogImplication> = BTreeMap::new();
92        let mut cluster_commands: BTreeMap<ClusterId, CatalogImplication> = BTreeMap::new();
93        let mut cluster_replica_commands: BTreeMap<(ClusterId, ReplicaId), CatalogImplication> =
94            BTreeMap::new();
95        // Introspection source index additions, collected separately and
96        // merged into the AddCluster handler. Not routed through the
97        // absorb machinery since they are simple additions that don't
98        // need Altered support.
99        let mut introspection_source_indexes: BTreeMap<ClusterId, BTreeMap<LogVariant, GlobalId>> =
100            BTreeMap::new();
101        // Whether any replica-scoped system-parameter override changed in this
102        // batch. The push re-pushes the complete per-replica dyncfg layer, so we
103        // only track that a change happened, not the individual rows.
104        let mut replica_scoped_config_changed = false;
105        // Whether any environment-wide system-parameter changed in this batch.
106        // We re-run all `SystemVars` callbacks against the committed values, so
107        // we only track that a change happened, not the individual vars.
108        let mut system_config_changed = false;
109
110        // Whether to wake the cluster controller once the implications below are
111        // applied. Decided from the committed diff, see the method.
112        let should_reconcile_now = Self::should_reconcile_now(&catalog_updates);
113
114        for update in catalog_updates {
115            tracing::trace!(?update, "got parsed state update");
116            match &update.kind {
117                ParsedStateUpdateKind::Item {
118                    durable_item,
119                    parsed_item: _,
120                    connection: _,
121                    parsed_full_name: _,
122                } => {
123                    let entry = catalog_implications
124                        .entry(durable_item.id.clone())
125                        .or_insert_with(|| CatalogImplication::None);
126                    entry.absorb(update);
127                }
128                ParsedStateUpdateKind::Cluster {
129                    durable_cluster,
130                    parsed_cluster: _,
131                } => {
132                    let entry = cluster_commands
133                        .entry(durable_cluster.id)
134                        .or_insert_with(|| CatalogImplication::None);
135                    entry.absorb(update.clone());
136                }
137                ParsedStateUpdateKind::ClusterReplica {
138                    durable_cluster_replica,
139                    parsed_cluster_replica: _,
140                } => {
141                    let entry = cluster_replica_commands
142                        .entry((
143                            durable_cluster_replica.cluster_id,
144                            durable_cluster_replica.replica_id,
145                        ))
146                        .or_insert_with(|| CatalogImplication::None);
147                    entry.absorb(update.clone());
148                }
149                ParsedStateUpdateKind::IntrospectionSourceIndex {
150                    cluster_id,
151                    log,
152                    index_id,
153                } => {
154                    if update.diff == StateDiff::Addition {
155                        introspection_source_indexes
156                            .entry(*cluster_id)
157                            .or_default()
158                            .insert(log.clone(), *index_id);
159                    }
160                    // Retractions don't need handling: introspection
161                    // source indexes are dropped with their cluster.
162                }
163                ParsedStateUpdateKind::ReplicaSystemConfiguration { durable: _ } => {
164                    // Additions and retractions both re-derive the full
165                    // per-replica layer from the working copy, so the diff sign
166                    // does not matter here.
167                    replica_scoped_config_changed = true;
168                }
169                ParsedStateUpdateKind::SystemConfiguration { durable: _ } => {
170                    // Additions and retractions both re-run the callbacks
171                    // against the committed values, so the diff sign does not
172                    // matter here.
173                    system_config_changed = true;
174                }
175            }
176        }
177
178        self.apply_catalog_implications_inner(
179            ctx,
180            catalog_implications.into_iter().collect_vec(),
181            cluster_commands.into_iter().collect_vec(),
182            cluster_replica_commands.into_iter().collect_vec(),
183            introspection_source_indexes,
184            replica_scoped_config_changed,
185            system_config_changed,
186        )
187        .await?;
188
189        if should_reconcile_now {
190            // Wake the controller to reconcile immediately rather than waiting
191            // out its tick interval. A missed or spurious wake is harmless: the
192            // periodic tick is the backstop, and an extra wake costs one no-op
193            // reconcile.
194            self.reconcile_now.notify_one();
195        }
196
197        self.metrics
198            .apply_catalog_implications_seconds
199            .observe(start.elapsed().as_secs_f64());
200
201        Ok(())
202    }
203
204    /// Whether a batch of committed catalog updates should wake the cluster
205    /// controller. True when any cluster or cluster-replica durable state
206    /// changed, the only catalog changes the controller reconciles against.
207    ///
208    /// We key the wake off the committed diff rather than the input ops so it
209    /// fires the same way whether this node applied the change or is following
210    /// another writer's diff. NOTE: environment-wide system-config changes (the
211    /// controller's gate and tick interval) do parse into
212    /// `ParsedStateUpdateKind::SystemConfiguration`, but we deliberately do not
213    /// match on them here, so they do not wake the controller. The controller
214    /// re-reads both each tick, so a config change is picked up on the next tick
215    /// without a wake.
216    fn should_reconcile_now(updates: &[ParsedStateUpdate]) -> bool {
217        updates.iter().any(|update| {
218            matches!(
219                update.kind,
220                ParsedStateUpdateKind::Cluster { .. }
221                    | ParsedStateUpdateKind::ClusterReplica { .. }
222            )
223        })
224    }
225
226    #[instrument(level = "debug")]
227    async fn apply_catalog_implications_inner(
228        &mut self,
229        ctx: Option<&mut ExecuteContext>,
230        implications: Vec<(CatalogItemId, CatalogImplication)>,
231        cluster_commands: Vec<(ClusterId, CatalogImplication)>,
232        cluster_replica_commands: Vec<((ClusterId, ReplicaId), CatalogImplication)>,
233        mut introspection_source_indexes: BTreeMap<ClusterId, BTreeMap<LogVariant, GlobalId>>,
234        replica_scoped_config_changed: bool,
235        system_config_changed: bool,
236    ) -> Result<(), AdapterError> {
237        // Re-run the `SystemVars` callbacks against the committed values.
238        // Deriving this from the committed diff, rather than the input ops, is
239        // what makes it also fire on a follower `environmentd` that only
240        // replays the catalog changes. The callbacks are order-independent
241        // idempotent reads, so we fire them once, up front.
242        if system_config_changed {
243            self.catalog().system_config().notify_all_callbacks();
244        }
245
246        let mut tables_to_drop = BTreeSet::new();
247        let mut sources_to_drop = vec![];
248        let mut replication_slots_to_drop: Vec<(PostgresConnection, String)> = vec![];
249        let mut storage_sink_gids_to_drop = vec![];
250        let mut indexes_to_drop = vec![];
251        let mut compute_sinks_to_drop = vec![];
252        let mut view_gids_to_drop = vec![];
253        let mut secrets_to_drop = vec![];
254        let mut vpc_endpoints_to_drop = vec![];
255        let mut clusters_to_drop = vec![];
256        let mut cluster_replicas_to_drop = vec![];
257        let mut active_compute_sinks_to_drop = BTreeMap::new();
258        let mut peeks_to_drop = vec![];
259        let mut copies_to_drop = vec![];
260
261        // Maps for storing names of dropped objects for error messages.
262        let mut dropped_item_names: BTreeMap<GlobalId, String> = BTreeMap::new();
263        let mut dropped_cluster_names: BTreeMap<ClusterId, String> = BTreeMap::new();
264
265        // Separate collections for tables (which need write timestamps) and
266        // sources (which don't).
267        let mut table_collections_to_create = BTreeMap::new();
268        let mut source_collections_to_create = BTreeMap::new();
269        let mut storage_policies_to_initialize = BTreeMap::new();
270        let mut execution_timestamps_to_set = BTreeSet::new();
271        let mut vpc_endpoints_to_create: Vec<(CatalogItemId, VpcEndpointConfig)> = vec![];
272
273        // Sources that shouldn't be dropped, even if we saw a `Dropped` event.
274        // Used for correct handling of ALTER MV.
275        let mut source_gids_to_keep = BTreeSet::new();
276
277        // Collections for batching connection-related alterations.
278        let mut source_connections_to_alter: BTreeMap<
279            GlobalId,
280            GenericSourceConnection<InlinedConnection>,
281        > = BTreeMap::new();
282        let mut sink_connections_to_alter: BTreeMap<GlobalId, StorageSinkConnection> =
283            BTreeMap::new();
284        let mut source_export_data_configs_to_alter: BTreeMap<GlobalId, SourceExportDataConfig> =
285            BTreeMap::new();
286        let mut source_descs_to_alter: BTreeMap<GlobalId, SourceDesc> = BTreeMap::new();
287
288        // We're incrementally migrating the code that manipulates the
289        // controller from closures in the sequencer. For some types of catalog
290        // changes we haven't done this migration yet, so there you will see
291        // just a log message. Over the next couple of PRs all of these will go
292        // away.
293
294        for (catalog_id, implication) in implications {
295            tracing::trace!(?implication, "have to apply catalog implication");
296
297            match implication {
298                CatalogImplication::Table(CatalogImplicationKind::Added(table)) => {
299                    self.handle_create_table(
300                        &ctx,
301                        &mut table_collections_to_create,
302                        &mut storage_policies_to_initialize,
303                        &mut execution_timestamps_to_set,
304                        catalog_id,
305                        table.clone(),
306                    )
307                    .await?
308                }
309                CatalogImplication::Table(CatalogImplicationKind::Altered {
310                    prev: prev_table,
311                    new: new_table,
312                }) => {
313                    self.handle_alter_table(catalog_id, prev_table, new_table)
314                        .await?
315                }
316
317                CatalogImplication::Table(CatalogImplicationKind::Dropped(table, full_name)) => {
318                    let global_ids = table.global_ids();
319                    for global_id in global_ids {
320                        tables_to_drop.insert((catalog_id, global_id));
321                        dropped_item_names.insert(global_id, full_name.clone());
322                    }
323                }
324                CatalogImplication::Source(CatalogImplicationKind::Added((
325                    source,
326                    _connection,
327                ))) => {
328                    // Get the compaction windows for all sources with this
329                    // catalog_id This replicates the logic from
330                    // sequence_create_source where it collects all item_ids and
331                    // gets their compaction windows
332                    let compaction_windows = self
333                        .catalog()
334                        .state()
335                        .source_compaction_windows(vec![catalog_id]);
336
337                    self.handle_create_source(
338                        &mut source_collections_to_create,
339                        &mut storage_policies_to_initialize,
340                        catalog_id,
341                        source,
342                        compaction_windows,
343                    )
344                    .await?
345                }
346                CatalogImplication::Source(CatalogImplicationKind::Altered {
347                    prev: (prev_source, _prev_connection),
348                    new: (new_source, new_connection),
349                }) => {
350                    if prev_source.custom_logical_compaction_window
351                        != new_source.custom_logical_compaction_window
352                    {
353                        let new_window = new_source
354                            .custom_logical_compaction_window
355                            .unwrap_or(CompactionWindow::Default);
356                        self.update_storage_read_policies(vec![(catalog_id, new_window.into())]);
357                    }
358                    match (&prev_source.data_source, &new_source.data_source) {
359                        (
360                            DataSourceDesc::Ingestion {
361                                desc: prev_desc, ..
362                            }
363                            | DataSourceDesc::OldSyntaxIngestion {
364                                desc: prev_desc, ..
365                            },
366                            DataSourceDesc::Ingestion { desc: new_desc, .. }
367                            | DataSourceDesc::OldSyntaxIngestion { desc: new_desc, .. },
368                        ) => {
369                            if prev_desc != new_desc {
370                                let inlined_connection = new_connection
371                                    .expect("ingestion source should have inlined connection");
372                                let inlined_desc = SourceDesc {
373                                    connection: inlined_connection,
374                                    timestamp_interval: new_desc.timestamp_interval,
375                                };
376                                source_descs_to_alter.insert(new_source.global_id, inlined_desc);
377                            }
378                        }
379                        _ => {}
380                    }
381                }
382                CatalogImplication::Source(CatalogImplicationKind::Dropped(
383                    (source, connection),
384                    full_name,
385                )) => {
386                    let global_id = source.global_id();
387                    sources_to_drop.push((catalog_id, global_id));
388                    dropped_item_names.insert(global_id, full_name);
389
390                    if let DataSourceDesc::Ingestion { desc, .. }
391                    | DataSourceDesc::OldSyntaxIngestion { desc, .. } = &source.data_source
392                    {
393                        match &desc.connection {
394                            GenericSourceConnection::Postgres(_referenced_conn) => {
395                                let inline_conn = connection.expect("missing inlined connection");
396
397                                let pg_conn = match inline_conn {
398                                    GenericSourceConnection::Postgres(pg_conn) => pg_conn,
399                                    other => {
400                                        panic!("expected postgres connection, got: {:?}", other)
401                                    }
402                                };
403                                let pending_drop = (
404                                    pg_conn.connection.clone(),
405                                    pg_conn.publication_details.slot.clone(),
406                                );
407                                replication_slots_to_drop.push(pending_drop);
408                            }
409                            _ => {}
410                        }
411                    }
412                }
413                CatalogImplication::Sink(CatalogImplicationKind::Added(sink)) => {
414                    tracing::debug!(?sink, "not handling AddSink in here yet");
415                }
416                CatalogImplication::Sink(CatalogImplicationKind::Altered {
417                    prev: prev_sink,
418                    new: new_sink,
419                }) => {
420                    tracing::debug!(?prev_sink, ?new_sink, "not handling AlterSink in here yet");
421                }
422                CatalogImplication::Sink(CatalogImplicationKind::Dropped(sink, full_name)) => {
423                    storage_sink_gids_to_drop.push(sink.global_id());
424                    dropped_item_names.insert(sink.global_id(), full_name);
425                }
426                CatalogImplication::Index(CatalogImplicationKind::Added(index)) => {
427                    tracing::debug!(?index, "not handling AddIndex in here yet");
428                }
429                CatalogImplication::Index(CatalogImplicationKind::Altered {
430                    prev: prev_index,
431                    new: new_index,
432                }) => {
433                    if prev_index.custom_logical_compaction_window
434                        != new_index.custom_logical_compaction_window
435                    {
436                        let new_window = new_index
437                            .custom_logical_compaction_window
438                            .unwrap_or(CompactionWindow::Default);
439                        self.update_compute_read_policy(
440                            new_index.cluster_id,
441                            catalog_id,
442                            new_window.into(),
443                        );
444                    }
445                }
446                CatalogImplication::Index(CatalogImplicationKind::Dropped(index, full_name)) => {
447                    indexes_to_drop.push((index.cluster_id, index.global_id()));
448                    dropped_item_names.insert(index.global_id(), full_name);
449                }
450                CatalogImplication::MetricSink(CatalogImplicationKind::Added(_metric_sink)) => {
451                    // Nothing to do, mirroring `Index`: shipping the dataflow at create time is
452                    // the sequencer's job (`create_metric_sink_finish`), and re-rendering it after
453                    // a restart happens during bootstrap (`bootstrap_dataflow_plans`).
454                }
455                CatalogImplication::MetricSink(CatalogImplicationKind::Altered { .. }) => {
456                    // Nothing to do: owner, privilege, and rename changes are catalog-only.
457                }
458                CatalogImplication::MetricSink(CatalogImplicationKind::Dropped(
459                    metric_sink,
460                    full_name,
461                )) => {
462                    // A metric sink is a non-readable leaf compute dataflow, like an MV's write
463                    // side, so it drops through the same path as other compute sinks.
464                    compute_sinks_to_drop.push((metric_sink.cluster_id, metric_sink.global_id));
465                    dropped_item_names.insert(metric_sink.global_id, full_name);
466                }
467                CatalogImplication::MaterializedView(CatalogImplicationKind::Added(mv)) => {
468                    tracing::debug!(?mv, "not handling AddMaterializedView in here yet");
469                }
470                CatalogImplication::MaterializedView(CatalogImplicationKind::Altered {
471                    prev: prev_mv,
472                    new: new_mv,
473                }) => {
474                    // We get here for three reasons:
475                    //  1. Name changes, like those caused by ALTER SCHEMA.
476                    //  2. Replacement application.
477                    //  3. Compaction window changes (ALTER ... SET (RETAIN HISTORY ...)).
478                    //
479                    // 1. Name changes: We don't have to do anything here.
480                    //
481                    // 2. Replacement application: This is tricky: It changes the `CatalogItemId` of
482                    // the target to that of the replacement and simultaneously drops the replacement.
483                    // Which means when we get here `prev_mv` is the replacement that should be
484                    // dropped, and `new_mv` is the target that already exists but under a different
485                    // ID (which will receive a `Dropped` event separately). We can sniff out this
486                    // case by checking for version differences.
487                    //
488                    // 3. Compaction window changes: We handle this in an `else if`, because if there
489                    // is also a replacement application, then the replacement's storage collections
490                    // already have the correct read policies from when they were created, so we
491                    // don't need to update them here.
492                    if prev_mv.collections != new_mv.collections {
493                        // Sanity check: The replacement's last (and only) version must be the same
494                        // as the new target's last version.
495                        assert_eq!(
496                            prev_mv.global_id_writes(),
497                            new_mv.global_id_writes(),
498                            "unexpected MV Altered implication: prev={prev_mv:?}, new={new_mv:?}",
499                        );
500
501                        let gid = new_mv.global_id_writes();
502                        self.allow_writes(new_mv.cluster_id, gid);
503
504                        // There will be a separate `Dropped` implication for the old definition of
505                        // the target MV. That will drop the old compute collection, as we desire,
506                        // but we need to prevent it from dropping the old storage collection as
507                        // well, since that might still be depended on.
508                        source_gids_to_keep.extend(new_mv.global_ids());
509                    } else if prev_mv.custom_logical_compaction_window
510                        != new_mv.custom_logical_compaction_window
511                    {
512                        let new_window = new_mv
513                            .custom_logical_compaction_window
514                            .unwrap_or(CompactionWindow::Default);
515                        self.update_storage_read_policies(vec![(catalog_id, new_window.into())]);
516                    }
517                }
518                CatalogImplication::MaterializedView(CatalogImplicationKind::Dropped(
519                    mv,
520                    full_name,
521                )) => {
522                    compute_sinks_to_drop.push((mv.cluster_id, mv.global_id_writes()));
523                    for gid in mv.global_ids() {
524                        sources_to_drop.push((catalog_id, gid));
525                        dropped_item_names.insert(gid, full_name.clone());
526                    }
527                }
528                CatalogImplication::View(CatalogImplicationKind::Added(_view)) => {
529                    // No action needed: views are catalog-only objects with no
530                    // storage collections or dataflows to create.
531                }
532                CatalogImplication::View(CatalogImplicationKind::Altered {
533                    prev: _prev_view,
534                    new: _new_view,
535                }) => {
536                    // No action needed: view alterations (e.g. renames) are
537                    // catalog-only and require no controller changes.
538                }
539                CatalogImplication::View(CatalogImplicationKind::Dropped(view, full_name)) => {
540                    view_gids_to_drop.push(view.global_id());
541                    dropped_item_names.insert(view.global_id(), full_name);
542                }
543                CatalogImplication::Secret(CatalogImplicationKind::Added(_secret)) => {
544                    // No action needed: the secret payload is stored in
545                    // secrets_controller.ensure() BEFORE the catalog transaction.
546                    // By the time we see this update, the secret is already stored.
547                }
548                CatalogImplication::Secret(CatalogImplicationKind::Altered {
549                    prev: _prev_secret,
550                    new: _new_secret,
551                }) => {
552                    // No action needed: altering a secret updates the payload via
553                    // secrets_controller.ensure() without a catalog transaction.
554                }
555                CatalogImplication::Secret(CatalogImplicationKind::Dropped(
556                    _secret,
557                    _full_name,
558                )) => {
559                    secrets_to_drop.push(catalog_id);
560                }
561                CatalogImplication::Connection(CatalogImplicationKind::Added(connection)) => {
562                    match &connection.details {
563                        // SSH connections: key pair is stored in secrets_controller
564                        // BEFORE the catalog transaction, so no action needed here.
565                        ConnectionDetails::Ssh { .. } => {}
566                        // AWS PrivateLink connections: create the VPC endpoint
567                        ConnectionDetails::AwsPrivatelink(privatelink) => {
568                            let spec = VpcEndpointConfig {
569                                aws_service_name: privatelink.service_name.to_owned(),
570                                availability_zone_ids: privatelink.availability_zones.to_owned(),
571                            };
572                            vpc_endpoints_to_create.push((catalog_id, spec));
573                        }
574                        // Other connection types don't require post-transaction actions
575                        _ => {}
576                    }
577                }
578                CatalogImplication::Connection(CatalogImplicationKind::Altered {
579                    prev: _prev_connection,
580                    new: new_connection,
581                }) => {
582                    self.handle_alter_connection(
583                        catalog_id,
584                        new_connection,
585                        &mut vpc_endpoints_to_create,
586                        &mut source_connections_to_alter,
587                        &mut sink_connections_to_alter,
588                        &mut source_export_data_configs_to_alter,
589                    );
590                }
591                CatalogImplication::Connection(CatalogImplicationKind::Dropped(
592                    connection,
593                    _full_name,
594                )) => {
595                    match &connection.details {
596                        // SSH connections have an associated secret that should be dropped
597                        ConnectionDetails::Ssh { .. } => {
598                            secrets_to_drop.push(catalog_id);
599                        }
600                        // AWS PrivateLink connections have an associated
601                        // VpcEndpoint K8S resource that should be dropped
602                        ConnectionDetails::AwsPrivatelink(_) => {
603                            vpc_endpoints_to_drop.push(catalog_id);
604                        }
605                        _ => (),
606                    }
607                }
608                CatalogImplication::None => {
609                    // Nothing to do for None commands
610                }
611                CatalogImplication::Cluster(_) | CatalogImplication::ClusterReplica(_) => {
612                    unreachable!("clusters and cluster replicas are handled below")
613                }
614                CatalogImplication::Table(CatalogImplicationKind::None)
615                | CatalogImplication::Source(CatalogImplicationKind::None)
616                | CatalogImplication::Sink(CatalogImplicationKind::None)
617                | CatalogImplication::Index(CatalogImplicationKind::None)
618                | CatalogImplication::MetricSink(CatalogImplicationKind::None)
619                | CatalogImplication::MaterializedView(CatalogImplicationKind::None)
620                | CatalogImplication::View(CatalogImplicationKind::None)
621                | CatalogImplication::Secret(CatalogImplicationKind::None)
622                | CatalogImplication::Connection(CatalogImplicationKind::None) => {
623                    unreachable!("will never leave None in place");
624                }
625            }
626        }
627
628        for (cluster_id, command) in cluster_commands {
629            tracing::trace!(?command, "have cluster command to apply!");
630
631            match command {
632                CatalogImplication::Cluster(CatalogImplicationKind::Added(cluster)) => {
633                    // The Cluster's log_indexes is empty at parse time
634                    // because IntrospectionSourceIndex updates are applied
635                    // after the Cluster update. Use the separately collected
636                    // introspection_source_indexes instead.
637                    let arranged_logs = introspection_source_indexes
638                        .remove(&cluster_id)
639                        .unwrap_or_default();
640                    let introspection_source_ids: Vec<_> =
641                        arranged_logs.values().copied().collect();
642
643                    self.controller
644                        .create_cluster(
645                            cluster_id,
646                            mz_controller::clusters::ClusterConfig {
647                                arranged_logs,
648                                workload_class: cluster.config.workload_class.clone(),
649                            },
650                        )
651                        .expect("creating cluster must not fail");
652
653                    if !introspection_source_ids.is_empty() {
654                        self.initialize_compute_read_policies(
655                            introspection_source_ids,
656                            cluster_id,
657                            CompactionWindow::Default,
658                        )
659                        .await;
660                    }
661                }
662                CatalogImplication::Cluster(CatalogImplicationKind::Altered {
663                    prev: prev_cluster,
664                    new: new_cluster,
665                }) => {
666                    // Replica adds/drops/renames from config changes arrive as
667                    // separate AddClusterReplica/DroppedClusterReplica/
668                    // AlterClusterReplica events, so the only cluster-level
669                    // side effect here is updating the workload class on the
670                    // controller when it changes.
671                    if prev_cluster.config.workload_class != new_cluster.config.workload_class {
672                        self.controller.update_cluster_workload_class(
673                            cluster_id,
674                            new_cluster.config.workload_class.clone(),
675                        );
676                    }
677                }
678                CatalogImplication::Cluster(CatalogImplicationKind::Dropped(
679                    cluster,
680                    _full_name,
681                )) => {
682                    clusters_to_drop.push(cluster_id);
683                    dropped_cluster_names.insert(cluster_id, cluster.name);
684                }
685                CatalogImplication::Cluster(CatalogImplicationKind::None) => {
686                    unreachable!("will never leave None in place");
687                }
688                command => {
689                    unreachable!(
690                        "we only handle cluster commands in this map, got: {:?}",
691                        command
692                    );
693                }
694            }
695        }
696
697        // Apply replica-scoped overrides after clusters are created (so their
698        // compute instances exist) but before replicas are created below. The
699        // override layer must be set before `create_replica`, so the new
700        // replica's first configuration replays with its override, and so the
701        // configuration the controller freezes into the replica's process at
702        // provisioning time resolves against it. The push reads the catalog
703        // working copy, which already reflects this transaction's scoped-config
704        // changes.
705        if replica_scoped_config_changed {
706            self.push_replica_dyncfg_overrides();
707        }
708
709        for ((cluster_id, replica_id), command) in cluster_replica_commands {
710            tracing::trace!(?command, "have cluster replica command to apply!");
711
712            match command {
713                CatalogImplication::ClusterReplica(CatalogImplicationKind::Added(replica)) => {
714                    // Read the cluster name and role from the current catalog
715                    // state. This is correct as long as implications are
716                    // processed right after each catalog transaction. For a
717                    // more future-proof approach that tracks cluster info
718                    // locally across transactions, see the last commit of
719                    // https://github.com/ggevay/materialize/tree/implications-cluster-name-tracking
720                    // which removes that logic.
721                    let cluster = self.catalog().get_cluster(cluster_id);
722                    let cluster_name = cluster.name.clone();
723                    let cluster_role = cluster.role();
724                    self.handle_create_cluster_replica(
725                        cluster_id,
726                        replica_id,
727                        cluster_role,
728                        cluster_name,
729                        replica.name.clone(),
730                        replica.config.clone(),
731                    )
732                    .await;
733                }
734                CatalogImplication::ClusterReplica(CatalogImplicationKind::Altered {
735                    prev: _prev_replica,
736                    new: _new_replica,
737                }) => {
738                    // No action needed: cluster replica alterations (e.g.
739                    // renames, owner changes, pending flag changes) are
740                    // catalog-only and require no controller changes.
741                }
742                CatalogImplication::ClusterReplica(CatalogImplicationKind::Dropped(
743                    _replica,
744                    _full_name,
745                )) => {
746                    cluster_replicas_to_drop.push((cluster_id, replica_id));
747                }
748                CatalogImplication::ClusterReplica(CatalogImplicationKind::None) => {
749                    unreachable!("will never leave None in place");
750                }
751                command => {
752                    unreachable!(
753                        "we only handle cluster replica commands in this map, got: {:?}",
754                        command
755                    );
756                }
757            }
758        }
759
760        if !source_collections_to_create.is_empty() {
761            self.create_source_collections(source_collections_to_create)
762                .await?;
763        }
764
765        // Have to create sources first and then tables, because tables within
766        // one transaction can depend on sources.
767        if !table_collections_to_create.is_empty() {
768            self.create_table_collections(table_collections_to_create, execution_timestamps_to_set)
769                .await?;
770        }
771        // It is _very_ important that we only initialize read policies after we
772        // have created all the sources/collections. Some of the sources created
773        // in this collection might have dependencies on other sources, so the
774        // controller must get a chance to install read holds before we set a
775        // policy that might make the since advance.
776        self.initialize_storage_collections(storage_policies_to_initialize)
777            .await?;
778
779        // Create VPC endpoints for AWS PrivateLink connections
780        if !vpc_endpoints_to_create.is_empty() {
781            if let Some(cloud_resource_controller) = self.cloud_resource_controller.as_ref() {
782                for (connection_id, spec) in vpc_endpoints_to_create {
783                    if let Err(err) = cloud_resource_controller
784                        .ensure_vpc_endpoint(connection_id, spec)
785                        .await
786                    {
787                        tracing::error!(?err, "failed to ensure vpc endpoint!");
788                    }
789                }
790            } else {
791                tracing::error!(
792                    "AWS PrivateLink connections unsupported without cloud_resource_controller"
793                );
794            }
795        }
796
797        // Apply batched connection alterations to dependent sources/sinks/tables.
798        if !source_connections_to_alter.is_empty() {
799            self.controller
800                .storage
801                .alter_ingestion_connections(source_connections_to_alter)
802                .await
803                .unwrap_or_terminate("cannot fail to alter ingestion connections");
804        }
805
806        if !sink_connections_to_alter.is_empty() {
807            self.controller
808                .storage
809                .alter_export_connections(sink_connections_to_alter)
810                .await
811                .unwrap_or_terminate("altering export connections after txn must succeed");
812        }
813
814        if !source_export_data_configs_to_alter.is_empty() {
815            self.controller
816                .storage
817                .alter_ingestion_export_data_configs(source_export_data_configs_to_alter)
818                .await
819                .unwrap_or_terminate("altering source export data configs after txn must succeed");
820        }
821
822        if !source_descs_to_alter.is_empty() {
823            self.controller
824                .storage
825                .alter_ingestion_source_desc(source_descs_to_alter)
826                .await
827                .unwrap_or_terminate("cannot fail to alter ingestion source desc");
828        }
829
830        // Apply source drop overwrites.
831        sources_to_drop.retain(|(_, gid)| !source_gids_to_keep.contains(gid));
832
833        let readable_collections_to_drop: BTreeSet<_> = sources_to_drop
834            .iter()
835            .map(|(_, gid)| *gid)
836            .chain(tables_to_drop.iter().map(|(_, gid)| *gid))
837            .chain(indexes_to_drop.iter().map(|(_, gid)| *gid))
838            .chain(view_gids_to_drop.iter().copied())
839            .collect();
840
841        // Clean up any active compute sinks like subscribes or copy to-s that
842        // rely on dropped relations or clusters.
843        for (sink_id, sink) in &self.active_compute_sinks {
844            let cluster_id = sink.cluster_id();
845            if let Some(id) = sink
846                .depends_on()
847                .iter()
848                .find(|id| readable_collections_to_drop.contains(id))
849            {
850                let name = dropped_item_names
851                    .get(id)
852                    .cloned()
853                    .expect("missing relation name");
854                active_compute_sinks_to_drop.insert(
855                    *sink_id,
856                    ActiveComputeSinkRetireReason::DependencyDropped(DroppedDependency::Relation {
857                        name,
858                    }),
859                );
860            } else if clusters_to_drop.contains(&cluster_id) {
861                let name = dropped_cluster_names
862                    .get(&cluster_id)
863                    .cloned()
864                    .expect("missing cluster name");
865                active_compute_sinks_to_drop.insert(
866                    *sink_id,
867                    ActiveComputeSinkRetireReason::DependencyDropped(DroppedDependency::Cluster {
868                        name,
869                    }),
870                );
871            }
872        }
873
874        // Clean up any pending peeks that rely on dropped relations or clusters.
875        for (uuid, pending_peek) in &self.pending_peeks {
876            if let Some(id) = pending_peek
877                .depends_on
878                .iter()
879                .find(|id| readable_collections_to_drop.contains(id))
880            {
881                let name = dropped_item_names
882                    .get(id)
883                    .cloned()
884                    .expect("missing relation name");
885                peeks_to_drop.push((DroppedDependency::Relation { name }, uuid.clone()));
886            } else if clusters_to_drop.contains(&pending_peek.cluster_id) {
887                let name = dropped_cluster_names
888                    .get(&pending_peek.cluster_id)
889                    .cloned()
890                    .expect("missing cluster name");
891                peeks_to_drop.push((DroppedDependency::Cluster { name }, uuid.clone()));
892            }
893        }
894
895        // Clean up any pending `COPY` statements that rely on dropped relations or clusters.
896        for (conn_id, pending_copy) in &self.active_copies {
897            let dropping_table = tables_to_drop
898                .iter()
899                .any(|(item_id, _gid)| pending_copy.table_id == *item_id);
900            let dropping_cluster = clusters_to_drop.contains(&pending_copy.cluster_id);
901
902            if dropping_table || dropping_cluster {
903                copies_to_drop.push(conn_id.clone());
904            }
905        }
906
907        let storage_gids_to_drop: BTreeSet<_> = sources_to_drop
908            .iter()
909            .map(|(_id, gid)| gid)
910            .chain(storage_sink_gids_to_drop.iter())
911            .chain(tables_to_drop.iter().map(|(_id, gid)| gid))
912            .copied()
913            .collect();
914        let compute_gids_to_drop: Vec<_> = indexes_to_drop
915            .iter()
916            .chain(compute_sinks_to_drop.iter())
917            .copied()
918            .collect();
919
920        // Gather resources that we have to remove from timeline state and
921        // pre-check if any Timelines become empty, when we drop the specified
922        // storage and compute resources.
923        //
924        // Note: We only apply these changes below.
925        let mut timeline_id_bundles = BTreeMap::new();
926
927        for (timeline, TimelineState { read_holds, .. }) in &self.global_timelines {
928            let mut id_bundle = CollectionIdBundle::default();
929
930            for storage_id in read_holds.storage_ids() {
931                if storage_gids_to_drop.contains(&storage_id) {
932                    id_bundle.storage_ids.insert(storage_id);
933                }
934            }
935
936            for (instance_id, id) in read_holds.compute_ids() {
937                if compute_gids_to_drop.contains(&(instance_id, id))
938                    || clusters_to_drop.contains(&instance_id)
939                {
940                    id_bundle
941                        .compute_ids
942                        .entry(instance_id)
943                        .or_default()
944                        .insert(id);
945                }
946            }
947
948            timeline_id_bundles.insert(timeline.clone(), id_bundle);
949        }
950
951        let mut timeline_associations = BTreeMap::new();
952        for (timeline, id_bundle) in timeline_id_bundles.into_iter() {
953            let TimelineState { read_holds, .. } = self
954                .global_timelines
955                .get(&timeline)
956                .expect("all timelines have a timestamp oracle");
957
958            let empty = read_holds.id_bundle().difference(&id_bundle).is_empty();
959            timeline_associations.insert(timeline, (empty, id_bundle));
960        }
961
962        // No error returns are allowed after this point. Enforce this at compile time
963        // by using this odd structure so we don't accidentally add a stray `?`.
964        let _: () = async {
965            if !timeline_associations.is_empty() {
966                for (timeline, (should_be_empty, id_bundle)) in timeline_associations {
967                    let became_empty =
968                        self.remove_resources_associated_with_timeline(timeline, id_bundle);
969                    assert_eq!(should_be_empty, became_empty, "emptiness did not match!");
970                }
971            }
972
973            // Note that we drop tables before sources since there can be a weak
974            // dependency on sources from tables in the storage controller that
975            // will result in error logging that we'd prefer to avoid. This
976            // isn't an actual dependency issue but we'd like to keep that error
977            // logging around to indicate when an actual dependency error might
978            // occur.
979            if !tables_to_drop.is_empty() {
980                self.drop_tables(tables_to_drop.into_iter().collect_vec())
981                    .await;
982            }
983
984            if !sources_to_drop.is_empty() {
985                self.drop_sources(sources_to_drop);
986            }
987
988            if !storage_sink_gids_to_drop.is_empty() {
989                self.drop_storage_sinks(storage_sink_gids_to_drop);
990            }
991
992            if !active_compute_sinks_to_drop.is_empty() {
993                let retire_notify = self
994                    .retire_compute_sinks(active_compute_sinks_to_drop)
995                    .await;
996                if let Some(ctx) = ctx {
997                    ctx.delay_response_until(retire_notify);
998                }
999            }
1000
1001            if !peeks_to_drop.is_empty() {
1002                for (dep, uuid) in peeks_to_drop {
1003                    if let Some(pending_peek) = self.remove_pending_peek(&uuid) {
1004                        let cancel_reason = PeekResponse::Error(dep.query_terminated_error());
1005                        self.controller
1006                            .compute
1007                            .cancel_peek(pending_peek.cluster_id, uuid, cancel_reason)
1008                            .unwrap_or_terminate("unable to cancel peek");
1009                        self.retire_execution(
1010                            StatementEndedExecutionReason::Canceled,
1011                            pending_peek.ctx_extra.defuse(),
1012                        );
1013                    }
1014                }
1015            }
1016
1017            if !copies_to_drop.is_empty() {
1018                for conn_id in copies_to_drop {
1019                    self.cancel_pending_copy(&conn_id);
1020                }
1021            }
1022
1023            if !compute_gids_to_drop.is_empty() {
1024                self.drop_compute_collections(compute_gids_to_drop);
1025            }
1026
1027            if !vpc_endpoints_to_drop.is_empty() {
1028                self.drop_vpc_endpoints_in_background(vpc_endpoints_to_drop)
1029            }
1030
1031            if !cluster_replicas_to_drop.is_empty() {
1032                fail::fail_point!("after_catalog_drop_replica");
1033
1034                for (cluster_id, replica_id) in cluster_replicas_to_drop {
1035                    self.drop_replica(cluster_id, replica_id);
1036                }
1037            }
1038            if !clusters_to_drop.is_empty() {
1039                for cluster_id in clusters_to_drop {
1040                    self.controller.drop_cluster(cluster_id);
1041                }
1042            }
1043
1044            // We don't want to block the main coordinator thread on cleaning
1045            // up external resources (PostgreSQL replication slots and secrets),
1046            // so we perform that cleanup in a background task.
1047            //
1048            // TODO(14551): This is inherently best effort. An ill-timed crash
1049            // means we'll never clean these resources up. Safer cleanup for non-Materialize resources.
1050            // See <https://github.com/MaterializeInc/materialize/issues/14551>
1051            task::spawn(|| "drop_replication_slots_and_secrets", {
1052                let ssh_tunnel_manager = self.connection_context().ssh_tunnel_manager.clone();
1053                let caching_secrets_reader = self.caching_secrets_reader.clone();
1054                let secrets_controller = Arc::clone(&self.secrets_controller);
1055                let secrets_reader = Arc::clone(self.secrets_reader());
1056                let storage_config = self.controller.storage.config().clone();
1057
1058                async move {
1059                    for (connection, replication_slot_name) in replication_slots_to_drop {
1060                        tracing::info!(?replication_slot_name, "dropping replication slot");
1061
1062                        // Try to drop the replication slots, but give up after
1063                        // a while. The PostgreSQL server may no longer be
1064                        // healthy. Users often drop PostgreSQL sources
1065                        // *because* the PostgreSQL server has been
1066                        // decomissioned.
1067                        let result: Result<(), anyhow::Error> = Retry::default()
1068                            .max_duration(Duration::from_secs(60))
1069                            .retry_async(|_state| async {
1070                                let config = connection
1071                                    .config(&secrets_reader, &storage_config, InTask::No)
1072                                    .await
1073                                    .map_err(|e| {
1074                                        anyhow::anyhow!(
1075                                            "error creating Postgres client for \
1076                                            dropping acquired slots: {}",
1077                                            e.display_with_causes()
1078                                        )
1079                                    })?;
1080
1081                                mz_postgres_util::drop_replication_slots(
1082                                    &ssh_tunnel_manager,
1083                                    config.clone(),
1084                                    &[(&replication_slot_name, true)],
1085                                )
1086                                .await?;
1087
1088                                Ok(())
1089                            })
1090                            .await;
1091
1092                        if let Err(err) = result {
1093                            tracing::warn!(
1094                                ?replication_slot_name,
1095                                ?err,
1096                                "failed to drop replication slot"
1097                            );
1098                        }
1099                    }
1100
1101                    // Drop secrets *after* dropping the replication slots,
1102                    // because dropping replication slots may rely on those
1103                    // secrets still being present.
1104                    //
1105                    // It's okay if we crash before processing the secret drops,
1106                    // as we look for and remove any orphaned secrets during
1107                    // startup.
1108                    fail_point!("drop_secrets");
1109                    for secret in secrets_to_drop {
1110                        if let Err(e) = secrets_controller.delete(secret).await {
1111                            warn!("Dropping secrets has encountered an error: {}", e);
1112                        } else {
1113                            caching_secrets_reader.invalidate(secret);
1114                        }
1115                    }
1116                }
1117            });
1118        }
1119        .instrument(info_span!(
1120            "coord::apply_catalog_implications_inner::finalize"
1121        ))
1122        .await;
1123
1124        Ok(())
1125    }
1126
1127    #[instrument(level = "debug")]
1128    async fn create_table_collections(
1129        &mut self,
1130        table_collections_to_create: BTreeMap<GlobalId, CollectionDescription>,
1131        execution_timestamps_to_set: BTreeSet<StatementLoggingId>,
1132    ) -> Result<(), AdapterError> {
1133        // Storage filters table catalog items that are not managed by txn-wal.
1134        let table_ids: Vec<GlobalId> = table_collections_to_create.keys().copied().collect();
1135        let collections = table_collections_to_create.into_iter().collect_vec();
1136
1137        // Confirm leadership after allocating the collections' initial timestamp.
1138        let write_ts = self.get_local_write_ts().await;
1139        let register_ts = write_ts.timestamp;
1140        self.catalog
1141            .advance_upper(write_ts.advance_to)
1142            .await
1143            .unwrap_or_terminate("unable to advance catalog upper");
1144
1145        {
1146            let storage_metadata = self.catalog.state().storage_metadata();
1147            self.controller
1148                .storage
1149                .create_collections(storage_metadata, Some(register_ts), collections)
1150                .await
1151                .unwrap_or_terminate("cannot fail to create collections");
1152        }
1153
1154        // Registration can choose a later timestamp than the collections' initial since. Reads
1155        // remain above the applied registration timestamp.
1156        let registrations = self
1157            .controller
1158            .storage
1159            .table_registrations(table_ids)
1160            .unwrap_or_terminate("cannot fail to look up table registrations");
1161        let table_ts = if registrations.is_empty() {
1162            // Without txn-wal registration, this timestamp still makes the collections readable.
1163            self.apply_local_write(register_ts).await;
1164            register_ts
1165        } else {
1166            self.register_tables_via_committer(registrations).await
1167        };
1168
1169        for id in execution_timestamps_to_set {
1170            self.set_statement_execution_timestamp(id, table_ts);
1171        }
1172
1173        Ok(())
1174    }
1175
1176    #[instrument(level = "debug")]
1177    async fn create_source_collections(
1178        &mut self,
1179        source_collections_to_create: BTreeMap<GlobalId, CollectionDescription>,
1180    ) -> Result<(), AdapterError> {
1181        let storage_metadata = self.catalog.state().storage_metadata();
1182
1183        self.controller
1184            .storage
1185            .create_collections(
1186                storage_metadata,
1187                None, // Sources don't need a write timestamp
1188                source_collections_to_create.into_iter().collect_vec(),
1189            )
1190            .await
1191            .unwrap_or_terminate("cannot fail to create collections");
1192
1193        Ok(())
1194    }
1195
1196    #[instrument(level = "debug")]
1197    async fn initialize_storage_collections(
1198        &mut self,
1199        storage_policies_to_initialize: BTreeMap<CompactionWindow, BTreeSet<GlobalId>>,
1200    ) -> Result<(), AdapterError> {
1201        for (compaction_window, global_ids) in storage_policies_to_initialize {
1202            self.initialize_read_policies(
1203                &CollectionIdBundle {
1204                    storage_ids: global_ids,
1205                    compute_ids: BTreeMap::new(),
1206                },
1207                compaction_window,
1208            )
1209            .await;
1210        }
1211
1212        Ok(())
1213    }
1214
1215    #[instrument(level = "debug")]
1216    async fn handle_create_table(
1217        &self,
1218        ctx: &Option<&mut ExecuteContext>,
1219        storage_collections_to_create: &mut BTreeMap<GlobalId, CollectionDescription>,
1220        storage_policies_to_initialize: &mut BTreeMap<CompactionWindow, BTreeSet<GlobalId>>,
1221        execution_timestamps_to_set: &mut BTreeSet<StatementLoggingId>,
1222        table_id: CatalogItemId,
1223        table: Table,
1224    ) -> Result<(), AdapterError> {
1225        // The table data_source determines whether this table will be written to
1226        // by environmentd (e.g. with INSERT INTO statements) or by the storage layer
1227        // (e.g. a source-fed table).
1228        match &table.data_source {
1229            TableDataSource::TableWrites { defaults: _ } => {
1230                let versions: BTreeMap<_, _> = table
1231                    .collection_descs()
1232                    .map(|(gid, version, desc)| (version, (gid, desc)))
1233                    .collect();
1234                let collection_descs = versions.iter().map(|(_version, (gid, desc))| {
1235                    let collection_desc = CollectionDescription::for_table(desc.clone());
1236
1237                    (*gid, collection_desc)
1238                });
1239
1240                let compaction_window = table
1241                    .custom_logical_compaction_window
1242                    .unwrap_or(CompactionWindow::Default);
1243                let ids_to_initialize = storage_policies_to_initialize
1244                    .entry(compaction_window)
1245                    .or_default();
1246
1247                for (gid, collection_desc) in collection_descs {
1248                    storage_collections_to_create.insert(gid, collection_desc);
1249                    ids_to_initialize.insert(gid);
1250                }
1251
1252                if let Some(id) = ctx.as_ref().and_then(|ctx| ctx.extra().contents()) {
1253                    execution_timestamps_to_set.insert(id);
1254                }
1255            }
1256            TableDataSource::DataSource {
1257                desc: data_source_desc,
1258                timeline,
1259            } => {
1260                match data_source_desc {
1261                    DataSourceDesc::IngestionExport {
1262                        ingestion_id,
1263                        external_reference: _,
1264                        details,
1265                        data_config,
1266                    } => {
1267                        let global_ingestion_id =
1268                            self.catalog().get_entry(ingestion_id).latest_global_id();
1269
1270                        let collection_desc = CollectionDescription {
1271                            desc: table.desc.latest(),
1272                            data_source: DataSource::IngestionExport {
1273                                ingestion_id: global_ingestion_id,
1274                                details: details.clone(),
1275                                data_config: data_config
1276                                    .clone()
1277                                    .into_inline_connection(self.catalog.state()),
1278                            },
1279                            since: None,
1280                            timeline: Some(timeline.clone()),
1281                            primary: None,
1282                        };
1283
1284                        let global_id = table
1285                            .global_ids()
1286                            .expect_element(|| "subsources cannot have multiple versions");
1287
1288                        storage_collections_to_create.insert(global_id, collection_desc);
1289
1290                        let read_policies = self
1291                            .catalog()
1292                            .state()
1293                            .source_compaction_windows(vec![table_id]);
1294                        for (compaction_window, catalog_ids) in read_policies {
1295                            let compaction_ids = storage_policies_to_initialize
1296                                .entry(compaction_window)
1297                                .or_default();
1298
1299                            let gids = catalog_ids
1300                                .into_iter()
1301                                .map(|item_id| self.catalog().get_entry(&item_id).global_ids())
1302                                .flatten();
1303                            compaction_ids.extend(gids);
1304                        }
1305                    }
1306                    DataSourceDesc::Webhook {
1307                        validate_using: _,
1308                        body_format: _,
1309                        headers: _,
1310                        cluster_id: _,
1311                    } => {
1312                        // Create the underlying collection with the latest schema from the Table.
1313                        assert_eq!(
1314                            table.desc.latest_version(),
1315                            RelationVersion::root(),
1316                            "found webhook with more than 1 relation version, {:?}",
1317                            table.desc
1318                        );
1319                        let desc = table.desc.latest();
1320
1321                        let collection_desc = CollectionDescription {
1322                            desc,
1323                            data_source: DataSource::Webhook,
1324                            since: None,
1325                            timeline: Some(timeline.clone()),
1326                            primary: None,
1327                        };
1328
1329                        let global_id = table
1330                            .global_ids()
1331                            .expect_element(|| "webhooks cannot have multiple versions");
1332
1333                        storage_collections_to_create.insert(global_id, collection_desc);
1334
1335                        let read_policies = self
1336                            .catalog()
1337                            .state()
1338                            .source_compaction_windows(vec![table_id]);
1339
1340                        for (compaction_window, catalog_ids) in read_policies {
1341                            let compaction_ids = storage_policies_to_initialize
1342                                .entry(compaction_window)
1343                                .or_default();
1344
1345                            let gids = catalog_ids
1346                                .into_iter()
1347                                .map(|item_id| self.catalog().get_entry(&item_id).global_ids())
1348                                .flatten();
1349                            compaction_ids.extend(gids);
1350                        }
1351                    }
1352                    _ => unreachable!("CREATE TABLE data source got {:?}", data_source_desc),
1353                }
1354            }
1355        }
1356
1357        Ok(())
1358    }
1359
1360    #[instrument(level = "debug")]
1361    async fn handle_alter_table(
1362        &mut self,
1363        catalog_id: CatalogItemId,
1364        prev_table: Table,
1365        new_table: Table,
1366    ) -> Result<(), AdapterError> {
1367        let existing_gid = prev_table.global_id_writes();
1368        let new_gid = new_table.global_id_writes();
1369
1370        if existing_gid == new_gid {
1371            // It's not an ALTER TABLE ADD COLUMN, because we still have the
1372            // same GlobalId. It might be a compaction window change.
1373            if prev_table.custom_logical_compaction_window
1374                != new_table.custom_logical_compaction_window
1375            {
1376                let new_window = new_table
1377                    .custom_logical_compaction_window
1378                    .unwrap_or(CompactionWindow::Default);
1379                self.update_storage_read_policies(vec![(catalog_id, new_window.into())]);
1380            }
1381            return Ok(());
1382        }
1383
1384        // Acquire a read hold on the original table for the duration of
1385        // the alter to prevent the since of the original table from
1386        // getting advanced, while the ALTER is running.
1387        let existing_table = crate::CollectionIdBundle {
1388            storage_ids: BTreeSet::from([existing_gid]),
1389            compute_ids: BTreeMap::new(),
1390        };
1391        let existing_table_read_hold = self.acquire_read_holds(&existing_table);
1392
1393        let expected_version = prev_table.desc.latest_version();
1394        let new_version = new_table.desc.latest_version();
1395        let new_desc = new_table
1396            .desc
1397            .at_version(RelationVersionSelector::Specific(new_version));
1398
1399        // Confirm leadership before mutating controller state.
1400        let write_ts = self.get_local_write_ts().await;
1401        self.catalog
1402            .advance_upper(write_ts.advance_to)
1403            .await
1404            .unwrap_or_terminate("unable to advance catalog upper");
1405
1406        self.controller
1407            .storage
1408            .alter_table_desc(existing_gid, new_gid, new_desc, expected_version)
1409            .await
1410            .unwrap_or_terminate("failed to alter desc of table");
1411
1412        // FIFO registration follows all staged writes to the old collection.
1413        let registrations = self
1414            .controller
1415            .storage
1416            .table_registrations(vec![new_gid])
1417            .unwrap_or_terminate("cannot fail to look up table registrations");
1418        self.register_tables_via_committer(registrations).await;
1419
1420        // Initialize the ReadPolicy which ensures we have the correct read holds.
1421        let compaction_window = new_table
1422            .custom_logical_compaction_window
1423            .unwrap_or(CompactionWindow::Default);
1424        self.initialize_read_policies(
1425            &crate::CollectionIdBundle {
1426                storage_ids: BTreeSet::from([new_gid]),
1427                compute_ids: BTreeMap::new(),
1428            },
1429            compaction_window,
1430        )
1431        .await;
1432
1433        // Alter is complete! We can drop our read hold.
1434        drop(existing_table_read_hold);
1435
1436        Ok(())
1437    }
1438
1439    #[instrument(level = "debug")]
1440    async fn handle_create_source(
1441        &self,
1442        storage_collections_to_create: &mut BTreeMap<GlobalId, CollectionDescription>,
1443        storage_policies_to_initialize: &mut BTreeMap<CompactionWindow, BTreeSet<GlobalId>>,
1444        item_id: CatalogItemId,
1445        source: Source,
1446        compaction_windows: BTreeMap<CompactionWindow, BTreeSet<CatalogItemId>>,
1447    ) -> Result<(), AdapterError> {
1448        let data_source = match source.data_source {
1449            DataSourceDesc::Ingestion { desc, cluster_id } => {
1450                let desc = desc.into_inline_connection(self.catalog().state());
1451                let item_global_id = self.catalog().get_entry(&item_id).latest_global_id();
1452
1453                let ingestion = mz_storage_types::sources::IngestionDescription::new(
1454                    desc,
1455                    cluster_id,
1456                    item_global_id,
1457                );
1458
1459                DataSource::Ingestion(ingestion)
1460            }
1461            DataSourceDesc::OldSyntaxIngestion {
1462                desc,
1463                progress_subsource,
1464                data_config,
1465                details,
1466                cluster_id,
1467            } => {
1468                let desc = desc.into_inline_connection(self.catalog().state());
1469                let data_config = data_config.into_inline_connection(self.catalog().state());
1470
1471                // TODO(parkmycar): We should probably check the type here, but I'm not
1472                // sure if this will always be a Source or a Table.
1473                let progress_subsource = self
1474                    .catalog()
1475                    .get_entry(&progress_subsource)
1476                    .latest_global_id();
1477
1478                let mut ingestion = mz_storage_types::sources::IngestionDescription::new(
1479                    desc,
1480                    cluster_id,
1481                    progress_subsource,
1482                );
1483
1484                let legacy_export = SourceExport {
1485                    storage_metadata: (),
1486                    data_config,
1487                    details,
1488                };
1489
1490                ingestion
1491                    .source_exports
1492                    .insert(source.global_id, legacy_export);
1493
1494                DataSource::Ingestion(ingestion)
1495            }
1496            DataSourceDesc::IngestionExport {
1497                ingestion_id,
1498                external_reference: _,
1499                details,
1500                data_config,
1501            } => {
1502                // TODO(parkmycar): We should probably check the type here, but I'm not sure if
1503                // this will always be a Source or a Table.
1504                let ingestion_id = self.catalog().get_entry(&ingestion_id).latest_global_id();
1505
1506                DataSource::IngestionExport {
1507                    ingestion_id,
1508                    details,
1509                    data_config: data_config.into_inline_connection(self.catalog().state()),
1510                }
1511            }
1512            DataSourceDesc::Progress => DataSource::Progress,
1513            DataSourceDesc::Webhook { .. } => DataSource::Webhook,
1514            DataSourceDesc::Introspection(_) | DataSourceDesc::Catalog => {
1515                unreachable!("cannot create sources with internal data sources")
1516            }
1517        };
1518
1519        storage_collections_to_create.insert(
1520            source.global_id,
1521            CollectionDescription {
1522                desc: source.desc.clone(),
1523                data_source,
1524                timeline: Some(source.timeline),
1525                since: None,
1526                primary: None,
1527            },
1528        );
1529
1530        // Initialize read policies for the source
1531        for (compaction_window, catalog_ids) in compaction_windows {
1532            let compaction_ids = storage_policies_to_initialize
1533                .entry(compaction_window)
1534                .or_default();
1535
1536            let gids = catalog_ids
1537                .into_iter()
1538                .map(|item_id| self.catalog().get_entry(&item_id).global_ids())
1539                .flatten();
1540            compaction_ids.extend(gids);
1541        }
1542
1543        Ok(())
1544    }
1545
1546    /// Handles altering a connection by collecting all the dependent sources,
1547    /// sinks, and tables that need their connection updated.
1548    ///
1549    /// This mirrors the logic from `sequence_alter_connection_stage_finish` but
1550    /// collects the changes into batched collections for application after all
1551    /// implications are processed.
1552    #[instrument(level = "debug")]
1553    fn handle_alter_connection(
1554        &self,
1555        connection_id: CatalogItemId,
1556        connection: Connection,
1557        vpc_endpoints_to_create: &mut Vec<(CatalogItemId, VpcEndpointConfig)>,
1558        source_connections_to_alter: &mut BTreeMap<
1559            GlobalId,
1560            GenericSourceConnection<InlinedConnection>,
1561        >,
1562        sink_connections_to_alter: &mut BTreeMap<GlobalId, StorageSinkConnection>,
1563        source_export_data_configs_to_alter: &mut BTreeMap<GlobalId, SourceExportDataConfig>,
1564    ) {
1565        use std::collections::VecDeque;
1566
1567        // Handle AWS PrivateLink connections by queueing VPC endpoint creation.
1568        if let ConnectionDetails::AwsPrivatelink(ref privatelink) = connection.details {
1569            let spec = VpcEndpointConfig {
1570                aws_service_name: privatelink.service_name.to_owned(),
1571                availability_zone_ids: privatelink.availability_zones.to_owned(),
1572            };
1573            vpc_endpoints_to_create.push((connection_id, spec));
1574        }
1575
1576        // Walk the dependency graph to find all sources, sinks, and tables
1577        // that depend on this connection (directly or transitively through
1578        // other connections).
1579        let mut connections_to_process = VecDeque::new();
1580        connections_to_process.push_front(connection_id.clone());
1581
1582        while let Some(id) = connections_to_process.pop_front() {
1583            for dependent_id in self.catalog().get_entry(&id).used_by() {
1584                let dependent_entry = self.catalog().get_entry(dependent_id);
1585                match dependent_entry.item() {
1586                    CatalogItem::Connection(_) => {
1587                        // Connections can depend on other connections (e.g., a
1588                        // Kafka connection using an SSH tunnel connection).
1589                        // Process these transitively.
1590                        connections_to_process.push_back(*dependent_id);
1591                    }
1592                    CatalogItem::Source(source) => {
1593                        let desc = match &dependent_entry
1594                            .source()
1595                            .expect("known to be source")
1596                            .data_source
1597                        {
1598                            DataSourceDesc::Ingestion { desc, .. }
1599                            | DataSourceDesc::OldSyntaxIngestion { desc, .. } => {
1600                                desc.clone().into_inline_connection(self.catalog().state())
1601                            }
1602                            DataSourceDesc::IngestionExport { .. }
1603                            | DataSourceDesc::Introspection(_)
1604                            | DataSourceDesc::Progress
1605                            | DataSourceDesc::Webhook { .. }
1606                            | DataSourceDesc::Catalog => {
1607                                // Only ingestions reference connections directly.
1608                                continue;
1609                            }
1610                        };
1611
1612                        source_connections_to_alter.insert(source.global_id, desc.connection);
1613                    }
1614                    CatalogItem::Sink(sink) => {
1615                        let export = dependent_entry.sink().expect("known to be sink");
1616                        sink_connections_to_alter.insert(
1617                            sink.global_id,
1618                            export
1619                                .connection
1620                                .clone()
1621                                .into_inline_connection(self.catalog().state()),
1622                        );
1623                    }
1624                    CatalogItem::Table(table) => {
1625                        // This is a source-fed table that references a schema
1626                        // registry connection as part of its encoding/data
1627                        // config.
1628                        if let Some((_, _, _, export_data_config)) =
1629                            dependent_entry.source_export_details()
1630                        {
1631                            let data_config = export_data_config.clone();
1632                            source_export_data_configs_to_alter.insert(
1633                                table.global_id_writes(),
1634                                data_config.into_inline_connection(self.catalog().state()),
1635                            );
1636                        }
1637                    }
1638                    CatalogItem::Log(_)
1639                    | CatalogItem::View(_)
1640                    | CatalogItem::MaterializedView(_)
1641                    | CatalogItem::Index(_)
1642                    | CatalogItem::Type(_)
1643                    | CatalogItem::Func(_)
1644                    | CatalogItem::Secret(_)
1645                    | CatalogItem::MetricSink(_) => {
1646                        // Other item types don't have connection dependencies
1647                        // that need updating.
1648                    }
1649                }
1650            }
1651        }
1652    }
1653
1654    async fn handle_create_cluster_replica(
1655        &mut self,
1656        cluster_id: ClusterId,
1657        replica_id: ReplicaId,
1658        role: ClusterRole,
1659        cluster_name: String,
1660        replica_name: String,
1661        replica_config: ReplicaConfig,
1662    ) {
1663        let enable_worker_core_affinity =
1664            self.catalog().system_config().enable_worker_core_affinity();
1665        let enable_storage_introspection_logs = self
1666            .catalog()
1667            .system_config()
1668            .enable_storage_introspection_logs();
1669
1670        // This replica's scoped (replica-local) overrides were pushed into the
1671        // controller's per-replica layer before this loop, by the
1672        // replica-scoped-configuration implication, so the replica's first
1673        // configuration replays with them. Render-frozen flags make a later push
1674        // too late, which is why the push precedes `create_replica`.
1675
1676        self.controller
1677            .create_replica(
1678                cluster_id,
1679                replica_id,
1680                cluster_name,
1681                replica_name,
1682                role,
1683                replica_config,
1684                enable_worker_core_affinity,
1685                enable_storage_introspection_logs,
1686            )
1687            .expect("creating replicas must not fail");
1688
1689        self.install_introspection_subscribes(cluster_id, replica_id)
1690            .await;
1691    }
1692}
1693
1694/// A state machine for building catalog implications from catalog updates.
1695///
1696/// Once all [ParsedStateUpdate] of a timestamp are ingested this is a command
1697/// that has to potentially be applied to in-memory state and/or the
1698/// controller(s).
1699#[derive(Debug, Clone)]
1700enum CatalogImplication {
1701    None,
1702    Table(CatalogImplicationKind<Table>),
1703    Source(CatalogImplicationKind<(Source, Option<GenericSourceConnection>)>),
1704    Sink(CatalogImplicationKind<Sink>),
1705    Index(CatalogImplicationKind<Index>),
1706    MetricSink(CatalogImplicationKind<MetricSink>),
1707    MaterializedView(CatalogImplicationKind<MaterializedView>),
1708    View(CatalogImplicationKind<View>),
1709    Secret(CatalogImplicationKind<Secret>),
1710    Connection(CatalogImplicationKind<Connection>),
1711    Cluster(CatalogImplicationKind<Cluster>),
1712    ClusterReplica(CatalogImplicationKind<ClusterReplica>),
1713}
1714
1715#[derive(Debug, Clone)]
1716enum CatalogImplicationKind<T> {
1717    /// No operations seen yet.
1718    None,
1719    /// Item was added.
1720    Added(T),
1721    /// Item was dropped (with its name retained for error messages).
1722    Dropped(T, String),
1723    /// Item is being altered from one state to another.
1724    Altered { prev: T, new: T },
1725}
1726
1727impl<T: Clone> CatalogImplicationKind<T> {
1728    /// Apply a state transition based on a diff. Returns an error message if
1729    /// the transition is invalid.
1730    fn transition(&mut self, item: T, name: Option<String>, diff: StateDiff) -> Result<(), String> {
1731        use CatalogImplicationKind::*;
1732        use StateDiff::*;
1733
1734        let new_state = match (&*self, diff) {
1735            // Initial state transitions
1736            (None, Addition) => Added(item),
1737            (None, Retraction) => Dropped(item, name.unwrap_or_else(|| "<unknown>".to_string())),
1738
1739            // From Added state
1740            (Added(existing), Retraction) => {
1741                // Add -> Drop means the item is being altered
1742                Altered {
1743                    prev: item,
1744                    new: existing.clone(),
1745                }
1746            }
1747            (Added(_), Addition) => {
1748                return Err("Cannot add an already added object".to_string());
1749            }
1750
1751            // From Dropped state
1752            (Dropped(existing, _), Addition) => {
1753                // Drop -> Add means the item is being altered
1754                Altered {
1755                    prev: existing.clone(),
1756                    new: item,
1757                }
1758            }
1759            (Dropped(_, _), Retraction) => {
1760                return Err("Cannot drop an already dropped object".to_string());
1761            }
1762
1763            // From Altered state
1764            (Altered { .. }, _) => {
1765                return Err(format!(
1766                    "Cannot apply {:?} to an object in Altered state",
1767                    diff
1768                ));
1769            }
1770        };
1771
1772        *self = new_state;
1773        Ok(())
1774    }
1775}
1776
1777/// Macro to generate absorb methods for each item type.
1778macro_rules! impl_absorb_method {
1779    (
1780        $method_name:ident,
1781        $variant:ident,
1782        $item_type:ty
1783    ) => {
1784        fn $method_name(
1785            &mut self,
1786            item: $item_type,
1787            parsed_full_name: Option<String>,
1788            diff: StateDiff,
1789        ) {
1790            let state = match self {
1791                CatalogImplication::$variant(state) => state,
1792                CatalogImplication::None => {
1793                    *self = CatalogImplication::$variant(CatalogImplicationKind::None);
1794                    match self {
1795                        CatalogImplication::$variant(state) => state,
1796                        _ => unreachable!(),
1797                    }
1798                }
1799                _ => {
1800                    panic!(
1801                        "Unexpected command type for {:?}: {} {:?}",
1802                        self,
1803                        stringify!($variant),
1804                        diff,
1805                    );
1806                }
1807            };
1808
1809            if let Err(e) = state.transition(item, parsed_full_name, diff) {
1810                panic!(
1811                    "Invalid state transition for {}: {}",
1812                    stringify!($variant),
1813                    e
1814                );
1815            }
1816        }
1817    };
1818}
1819
1820impl CatalogImplication {
1821    /// Absorbs the given catalog update into this [CatalogImplication], causing
1822    /// a state transition or error.
1823    fn absorb(&mut self, catalog_update: ParsedStateUpdate) {
1824        match catalog_update.kind {
1825            ParsedStateUpdateKind::Item {
1826                durable_item: _,
1827                parsed_item,
1828                connection,
1829                parsed_full_name,
1830            } => match parsed_item {
1831                CatalogItem::Table(table) => {
1832                    self.absorb_table(table, Some(parsed_full_name), catalog_update.diff)
1833                }
1834                CatalogItem::Source(source) => {
1835                    self.absorb_source(
1836                        (source, connection),
1837                        Some(parsed_full_name),
1838                        catalog_update.diff,
1839                    );
1840                }
1841                CatalogItem::Sink(sink) => {
1842                    self.absorb_sink(sink, Some(parsed_full_name), catalog_update.diff);
1843                }
1844                CatalogItem::Index(index) => {
1845                    self.absorb_index(index, Some(parsed_full_name), catalog_update.diff);
1846                }
1847                CatalogItem::MaterializedView(mv) => {
1848                    self.absorb_materialized_view(mv, Some(parsed_full_name), catalog_update.diff);
1849                }
1850                CatalogItem::View(view) => {
1851                    self.absorb_view(view, Some(parsed_full_name), catalog_update.diff);
1852                }
1853
1854                CatalogItem::Secret(secret) => {
1855                    self.absorb_secret(secret, None, catalog_update.diff);
1856                }
1857                CatalogItem::Connection(connection) => {
1858                    self.absorb_connection(connection, None, catalog_update.diff);
1859                }
1860                CatalogItem::MetricSink(metric_sink) => {
1861                    self.absorb_metric_sink(
1862                        metric_sink,
1863                        Some(parsed_full_name),
1864                        catalog_update.diff,
1865                    );
1866                }
1867                CatalogItem::Log(_) => {}
1868                CatalogItem::Type(_) => {}
1869                CatalogItem::Func(_) => {}
1870            },
1871            ParsedStateUpdateKind::Cluster {
1872                durable_cluster: _,
1873                parsed_cluster,
1874            } => {
1875                let name = parsed_cluster.name.clone();
1876                self.absorb_cluster(parsed_cluster, Some(name), catalog_update.diff);
1877            }
1878            ParsedStateUpdateKind::ClusterReplica {
1879                durable_cluster_replica: _,
1880                parsed_cluster_replica,
1881            } => {
1882                let name = parsed_cluster_replica.name.clone();
1883                self.absorb_cluster_replica(
1884                    parsed_cluster_replica,
1885                    Some(name),
1886                    catalog_update.diff,
1887                );
1888            }
1889            ParsedStateUpdateKind::IntrospectionSourceIndex { .. } => {
1890                // IntrospectionSourceIndex updates are collected
1891                // separately in apply_catalog_implications and not
1892                // routed through absorb.
1893                unreachable!("IntrospectionSourceIndex should not be passed to absorb");
1894            }
1895            ParsedStateUpdateKind::ReplicaSystemConfiguration { .. } => {
1896                // ReplicaSystemConfiguration updates are collected separately in
1897                // apply_catalog_implications and not routed through absorb.
1898                unreachable!("ReplicaSystemConfiguration should not be passed to absorb");
1899            }
1900            ParsedStateUpdateKind::SystemConfiguration { .. } => {
1901                // SystemConfiguration updates are collected separately in
1902                // apply_catalog_implications and not routed through absorb.
1903                unreachable!("SystemConfiguration should not be passed to absorb");
1904            }
1905        }
1906    }
1907
1908    impl_absorb_method!(absorb_table, Table, Table);
1909    impl_absorb_method!(
1910        absorb_source,
1911        Source,
1912        (Source, Option<GenericSourceConnection>)
1913    );
1914    impl_absorb_method!(absorb_sink, Sink, Sink);
1915    impl_absorb_method!(absorb_index, Index, Index);
1916    impl_absorb_method!(absorb_metric_sink, MetricSink, MetricSink);
1917    impl_absorb_method!(absorb_materialized_view, MaterializedView, MaterializedView);
1918    impl_absorb_method!(absorb_view, View, View);
1919
1920    impl_absorb_method!(absorb_secret, Secret, Secret);
1921    impl_absorb_method!(absorb_connection, Connection, Connection);
1922
1923    impl_absorb_method!(absorb_cluster, Cluster, Cluster);
1924    impl_absorb_method!(absorb_cluster_replica, ClusterReplica, ClusterReplica);
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use super::*;
1930    use mz_repr::{GlobalId, RelationDesc, RelationVersion, VersionedRelationDesc};
1931    use mz_sql::names::ResolvedIds;
1932    use std::collections::BTreeMap;
1933
1934    fn create_test_table(name: &str) -> Table {
1935        Table {
1936            desc: VersionedRelationDesc::new(
1937                RelationDesc::builder()
1938                    .with_column(name, mz_repr::SqlScalarType::String.nullable(false))
1939                    .finish(),
1940            ),
1941            create_sql: None,
1942            collections: BTreeMap::from([(RelationVersion::root(), GlobalId::System(1))]),
1943            conn_id: None,
1944            resolved_ids: ResolvedIds::empty(),
1945            custom_logical_compaction_window: None,
1946            is_retained_metrics_object: false,
1947            data_source: TableDataSource::TableWrites { defaults: vec![] },
1948        }
1949    }
1950
1951    #[mz_ore::test]
1952    fn test_item_state_transitions() {
1953        // Test None -> Added
1954        let mut state = CatalogImplicationKind::None;
1955        assert!(
1956            state
1957                .transition("item1".to_string(), None, StateDiff::Addition)
1958                .is_ok()
1959        );
1960        assert!(matches!(state, CatalogImplicationKind::Added(_)));
1961
1962        // Test Added -> Altered (via retraction)
1963        let mut state = CatalogImplicationKind::Added("new_item".to_string());
1964        assert!(
1965            state
1966                .transition("old_item".to_string(), None, StateDiff::Retraction)
1967                .is_ok()
1968        );
1969        match &state {
1970            CatalogImplicationKind::Altered { prev, new } => {
1971                // The retracted item is the OLD state
1972                assert_eq!(prev, "old_item");
1973                // The existing Added item is the NEW state
1974                assert_eq!(new, "new_item");
1975            }
1976            _ => panic!("Expected Altered state"),
1977        }
1978
1979        // Test None -> Dropped
1980        let mut state = CatalogImplicationKind::None;
1981        assert!(
1982            state
1983                .transition(
1984                    "item1".to_string(),
1985                    Some("test_name".to_string()),
1986                    StateDiff::Retraction
1987                )
1988                .is_ok()
1989        );
1990        assert!(matches!(state, CatalogImplicationKind::Dropped(_, _)));
1991
1992        // Test Dropped -> Altered (via addition)
1993        let mut state = CatalogImplicationKind::Dropped("old_item".to_string(), "name".to_string());
1994        assert!(
1995            state
1996                .transition("new_item".to_string(), None, StateDiff::Addition)
1997                .is_ok()
1998        );
1999        match &state {
2000            CatalogImplicationKind::Altered { prev, new } => {
2001                // The existing Dropped item is the OLD state
2002                assert_eq!(prev, "old_item");
2003                // The added item is the NEW state
2004                assert_eq!(new, "new_item");
2005            }
2006            _ => panic!("Expected Altered state"),
2007        }
2008
2009        // Test invalid transitions
2010        let mut state = CatalogImplicationKind::Added("item".to_string());
2011        assert!(
2012            state
2013                .transition("item2".to_string(), None, StateDiff::Addition)
2014                .is_err()
2015        );
2016
2017        let mut state = CatalogImplicationKind::Dropped("item".to_string(), "name".to_string());
2018        assert!(
2019            state
2020                .transition("item2".to_string(), None, StateDiff::Retraction)
2021                .is_err()
2022        );
2023    }
2024
2025    #[mz_ore::test]
2026    fn test_table_absorb_state_machine() {
2027        let table1 = create_test_table("table1");
2028        let table2 = create_test_table("table2");
2029
2030        // Test None -> AddTable
2031        let mut cmd = CatalogImplication::None;
2032        cmd.absorb_table(
2033            table1.clone(),
2034            Some("schema.table1".to_string()),
2035            StateDiff::Addition,
2036        );
2037        // Check that we have an Added state
2038        match &cmd {
2039            CatalogImplication::Table(state) => match state {
2040                CatalogImplicationKind::Added(t) => {
2041                    assert_eq!(t.desc.latest().arity(), table1.desc.latest().arity())
2042                }
2043                _ => panic!("Expected Added state"),
2044            },
2045            _ => panic!("Expected Table command"),
2046        }
2047
2048        // Test AddTable -> AlterTable (via retraction)
2049        // This tests the bug fix: when we have AddTable(table1) and receive Retraction(table2),
2050        // table2 is the old state being removed, table1 is the new state
2051        cmd.absorb_table(
2052            table2.clone(),
2053            Some("schema.table2".to_string()),
2054            StateDiff::Retraction,
2055        );
2056        match &cmd {
2057            CatalogImplication::Table(state) => match state {
2058                CatalogImplicationKind::Altered { prev, new } => {
2059                    // Verify the fix: prev should be the retracted table, new should be the added table
2060                    assert_eq!(prev.desc.latest().arity(), table2.desc.latest().arity());
2061                    assert_eq!(new.desc.latest().arity(), table1.desc.latest().arity());
2062                }
2063                _ => panic!("Expected Altered state"),
2064            },
2065            _ => panic!("Expected Table command"),
2066        }
2067
2068        // Test None -> DropTable
2069        let mut cmd = CatalogImplication::None;
2070        cmd.absorb_table(
2071            table1.clone(),
2072            Some("schema.table1".to_string()),
2073            StateDiff::Retraction,
2074        );
2075        match &cmd {
2076            CatalogImplication::Table(state) => match state {
2077                CatalogImplicationKind::Dropped(t, name) => {
2078                    assert_eq!(t.desc.latest().arity(), table1.desc.latest().arity());
2079                    assert_eq!(name, "schema.table1");
2080                }
2081                _ => panic!("Expected Dropped state"),
2082            },
2083            _ => panic!("Expected Table command"),
2084        }
2085
2086        // Test DropTable -> AlterTable (via addition)
2087        cmd.absorb_table(
2088            table2.clone(),
2089            Some("schema.table2".to_string()),
2090            StateDiff::Addition,
2091        );
2092        match &cmd {
2093            CatalogImplication::Table(state) => match state {
2094                CatalogImplicationKind::Altered { prev, new } => {
2095                    // prev should be the dropped table, new should be the added table
2096                    assert_eq!(prev.desc.latest().arity(), table1.desc.latest().arity());
2097                    assert_eq!(new.desc.latest().arity(), table2.desc.latest().arity());
2098                }
2099                _ => panic!("Expected Altered state"),
2100            },
2101            _ => panic!("Expected Table command"),
2102        }
2103    }
2104
2105    #[mz_ore::test]
2106    #[should_panic(expected = "Cannot add an already added object")]
2107    fn test_invalid_double_add() {
2108        let table = create_test_table("table");
2109        let mut cmd = CatalogImplication::None;
2110
2111        // First addition
2112        cmd.absorb_table(
2113            table.clone(),
2114            Some("schema.table".to_string()),
2115            StateDiff::Addition,
2116        );
2117
2118        // Second addition should panic
2119        cmd.absorb_table(
2120            table.clone(),
2121            Some("schema.table".to_string()),
2122            StateDiff::Addition,
2123        );
2124    }
2125
2126    #[mz_ore::test]
2127    #[should_panic(expected = "Cannot drop an already dropped object")]
2128    fn test_invalid_double_drop() {
2129        let table = create_test_table("table");
2130        let mut cmd = CatalogImplication::None;
2131
2132        // First drop
2133        cmd.absorb_table(
2134            table.clone(),
2135            Some("schema.table".to_string()),
2136            StateDiff::Retraction,
2137        );
2138
2139        // Second drop should panic
2140        cmd.absorb_table(
2141            table.clone(),
2142            Some("schema.table".to_string()),
2143            StateDiff::Retraction,
2144        );
2145    }
2146}