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