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