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