Skip to main content

mz_adapter/catalog/
transact.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 executing catalog transactions.
11
12use std::borrow::Cow;
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15use std::sync::atomic;
16use std::time::Duration;
17
18use itertools::Itertools;
19use mz_adapter_types::cluster_state::{
20    BurstAudit, BurstFinishCause, ExpectedClusterState, ReconfigurationAudit,
21};
22use mz_adapter_types::compaction::CompactionWindow;
23use mz_adapter_types::connection::ConnectionId;
24use mz_adapter_types::dyncfgs::{
25    ENABLE_0DT_DEPLOYMENT_PANIC_AFTER_TIMEOUT, WITH_0DT_DEPLOYMENT_DDL_CHECK_INTERVAL,
26    WITH_0DT_DEPLOYMENT_MAX_WAIT,
27};
28use mz_audit_log::{
29    AlterClusterReconfigurationV1, BurstFinishCauseV1, ClusterHydrationBurstV1,
30    ClusterReplicaLoggingV1, CreateOrDropClusterReplicaReasonV1, EventDetails, EventType,
31    HydrationBurstLifecycleV1, IdFullNameV1, IdNameV1, ObjectType, ReconfigurationLifecycleV1,
32    SchedulingDecisionsWithReasonsV2, VersionedEvent,
33};
34use mz_catalog::SYSTEM_CONN_ID;
35use mz_catalog::builtin::BuiltinLog;
36use mz_catalog::durable::{NetworkPolicy, Snapshot, Transaction};
37use mz_catalog::expr_cache::LocalExpressions;
38use mz_catalog::memory::error::{AmbiguousRename, Error, ErrorKind};
39use mz_catalog::memory::objects::{
40    CatalogEntry, CatalogItem, ClusterConfig, ClusterVariant, DataSourceDesc, DefaultPrivileges,
41    ReconfigurationState, ReconfigurationStatus, ReconfigurationTarget, SourceReferences,
42    StateDiff, StateUpdate, StateUpdateKind, TemporaryItem,
43};
44use mz_controller::clusters::{ManagedReplicaLocation, ReplicaConfig, ReplicaLocation};
45use mz_controller_types::{ClusterId, ReplicaId};
46use mz_ore::collections::HashSet;
47use mz_ore::instrument;
48use mz_persist_types::ShardId;
49use mz_repr::adt::mz_acl_item::{AclMode, MzAclItem, PrivilegeMap, merge_mz_acl_items};
50use mz_repr::network_policy_id::NetworkPolicyId;
51use mz_repr::optimize::OptimizerFeatures;
52use mz_repr::role_id::RoleId;
53use mz_repr::{CatalogItemId, ColumnName, GlobalId, SqlColumnType, strconv};
54use mz_sql::ast::RawDataType;
55use mz_sql::catalog::{
56    AutoProvisionSource, CatalogDatabase, CatalogError as SqlCatalogError,
57    CatalogItem as SqlCatalogItem, CatalogRole, CatalogSchema, DefaultPrivilegeAclItem,
58    DefaultPrivilegeObject, PasswordAction, PasswordConfig, RoleAttributesRaw, RoleMembership,
59    RoleVars,
60};
61use mz_sql::names::{
62    CommentObjectId, DatabaseId, FullItemName, ObjectId, QualifiedItemName,
63    ResolvedDatabaseSpecifier, SchemaId, SchemaSpecifier, SystemObjectId,
64};
65use mz_sql::plan::{NetworkPolicyRule, PlanError};
66use mz_sql::session::user::{MZ_SUPPORT_ROLE_ID, MZ_SYSTEM_ROLE_ID};
67use mz_sql::session::vars::OwnedVarInput;
68use mz_sql::session::vars::{Value as VarValue, VarInput};
69use mz_sql::{DEFAULT_SCHEMA, rbac};
70use mz_sql_parser::ast::{QualifiedReplica, Value};
71use mz_storage_client::storage_collections::StorageCollections;
72use serde::{Deserialize, Serialize};
73use tracing::{info, trace};
74
75use crate::AdapterError;
76use crate::catalog::state::LocalExpressionCache;
77use crate::catalog::{
78    BuiltinTableUpdate, Catalog, CatalogState, UpdatePrivilegeVariant,
79    catalog_type_to_audit_object_type, comment_id_to_audit_object_type, is_reserved_name,
80    is_reserved_role_name, object_type_to_audit_object_type,
81    system_object_type_to_audit_object_type,
82};
83use crate::config::{ScopedParameters, ScopedParametersScope};
84use crate::coord::ConnMeta;
85use crate::coord::catalog_implications::parsed_state_updates::ParsedStateUpdate;
86use crate::coord::cluster_scheduling::SchedulingDecision;
87use crate::util::ResultExt;
88
89/// A manually injected audit event.
90///
91/// Matches [`mz_audit_log::EventV1`], but without the `id` and `occurred_at` fields -- both are
92/// filled in automatically.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InjectedAuditEvent {
95    pub event_type: EventType,
96    pub object_type: ObjectType,
97    pub details: EventDetails,
98    pub user: Option<String>,
99}
100
101#[derive(Debug, Clone)]
102pub enum Op {
103    AlterRetainHistory {
104        id: CatalogItemId,
105        value: Option<Value>,
106        window: CompactionWindow,
107    },
108    AlterSourceTimestampInterval {
109        id: CatalogItemId,
110        value: Option<Value>,
111        interval: Duration,
112    },
113    AlterRole {
114        id: RoleId,
115        name: String,
116        attributes: RoleAttributesRaw,
117        nopassword: bool,
118        vars: RoleVars,
119    },
120    AlterNetworkPolicy {
121        id: NetworkPolicyId,
122        rules: Vec<NetworkPolicyRule>,
123        name: String,
124        owner_id: RoleId,
125    },
126    AlterAddColumn {
127        id: CatalogItemId,
128        new_global_id: GlobalId,
129        name: ColumnName,
130        typ: SqlColumnType,
131        sql: RawDataType,
132    },
133    AlterMaterializedViewApplyReplacement {
134        id: CatalogItemId,
135        replacement_id: CatalogItemId,
136    },
137    CreateDatabase {
138        name: String,
139        owner_id: RoleId,
140    },
141    CreateSchema {
142        database_id: ResolvedDatabaseSpecifier,
143        schema_name: String,
144        owner_id: RoleId,
145    },
146    CreateRole {
147        name: String,
148        attributes: RoleAttributesRaw,
149    },
150    CreateCluster {
151        id: ClusterId,
152        name: String,
153        introspection_sources: Vec<&'static BuiltinLog>,
154        owner_id: RoleId,
155        config: ClusterConfig,
156    },
157    CreateClusterReplica {
158        cluster_id: ClusterId,
159        replica_id: ReplicaId,
160        name: String,
161        config: ReplicaConfig,
162        owner_id: RoleId,
163        reason: ReplicaCreateDropReason,
164    },
165    CreateItem {
166        id: CatalogItemId,
167        name: QualifiedItemName,
168        item: CatalogItem,
169        owner_id: RoleId,
170    },
171    CreateNetworkPolicy {
172        rules: Vec<NetworkPolicyRule>,
173        name: String,
174        owner_id: RoleId,
175    },
176    Comment {
177        object_id: CommentObjectId,
178        sub_component: Option<usize>,
179        comment: Option<String>,
180    },
181    DropObjects(Vec<DropObjectInfo>),
182    GrantRole {
183        role_id: RoleId,
184        member_id: RoleId,
185        grantor_id: RoleId,
186    },
187    RenameCluster {
188        id: ClusterId,
189        name: String,
190        to_name: String,
191        check_reserved_names: bool,
192    },
193    RenameClusterReplica {
194        cluster_id: ClusterId,
195        replica_id: ReplicaId,
196        name: QualifiedReplica,
197        to_name: String,
198    },
199    RenameItem {
200        id: CatalogItemId,
201        current_full_name: FullItemName,
202        to_name: String,
203    },
204    RenameSchema {
205        database_spec: ResolvedDatabaseSpecifier,
206        schema_spec: SchemaSpecifier,
207        new_name: String,
208        check_reserved_names: bool,
209    },
210    UpdateOwner {
211        id: ObjectId,
212        new_owner: RoleId,
213    },
214    UpdatePrivilege {
215        target_id: SystemObjectId,
216        /// The ACL changes to apply to `target_id`, applied as a single durable write. A bulk
217        /// `GRANT`/`REVOKE` touching one object for many grantees lands here as one op rather
218        /// than one op per grantee.
219        privileges: Vec<MzAclItem>,
220        variant: UpdatePrivilegeVariant,
221    },
222    UpdateDefaultPrivilege {
223        privilege_object: DefaultPrivilegeObject,
224        privilege_acl_item: DefaultPrivilegeAclItem,
225        variant: UpdatePrivilegeVariant,
226    },
227    RevokeRole {
228        role_id: RoleId,
229        member_id: RoleId,
230        grantor_id: RoleId,
231    },
232    UpdateClusterConfig {
233        id: ClusterId,
234        name: String,
235        config: ClusterConfig,
236        /// Writer-declared reconfiguration lifecycle transition to audit for
237        /// this write, alongside the record carried in `config`.
238        reconfiguration_audit: Option<ReconfigurationAudit>,
239        /// Writer-declared hydration-burst lifecycle transition to audit for
240        /// this write, alongside the record carried in `config`.
241        burst_audit: Option<BurstAudit>,
242    },
243    UpdateClusterReplicaConfig {
244        cluster_id: ClusterId,
245        replica_id: ReplicaId,
246        config: ReplicaConfig,
247    },
248    UpdateItem {
249        id: CatalogItemId,
250        name: QualifiedItemName,
251        to_item: CatalogItem,
252    },
253    UpdateSourceReferences {
254        source_id: CatalogItemId,
255        references: SourceReferences,
256    },
257    UpdateSystemConfiguration {
258        name: String,
259        value: OwnedVarInput,
260    },
261    ResetSystemConfiguration {
262        name: String,
263    },
264    ResetAllSystemConfiguration,
265    /// Persists the durable cache of scoped (per-cluster and per-replica)
266    /// system parameters towards `scoped`. The handler diffs against the current
267    /// durable contents: it upserts changed/added entries and removes entries
268    /// the update no longer serves.
269    ///
270    /// `prune_scope` bounds removals to the objects the update was evaluated
271    /// for. `Some(scope)` removes a row only when its owning object is in
272    /// `scope`, so an object created after the update's evaluation snapshot, and
273    /// the override it folded into its own create transaction, survives a
274    /// concurrent full-state reconcile. `None` is an unscoped full replace, used
275    /// only by the disabled-feature clear path where no create-time writes race.
276    UpdateScopedSystemParameters {
277        scoped: ScopedParameters,
278        prune_scope: Option<ScopedParametersScope>,
279    },
280    /// Injects audit events into the catalog.
281    ///
282    /// This is a nonstandard path used for manually appending audit events at the current time.
283    /// Mainly useful for correcting the audit log in the face of bugs that made us forget to emit
284    /// audit events.
285    InjectAuditEvents {
286        events: Vec<InjectedAuditEvent>,
287    },
288    /// Precondition, not a mutation. Aborts the whole transaction unless
289    /// `cluster_id`'s current managed config still equals `expected`. Running
290    /// the check inside the transaction makes it inseparable from the commit it
291    /// guards, giving a compare-and-append over the cluster's config. A purely
292    /// internal op for conditional cluster-config writes, never emitted by SQL
293    /// DDL.
294    CheckClusterState {
295        cluster_id: ClusterId,
296        expected: ExpectedClusterState,
297    },
298}
299
300/// Almost the same as `ObjectId`, but the `ClusterReplica` case has an extra
301/// `ReplicaCreateDropReason` field. This is forwarded to `mz_audit_events.details` when applying
302/// the `Op::DropObjects`.
303#[derive(Debug, Clone)]
304pub enum DropObjectInfo {
305    Cluster(ClusterId),
306    ClusterReplica((ClusterId, ReplicaId, ReplicaCreateDropReason)),
307    Database(DatabaseId),
308    Schema((ResolvedDatabaseSpecifier, SchemaSpecifier)),
309    Role(RoleId),
310    Item(CatalogItemId),
311    NetworkPolicy(NetworkPolicyId),
312}
313
314impl DropObjectInfo {
315    /// Creates a `DropObjectInfo` from an `ObjectId`.
316    /// If it is a `ClusterReplica`, the reason will be set to `ReplicaCreateDropReason::Manual`.
317    pub(crate) fn manual_drop_from_object_id(id: ObjectId) -> Self {
318        match id {
319            ObjectId::Cluster(cluster_id) => DropObjectInfo::Cluster(cluster_id),
320            ObjectId::ClusterReplica((cluster_id, replica_id)) => DropObjectInfo::ClusterReplica((
321                cluster_id,
322                replica_id,
323                ReplicaCreateDropReason::Manual,
324            )),
325            ObjectId::Database(database_id) => DropObjectInfo::Database(database_id),
326            ObjectId::Schema(schema) => DropObjectInfo::Schema(schema),
327            ObjectId::Role(role_id) => DropObjectInfo::Role(role_id),
328            ObjectId::Item(item_id) => DropObjectInfo::Item(item_id),
329            ObjectId::NetworkPolicy(policy_id) => DropObjectInfo::NetworkPolicy(policy_id),
330        }
331    }
332
333    /// Creates an `ObjectId` from a `DropObjectInfo`.
334    /// Loses the `ReplicaCreateDropReason` if there is one!
335    fn to_object_id(&self) -> ObjectId {
336        match &self {
337            DropObjectInfo::Cluster(cluster_id) => ObjectId::Cluster(cluster_id.clone()),
338            DropObjectInfo::ClusterReplica((cluster_id, replica_id, _reason)) => {
339                ObjectId::ClusterReplica((cluster_id.clone(), replica_id.clone()))
340            }
341            DropObjectInfo::Database(database_id) => ObjectId::Database(database_id.clone()),
342            DropObjectInfo::Schema(schema) => ObjectId::Schema(schema.clone()),
343            DropObjectInfo::Role(role_id) => ObjectId::Role(role_id.clone()),
344            DropObjectInfo::Item(item_id) => ObjectId::Item(item_id.clone()),
345            DropObjectInfo::NetworkPolicy(network_policy_id) => {
346                ObjectId::NetworkPolicy(network_policy_id.clone())
347            }
348        }
349    }
350}
351
352/// The reason for creating or dropping a replica.
353#[derive(Debug, Clone)]
354pub enum ReplicaCreateDropReason {
355    /// The user initiated the replica create or drop, e.g., by
356    /// - creating/dropping a cluster,
357    /// - ALTERing various options on a managed cluster,
358    /// - CREATE/DROP CLUSTER REPLICA on an unmanaged cluster.
359    Manual,
360    /// The automated cluster scheduling initiated the replica create or drop, e.g., a
361    /// materialized view is needing a refresh on a SCHEDULE ON REFRESH cluster.
362    ClusterScheduling(Vec<SchedulingDecision>),
363    /// The cluster controller's graceful-reconfiguration strategy created the replica while
364    /// converging a cluster onto an in-flight `reconfiguration` target (a background
365    /// `ALTER CLUSTER`).
366    GracefulReconfiguration,
367    /// The cluster controller dropped the replica because the cluster's configuration no longer
368    /// calls for it. The uniform reason on every controller-emitted drop (e.g. a
369    /// replication-factor decrease).
370    Retired,
371}
372
373impl ReplicaCreateDropReason {
374    pub fn into_audit_log(
375        self,
376    ) -> (
377        CreateOrDropClusterReplicaReasonV1,
378        Option<SchedulingDecisionsWithReasonsV2>,
379    ) {
380        let (reason, scheduling_policies) = match self {
381            ReplicaCreateDropReason::Manual => (CreateOrDropClusterReplicaReasonV1::Manual, None),
382            ReplicaCreateDropReason::ClusterScheduling(scheduling_decisions) => (
383                CreateOrDropClusterReplicaReasonV1::Schedule,
384                Some(scheduling_decisions),
385            ),
386            ReplicaCreateDropReason::GracefulReconfiguration => {
387                (CreateOrDropClusterReplicaReasonV1::Reconfiguration, None)
388            }
389            ReplicaCreateDropReason::Retired => (CreateOrDropClusterReplicaReasonV1::Retired, None),
390        };
391        (
392            reason,
393            scheduling_policies
394                .as_ref()
395                .map(SchedulingDecision::reasons_to_audit_log_reasons),
396        )
397    }
398}
399
400pub struct TransactionResult {
401    pub builtin_table_updates: Vec<BuiltinTableUpdate>,
402    /// Parsed catalog updates from which we will derive catalog implications.
403    pub catalog_updates: Vec<ParsedStateUpdate>,
404    pub audit_events: Vec<VersionedEvent>,
405}
406
407#[derive(Debug, Clone, Copy)]
408enum TransactInnerMode {
409    /// Prepare storage state and return a commit-ready transaction state.
410    ///
411    /// This mode is used by durable catalog transactions.
412    Commit,
413    /// Execute a dry run against a durable transaction that will not be
414    /// committed.
415    ///
416    /// This mode still validates and applies updates to an in-memory
417    /// `CatalogState`, but it must not call `prepare_state` because dry runs
418    /// must not trigger controller side effects.
419    DryRun,
420}
421
422impl Catalog {
423    fn should_audit_log_item(item: &CatalogItem) -> bool {
424        !item.is_temporary()
425    }
426
427    /// The cluster config's `reconfiguration` record, if any.
428    fn reconfiguration_record_of(
429        config: &ClusterConfig,
430    ) -> Option<&mz_catalog::memory::objects::ReconfigurationState> {
431        match &config.variant {
432            ClusterVariant::Managed(managed) => managed.reconfiguration.as_ref(),
433            ClusterVariant::Unmanaged => None,
434        }
435    }
436
437    /// Whether a cluster config write moves the reconfiguration lifecycle: a
438    /// status change, a fresh record, or the drop of an in-progress record.
439    ///
440    /// Every such movement is an audit-log transition, so a write performing
441    /// one must declare the matching intent. Status-preserving copies (legacy
442    /// paths carrying a record forward, re-targets that stay in progress with a
443    /// declared `Started`) and drops of already-settled records move nothing.
444    fn reconfiguration_lifecycle_moved(
445        old_config: &ClusterConfig,
446        new_config: &ClusterConfig,
447    ) -> bool {
448        match (
449            Self::reconfiguration_record_of(old_config),
450            Self::reconfiguration_record_of(new_config),
451        ) {
452            (None, Some(_)) => true,
453            (Some(old), Some(new)) => old.status != new.status,
454            (Some(old), None) => old.is_in_progress(),
455            (None, None) => false,
456        }
457    }
458
459    /// Builds a reconfiguration lifecycle audit event from the writer-declared
460    /// audit intent and the record in `config`.
461    ///
462    /// The intent must cohere with the record it rides along with: the durable
463    /// `status` and the audited transition are two views of one decision, so a
464    /// mismatch is a writer bug and fails the transaction rather than commit an
465    /// event that contradicts the state. The valid pairings are tabulated on
466    /// [`mz_catalog::memory::objects::ReconfigurationStatus`].
467    fn reconfiguration_audit_details(
468        config: &ClusterConfig,
469        cluster_id: ClusterId,
470        cluster_name: &str,
471        audit: ReconfigurationAudit,
472    ) -> Result<AlterClusterReconfigurationV1, AdapterError> {
473        let record = Self::reconfiguration_record_of(config);
474        let Some(record) = record else {
475            return Err(AdapterError::Internal(format!(
476                "reconfiguration audit transition {audit:?} for cluster {cluster_name} \
477                 without a reconfiguration record"
478            )));
479        };
480        // The one mirror match from the write-side vocabulary to the audit-log
481        // vocabulary. `forced` exists only on the intent: the durable status
482        // reads `Finalized` for both a hydrated and a forced cut-over.
483        let (transition, forced) = match audit {
484            ReconfigurationAudit::Started => (ReconfigurationLifecycleV1::Started, None),
485            ReconfigurationAudit::Cancelled => (ReconfigurationLifecycleV1::Cancelled, None),
486            ReconfigurationAudit::Finalized { forced } => {
487                (ReconfigurationLifecycleV1::Finalized, Some(forced))
488            }
489            ReconfigurationAudit::TimedOut => (ReconfigurationLifecycleV1::TimedOut, None),
490            ReconfigurationAudit::ResourceExhausted => {
491                (ReconfigurationLifecycleV1::ResourceExhausted, None)
492            }
493        };
494        let coherent = matches!(
495            (audit, record.status),
496            (
497                ReconfigurationAudit::Started,
498                ReconfigurationStatus::InProgress
499            ) | (
500                ReconfigurationAudit::Cancelled,
501                ReconfigurationStatus::Cancelled
502            ) | (
503                ReconfigurationAudit::Finalized { .. },
504                ReconfigurationStatus::Finalized
505            ) | (
506                ReconfigurationAudit::TimedOut,
507                ReconfigurationStatus::TimedOut
508            ) | (
509                ReconfigurationAudit::ResourceExhausted,
510                ReconfigurationStatus::ResourceExhausted
511            )
512        );
513        if !coherent {
514            return Err(AdapterError::Internal(format!(
515                "reconfiguration audit transition {audit:?} for cluster {cluster_name} \
516                 contradicts the written record status {:?}",
517                record.status
518            )));
519        }
520        let ReconfigurationState {
521            target,
522            deadline,
523            on_timeout: _,
524            status: _,
525        } = record;
526        let ReconfigurationTarget {
527            size,
528            replication_factor,
529            availability_zones,
530            logging,
531        } = target;
532        Ok(AlterClusterReconfigurationV1 {
533            cluster_id: cluster_id.to_string(),
534            cluster_name: cluster_name.to_string(),
535            transition,
536            forced,
537            target_size: size.clone(),
538            target_replication_factor: *replication_factor,
539            target_availability_zones: availability_zones.clone(),
540            target_logging: ClusterReplicaLoggingV1 {
541                log_logging: logging.log_logging,
542                interval: logging.interval,
543            },
544            deadline: Some((*deadline).into()),
545        })
546    }
547
548    /// Builds a hydration-burst lifecycle audit event from the writer-declared
549    /// audit intent and the `burst` record it rides along with.
550    ///
551    /// A `started` intent reads the record from the new config (the write that
552    /// armed the burst carries it). A `finished` intent reads it from the old
553    /// config, since the same write cleared it. A missing record on the
554    /// respective side means the intent contradicts the write, a writer bug
555    /// that fails the transaction.
556    fn burst_audit_details(
557        old_config: &ClusterConfig,
558        new_config: &ClusterConfig,
559        cluster_id: ClusterId,
560        cluster_name: &str,
561        audit: BurstAudit,
562    ) -> Result<ClusterHydrationBurstV1, AdapterError> {
563        fn burst(config: &ClusterConfig) -> Option<&mz_catalog::memory::objects::BurstState> {
564            match &config.variant {
565                ClusterVariant::Managed(managed) => managed.burst.as_ref(),
566                ClusterVariant::Unmanaged => None,
567            }
568        }
569        let (transition, finish_cause, record) = match audit {
570            BurstAudit::Started => (HydrationBurstLifecycleV1::Started, None, burst(new_config)),
571            BurstAudit::Finished { cause } => {
572                let cause = match cause {
573                    BurstFinishCause::LingerElapsed => BurstFinishCauseV1::LingerElapsed,
574                    BurstFinishCause::NoLongerWarranted => BurstFinishCauseV1::NoLongerWarranted,
575                };
576                (
577                    HydrationBurstLifecycleV1::Finished,
578                    Some(cause),
579                    burst(old_config),
580                )
581            }
582        };
583        let Some(record) = record else {
584            return Err(AdapterError::Internal(format!(
585                "burst audit transition {audit:?} for cluster {cluster_name} \
586                 without a burst record on the corresponding side of the write"
587            )));
588        };
589        Ok(ClusterHydrationBurstV1 {
590            cluster_id: cluster_id.to_string(),
591            cluster_name: cluster_name.to_string(),
592            transition,
593            finish_cause,
594            burst_size: record.burst_size.clone(),
595        })
596    }
597
598    /// Gets [`CatalogItemId`]s of temporary items to be created, checks for name collisions
599    /// within a connection id.
600    fn temporary_ids(
601        &self,
602        ops: &[Op],
603        temporary_drops: BTreeSet<(&ConnectionId, String)>,
604    ) -> Result<BTreeSet<CatalogItemId>, Error> {
605        let mut creating = BTreeSet::new();
606        let mut temporary_ids = BTreeSet::new();
607        for op in ops.iter() {
608            if let Op::CreateItem {
609                id,
610                name,
611                item,
612                owner_id: _,
613            } = op
614            {
615                if let Some(conn_id) = item.conn_id() {
616                    if self.item_exists_in_temp_schemas(conn_id, &name.item)
617                        && !temporary_drops.contains(&(conn_id, name.item.clone()))
618                        || creating.contains(&(conn_id, &name.item))
619                    {
620                        return Err(
621                            SqlCatalogError::ItemAlreadyExists(*id, name.item.clone()).into()
622                        );
623                    } else {
624                        creating.insert((conn_id, &name.item));
625                        temporary_ids.insert(id.clone());
626                    }
627                }
628            }
629        }
630        Ok(temporary_ids)
631    }
632
633    #[instrument(name = "catalog::transact")]
634    pub async fn transact(
635        &mut self,
636        // n.b. this is an option to prevent us from needing to build out a
637        // dummy impl of `StorageController` for tests.
638        storage_collections: Option<&mut Arc<dyn StorageCollections + Send + Sync>>,
639        oracle_write_ts: mz_repr::Timestamp,
640        session: Option<&ConnMeta>,
641        ops: Vec<Op>,
642    ) -> Result<TransactionResult, AdapterError> {
643        trace!("transact: {:?}", ops);
644        fail::fail_point!("catalog_transact", |arg| {
645            Err(AdapterError::Unstructured(anyhow::anyhow!(
646                "failpoint: {arg:?}"
647            )))
648        });
649
650        let drop_ids: BTreeSet<CatalogItemId> = ops
651            .iter()
652            .filter_map(|op| match op {
653                Op::DropObjects(drop_object_infos) => {
654                    let ids = drop_object_infos.iter().map(|info| info.to_object_id());
655                    let item_ids = ids.filter_map(|id| match id {
656                        ObjectId::Item(id) => Some(id),
657                        _ => None,
658                    });
659                    Some(item_ids)
660                }
661                _ => None,
662            })
663            .flatten()
664            .collect();
665        let temporary_drops = drop_ids
666            .iter()
667            .filter_map(|id| {
668                let entry = self.get_entry(id);
669                match entry.item().conn_id() {
670                    Some(conn_id) => Some((conn_id, entry.name().item.clone())),
671                    None => None,
672                }
673            })
674            .collect();
675
676        let temporary_ids = self.temporary_ids(&ops, temporary_drops)?;
677        let mut builtin_table_updates = vec![];
678        let mut catalog_updates = vec![];
679        let mut audit_events = vec![];
680        let mut storage = self.storage().await;
681        let mut tx = storage
682            .transaction()
683            .await
684            .unwrap_or_terminate("starting catalog transaction");
685
686        let new_state = Self::transact_inner(
687            TransactInnerMode::Commit,
688            storage_collections,
689            oracle_write_ts,
690            session,
691            ops,
692            temporary_ids,
693            &mut builtin_table_updates,
694            &mut catalog_updates,
695            &mut audit_events,
696            &mut tx,
697            &self.state,
698        )
699        .await?;
700
701        // The user closure was successful, apply the updates. Terminate the
702        // process if this fails, because we have to restart envd due to
703        // indeterminate catalog state, which we only reconcile during catalog
704        // init.
705        tx.commit(oracle_write_ts)
706            .await
707            .unwrap_or_terminate("catalog storage transaction commit must succeed");
708
709        // Dropping here keeps the mutable borrow on self, preventing us accidentally
710        // mutating anything until after f is executed.
711        drop(storage);
712        if let Some(new_state) = new_state {
713            self.transient_revision += 1;
714            // Publish the new revision before returning. Everything that can
715            // reveal this transaction's effects (responses, notices, builtin
716            // table writes) happens after `transact` returns, so any session
717            // that has observed such evidence is guaranteed to see this bump
718            // and refresh its cached catalog snapshot.
719            self.shared_transient_revision
720                .store(self.transient_revision, atomic::Ordering::SeqCst);
721            self.state = new_state;
722        }
723
724        Ok(TransactionResult {
725            builtin_table_updates,
726            catalog_updates,
727            audit_events,
728        })
729    }
730
731    /// Performs an incremental dry-run catalog transaction: processes only the
732    /// NEW ops against an accumulated `CatalogState` from previous dry runs.
733    /// This avoids the O(N^2) replay cost of replaying all accumulated ops.
734    ///
735    /// The durable transaction is intentionally never committed and no storage
736    /// controller prepare-state side effects are run.
737    ///
738    /// If `prev_snapshot` is `Some`, the transaction is initialized from that
739    /// snapshot (which represents the tx state after the previous dry run),
740    /// ensuring it starts in sync with `base_state`. If `None` (first
741    /// statement), a fresh transaction is loaded from durable storage.
742    ///
743    /// Returns the new accumulated state and a snapshot of the transaction's
744    /// state for use in subsequent incremental dry runs.
745    pub async fn transact_incremental_dry_run(
746        &self,
747        base_state: &CatalogState,
748        ops: Vec<Op>,
749        session: Option<&ConnMeta>,
750        prev_snapshot: Option<Snapshot>,
751        oracle_write_ts: mz_repr::Timestamp,
752    ) -> Result<(CatalogState, Snapshot), AdapterError> {
753        // For DDL transactions, items are not temporary (CREATE TABLE FROM SOURCE, etc.)
754        // but we still need to check for collisions.
755        let temporary_ids = self.temporary_ids(&ops, BTreeSet::new())?;
756
757        let mut builtin_table_updates = vec![];
758        let mut catalog_updates = vec![];
759        let mut audit_events = vec![];
760        let mut storage = self.storage().await;
761        let mut tx = if let Some(snapshot) = prev_snapshot {
762            // Restore transaction from saved snapshot so it starts in sync
763            // with the accumulated CatalogState from previous dry runs.
764            storage
765                .transaction_from_snapshot(snapshot)
766                .unwrap_or_terminate("starting catalog transaction from snapshot")
767        } else {
768            // First statement: fresh transaction from durable storage, which
769            // is in sync with the real catalog state.
770            storage
771                .transaction()
772                .await
773                .unwrap_or_terminate("starting catalog transaction")
774        };
775
776        // Process only the new ops against the accumulated state in dry-run mode.
777        let new_state = Self::transact_inner(
778            TransactInnerMode::DryRun,
779            None,
780            oracle_write_ts,
781            session,
782            ops,
783            temporary_ids,
784            &mut builtin_table_updates,
785            &mut catalog_updates,
786            &mut audit_events,
787            &mut tx,
788            base_state,
789        )
790        .await?;
791
792        // Save the transaction's current state as a snapshot for the next
793        // incremental dry run.
794        let new_snapshot = tx.current_snapshot();
795
796        // Transaction is NOT committed — drop it.
797        drop(storage);
798
799        // transact_inner returns Some(state) when ops produced changes.
800        let state = new_state.unwrap_or_else(|| base_state.clone());
801        Ok((state, new_snapshot))
802    }
803
804    /// Extracts optimized expressions from `Op::CreateItem` operations for views
805    /// and materialized views. These can be used to populate a `LocalExpressionCache`
806    /// to avoid re-optimization during `apply_updates`.
807    fn extract_expressions_from_ops(
808        ops: &[Op],
809        optimizer_features: &OptimizerFeatures,
810    ) -> BTreeMap<GlobalId, LocalExpressions> {
811        let mut exprs = BTreeMap::new();
812
813        for op in ops {
814            if let Op::CreateItem { item, .. } = op {
815                match item {
816                    CatalogItem::View(view) => {
817                        exprs.insert(
818                            view.global_id,
819                            LocalExpressions {
820                                local_mir: (*view.locally_optimized_expr).clone(),
821                                optimizer_features: optimizer_features.clone(),
822                            },
823                        );
824                    }
825                    CatalogItem::MaterializedView(mv) => {
826                        exprs.insert(
827                            mv.global_id_writes(),
828                            LocalExpressions {
829                                local_mir: (*mv.locally_optimized_expr).clone(),
830                                optimizer_features: optimizer_features.clone(),
831                            },
832                        );
833                    }
834                    CatalogItem::Table(_)
835                    | CatalogItem::Source(_)
836                    | CatalogItem::Log(_)
837                    | CatalogItem::Sink(_)
838                    | CatalogItem::Index(_)
839                    | CatalogItem::Type(_)
840                    | CatalogItem::Func(_)
841                    | CatalogItem::Secret(_)
842                    | CatalogItem::Connection(_) => {}
843                }
844            }
845        }
846
847        exprs
848    }
849
850    /// Performs the transaction described by `ops` and returns the new state of the catalog, if
851    /// it has changed. If `ops` don't result in a change in the state this method returns `None`.
852    ///
853    /// `mode` controls whether storage prepare-state side effects are allowed.
854    /// In `DryRun` mode, this method may update the in-memory state returned to
855    /// the caller, but it must not trigger controller side effects.
856    ///
857    #[instrument(name = "catalog::transact_inner")]
858    async fn transact_inner(
859        mode: TransactInnerMode,
860        storage_collections: Option<&mut Arc<dyn StorageCollections + Send + Sync>>,
861        oracle_write_ts: mz_repr::Timestamp,
862        session: Option<&ConnMeta>,
863        ops: Vec<Op>,
864        temporary_ids: BTreeSet<CatalogItemId>,
865        builtin_table_updates: &mut Vec<BuiltinTableUpdate>,
866        parsed_catalog_updates: &mut Vec<ParsedStateUpdate>,
867        audit_events: &mut Vec<VersionedEvent>,
868        tx: &mut Transaction<'_>,
869        state: &CatalogState,
870    ) -> Result<Option<CatalogState>, AdapterError> {
871        // We come up with new catalog state, builtin state updates, and parsed
872        // catalog updates (for deriving catalog implications) in two phases:
873        //
874        // 1. We (cow)-clone catalog state as `preliminary_state` and apply ops
875        //    one-by-one. This will give us the full list of updates to apply to
876        //    the catalog, which will allow us to apply it in one batch, which
877        //    in turn will allow the apply machinery to consolidate the updates.
878        // 2. We do one final apply call with all updates, which gives us the
879        //    final builtin table updates and parsed catalog updates.
880        //
881        // The reason is that the loop that is working off ops first does a
882        // transact_op to derive the state updates for that op, and then calls
883        // apply_updates on the catalog state. And successive ops might expect
884        // the catalog state to reflect the modified state _after_ applying
885        // previous ops.
886        //
887        // We want to, however, have one final apply_state that takes all the
888        // accumulated updates to derive the required controller updates and the
889        // builtin table updates.
890        //
891        // We won't win any DDL throughput benchmarks, but so far that's not
892        // what we're optimizing for and there would probably be other
893        // bottlenecks before we hit this one as a bottleneck.
894        //
895        // We could work around this by refactoring how the interplay of
896        // transact_op and apply_updates works, but that's a larger undertaking.
897        let mut preliminary_state = Cow::Borrowed(state);
898
899        // The final state that we will return, if modified.
900        let mut state = Cow::Borrowed(state);
901
902        if ops.is_empty() {
903            return Ok(None);
904        }
905
906        // Extract optimized expressions from CreateItem ops to avoid re-optimization
907        // during apply_updates. We extract before the loop since `ops` is moved there.
908        let optimizer_features = OptimizerFeatures::from(state.system_config());
909        let cached_exprs = Self::extract_expressions_from_ops(&ops, &optimizer_features);
910
911        let mut storage_collections_to_create = BTreeSet::new();
912        let mut storage_collections_to_drop = BTreeSet::new();
913        let mut storage_collections_to_register = BTreeMap::new();
914
915        let mut updates = Vec::new();
916
917        for op in ops {
918            let temporary_item_updates = Self::transact_op(
919                oracle_write_ts,
920                session,
921                op,
922                &temporary_ids,
923                audit_events,
924                tx,
925                &*preliminary_state,
926                &mut storage_collections_to_create,
927                &mut storage_collections_to_drop,
928                &mut storage_collections_to_register,
929            )
930            .await?;
931
932            // Temporary items are not stored in the durable catalog, so they need to be handled
933            // separately for updating state and builtin tables.
934            // TODO(jkosh44) Some more thought needs to be given as to how temporary tables work
935            // in a multi-subscriber catalog world.
936            let upper = tx.upper();
937            let temporary_item_updates =
938                temporary_item_updates
939                    .into_iter()
940                    .map(|(item, diff)| StateUpdate {
941                        kind: StateUpdateKind::TemporaryItem(item),
942                        ts: upper,
943                        diff,
944                    });
945
946            let mut op_updates: Vec<_> = tx.get_and_commit_op_updates();
947            op_updates.extend(temporary_item_updates);
948            if !op_updates.is_empty() {
949                // Clone the cache so each apply_updates call has access to cached expressions.
950                // The cache uses `remove` semantics, so we need a fresh clone for each call.
951                let mut local_expr_cache = LocalExpressionCache::new(cached_exprs.clone());
952                let (_op_builtin_table_updates, _op_catalog_updates) = preliminary_state
953                    .to_mut()
954                    .apply_updates(op_updates.clone(), &mut local_expr_cache)
955                    .await;
956            }
957            updates.append(&mut op_updates);
958        }
959
960        if !updates.is_empty() {
961            let mut local_expr_cache = LocalExpressionCache::new(cached_exprs.clone());
962            let (op_builtin_table_updates, op_catalog_updates) = state
963                .to_mut()
964                .apply_updates(updates.clone(), &mut local_expr_cache)
965                .await;
966            let op_builtin_table_updates = state
967                .to_mut()
968                .resolve_builtin_table_updates(op_builtin_table_updates);
969            builtin_table_updates.extend(op_builtin_table_updates);
970            parsed_catalog_updates.extend(op_catalog_updates);
971        }
972
973        match mode {
974            TransactInnerMode::Commit => {
975                // `storage_collections` can be `None` in tests.
976                if let Some(c) = storage_collections {
977                    c.prepare_state(
978                        tx,
979                        storage_collections_to_create,
980                        storage_collections_to_drop,
981                        storage_collections_to_register,
982                    )
983                    .await?;
984                }
985            }
986            TransactInnerMode::DryRun => {
987                debug_assert!(
988                    storage_collections.is_none(),
989                    "dry-run mode must not prepare storage state"
990                );
991            }
992        }
993
994        let updates = tx.get_and_commit_op_updates();
995        if !updates.is_empty() {
996            let mut local_expr_cache = LocalExpressionCache::new(cached_exprs.clone());
997            let (op_builtin_table_updates, op_catalog_updates) = state
998                .to_mut()
999                .apply_updates(updates.clone(), &mut local_expr_cache)
1000                .await;
1001            let op_builtin_table_updates = state
1002                .to_mut()
1003                .resolve_builtin_table_updates(op_builtin_table_updates);
1004            builtin_table_updates.extend(op_builtin_table_updates);
1005            parsed_catalog_updates.extend(op_catalog_updates);
1006        }
1007
1008        match state {
1009            Cow::Owned(state) => Ok(Some(state)),
1010            Cow::Borrowed(_) => Ok(None),
1011        }
1012    }
1013
1014    /// Performs the transaction operation described by `op`. This function prepares the changes in
1015    /// `tx`, but does not update `state`. `state` will be updated when applying the durable
1016    /// changes.
1017    ///
1018    /// Optionally returns a builtin table update for any builtin table updates than cannot be
1019    /// derived from the durable catalog state, and temporary item diffs. These are all very weird
1020    /// scenarios and ideally in the future don't exist.
1021    #[instrument]
1022    async fn transact_op(
1023        oracle_write_ts: mz_repr::Timestamp,
1024        session: Option<&ConnMeta>,
1025        op: Op,
1026        temporary_ids: &BTreeSet<CatalogItemId>,
1027        audit_events: &mut Vec<VersionedEvent>,
1028        tx: &mut Transaction<'_>,
1029        state: &CatalogState,
1030        storage_collections_to_create: &mut BTreeSet<GlobalId>,
1031        storage_collections_to_drop: &mut BTreeSet<GlobalId>,
1032        storage_collections_to_register: &mut BTreeMap<GlobalId, ShardId>,
1033    ) -> Result<Vec<(TemporaryItem, StateDiff)>, AdapterError> {
1034        let mut temporary_item_updates = Vec::new();
1035
1036        match op {
1037            Op::CheckClusterState {
1038                cluster_id,
1039                expected,
1040            } => {
1041                // Precondition only. Returning `Err` here aborts `transact_inner`
1042                // before `tx.commit`, so the compare-and-append holds atomically
1043                // with the write it guards.
1044                if !crate::catalog::cluster_state::cluster_matches_expected(
1045                    state, cluster_id, &expected,
1046                ) {
1047                    return Err(AdapterError::ClusterStateChanged { cluster_id });
1048                }
1049            }
1050            Op::AlterRetainHistory { id, value, window } => {
1051                let entry = state.get_entry(&id);
1052                if id.is_system() {
1053                    let name = entry.name();
1054                    let full_name =
1055                        state.resolve_full_name(name, session.map(|session| session.conn_id()));
1056                    return Err(AdapterError::Catalog(Error::new(ErrorKind::ReadOnlyItem(
1057                        full_name.to_string(),
1058                    ))));
1059                }
1060
1061                let mut new_entry = entry.clone();
1062                let previous = new_entry
1063                    .item
1064                    .update_retain_history(value.clone(), window)
1065                    .map_err(|_| {
1066                        AdapterError::Catalog(Error::new(ErrorKind::Internal(
1067                            "planner should have rejected invalid alter retain history item type"
1068                                .to_string(),
1069                        )))
1070                    })?;
1071
1072                if Self::should_audit_log_item(new_entry.item()) {
1073                    let details =
1074                        EventDetails::AlterRetainHistoryV1(mz_audit_log::AlterRetainHistoryV1 {
1075                            id: id.to_string(),
1076                            old_history: previous.map(|previous| previous.to_string()),
1077                            new_history: value.map(|v| v.to_string()),
1078                        });
1079                    CatalogState::add_to_audit_log(
1080                        &state.system_configuration,
1081                        oracle_write_ts,
1082                        session,
1083                        tx,
1084                        audit_events,
1085                        EventType::Alter,
1086                        catalog_type_to_audit_object_type(new_entry.item().typ()),
1087                        details,
1088                    )?;
1089                }
1090
1091                tx.update_item(id, new_entry.into())?;
1092
1093                Self::log_update(state, &id);
1094            }
1095            Op::AlterSourceTimestampInterval {
1096                id,
1097                value,
1098                interval,
1099            } => {
1100                let entry = state.get_entry(&id);
1101                if id.is_system() {
1102                    let name = entry.name();
1103                    let full_name =
1104                        state.resolve_full_name(name, session.map(|session| session.conn_id()));
1105                    return Err(AdapterError::Catalog(Error::new(ErrorKind::ReadOnlyItem(
1106                        full_name.to_string(),
1107                    ))));
1108                }
1109
1110                let mut new_entry = entry.clone();
1111                let previous = new_entry
1112                    .item
1113                    .update_timestamp_interval(value.clone(), interval)
1114                    .map_err(|_| {
1115                        AdapterError::Catalog(Error::new(ErrorKind::Internal(
1116                            "planner should have rejected invalid alter timestamp interval item type"
1117                                .to_string(),
1118                        )))
1119                    })?;
1120
1121                if Self::should_audit_log_item(new_entry.item()) {
1122                    let details = EventDetails::AlterSourceTimestampIntervalV1(
1123                        mz_audit_log::AlterSourceTimestampIntervalV1 {
1124                            id: id.to_string(),
1125                            old_interval: previous.map(|previous| previous.to_string()),
1126                            new_interval: value.map(|v| v.to_string()),
1127                        },
1128                    );
1129                    CatalogState::add_to_audit_log(
1130                        &state.system_configuration,
1131                        oracle_write_ts,
1132                        session,
1133                        tx,
1134                        audit_events,
1135                        EventType::Alter,
1136                        catalog_type_to_audit_object_type(new_entry.item().typ()),
1137                        details,
1138                    )?;
1139                }
1140
1141                tx.update_item(id, new_entry.into())?;
1142
1143                Self::log_update(state, &id);
1144            }
1145            Op::AlterRole {
1146                id,
1147                name,
1148                attributes,
1149                nopassword,
1150                vars,
1151            } => {
1152                state.ensure_not_reserved_role(&id)?;
1153
1154                let mut existing_role = state.get_role(&id).clone();
1155                let password = attributes.password.clone();
1156                let scram_iterations = attributes
1157                    .scram_iterations
1158                    .unwrap_or_else(|| state.system_config().scram_iterations());
1159                existing_role.attributes = attributes.into();
1160                existing_role.vars = vars;
1161                let password_action = if nopassword {
1162                    PasswordAction::Clear
1163                } else if let Some(password) = password {
1164                    PasswordAction::Set(PasswordConfig {
1165                        password,
1166                        scram_iterations,
1167                    })
1168                } else {
1169                    PasswordAction::NoChange
1170                };
1171                tx.update_role(id, existing_role.into(), password_action)?;
1172
1173                CatalogState::add_to_audit_log(
1174                    &state.system_configuration,
1175                    oracle_write_ts,
1176                    session,
1177                    tx,
1178                    audit_events,
1179                    EventType::Alter,
1180                    ObjectType::Role,
1181                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
1182                        id: id.to_string(),
1183                        name: name.clone(),
1184                    }),
1185                )?;
1186
1187                info!("update role {name} ({id})");
1188            }
1189            Op::AlterNetworkPolicy {
1190                id,
1191                rules,
1192                name,
1193                owner_id: _owner_id,
1194            } => {
1195                let existing_policy = state.get_network_policy(&id).clone();
1196                let mut policy: NetworkPolicy = existing_policy.into();
1197                policy.rules = rules;
1198                if is_reserved_name(&name) {
1199                    return Err(AdapterError::Catalog(Error::new(
1200                        ErrorKind::ReservedNetworkPolicyName(name),
1201                    )));
1202                }
1203                tx.update_network_policy(id, policy.clone())?;
1204
1205                CatalogState::add_to_audit_log(
1206                    &state.system_configuration,
1207                    oracle_write_ts,
1208                    session,
1209                    tx,
1210                    audit_events,
1211                    EventType::Alter,
1212                    ObjectType::NetworkPolicy,
1213                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
1214                        id: id.to_string(),
1215                        name: name.clone(),
1216                    }),
1217                )?;
1218
1219                info!("update network policy {name} ({id})");
1220            }
1221            Op::AlterAddColumn {
1222                id,
1223                new_global_id,
1224                name,
1225                typ,
1226                sql,
1227            } => {
1228                let column_name = name.to_string();
1229                let column_type = typ.to_string();
1230                let nullable = typ.nullable;
1231                let mut new_entry = state.get_entry(&id).clone();
1232                let version = new_entry.item.add_column(name, typ, sql)?;
1233                // All versions of a table share the same shard, so it shouldn't matter what
1234                // GlobalId we use here.
1235                let shard_id = state
1236                    .storage_metadata()
1237                    .get_collection_shard(new_entry.latest_global_id())?;
1238
1239                // TODO(alter_table): Support adding columns to sources.
1240                let CatalogItem::Table(table) = &mut new_entry.item else {
1241                    return Err(AdapterError::Unsupported("adding columns to non-Table"));
1242                };
1243                table.collections.insert(version, new_global_id);
1244
1245                if Self::should_audit_log_item(new_entry.item()) {
1246                    let details = EventDetails::AlterAddColumnV1(mz_audit_log::AlterAddColumnV1 {
1247                        id: id.to_string(),
1248                        column: column_name,
1249                        column_type,
1250                        nullable,
1251                    });
1252                    CatalogState::add_to_audit_log(
1253                        &state.system_configuration,
1254                        oracle_write_ts,
1255                        session,
1256                        tx,
1257                        audit_events,
1258                        EventType::Alter,
1259                        catalog_type_to_audit_object_type(new_entry.item().typ()),
1260                        details,
1261                    )?;
1262                }
1263
1264                tx.update_item(id, new_entry.into())?;
1265                storage_collections_to_register.insert(new_global_id, shard_id);
1266            }
1267            Op::AlterMaterializedViewApplyReplacement { id, replacement_id } => {
1268                let mut new_entry = state.get_entry(&id).clone();
1269                let replacement = state.get_entry(&replacement_id);
1270
1271                let new_audit_events =
1272                    apply_replacement_audit_events(state, &new_entry, replacement);
1273
1274                let CatalogItem::MaterializedView(mv) = &mut new_entry.item else {
1275                    return Err(AdapterError::internal(
1276                        "ALTER MATERIALIZED VIEW ... APPLY REPLACEMENT",
1277                        "id must refer to a materialized view",
1278                    ));
1279                };
1280                let CatalogItem::MaterializedView(replacement_mv) = &replacement.item else {
1281                    return Err(AdapterError::internal(
1282                        "ALTER MATERIALIZED VIEW ... APPLY REPLACEMENT",
1283                        "replacement_id must refer to a materialized view",
1284                    ));
1285                };
1286
1287                mv.apply_replacement(replacement_mv.clone());
1288
1289                tx.remove_item(replacement_id)?;
1290
1291                new_entry.id = replacement_id;
1292                tx_replace_item(tx, state, id, new_entry, &mut temporary_item_updates)?;
1293
1294                let comment_id = CommentObjectId::MaterializedView(replacement_id);
1295                tx.drop_comments(&[comment_id].into())?;
1296
1297                for (event_type, details) in new_audit_events {
1298                    CatalogState::add_to_audit_log(
1299                        &state.system_configuration,
1300                        oracle_write_ts,
1301                        session,
1302                        tx,
1303                        audit_events,
1304                        event_type,
1305                        ObjectType::MaterializedView,
1306                        details,
1307                    )?;
1308                }
1309            }
1310            Op::CreateDatabase { name, owner_id } => {
1311                let database_owner_privileges = vec![rbac::owner_privilege(
1312                    mz_sql::catalog::ObjectType::Database,
1313                    owner_id,
1314                )];
1315                let database_default_privileges = state
1316                    .default_privileges
1317                    .get_applicable_privileges(
1318                        owner_id,
1319                        None,
1320                        None,
1321                        mz_sql::catalog::ObjectType::Database,
1322                    )
1323                    .map(|item| item.mz_acl_item(owner_id));
1324                let database_privileges: Vec<_> = merge_mz_acl_items(
1325                    database_owner_privileges
1326                        .into_iter()
1327                        .chain(database_default_privileges),
1328                )
1329                .collect();
1330
1331                let schema_owner_privileges = vec![rbac::owner_privilege(
1332                    mz_sql::catalog::ObjectType::Schema,
1333                    owner_id,
1334                )];
1335                let schema_default_privileges = state
1336                    .default_privileges
1337                    .get_applicable_privileges(
1338                        owner_id,
1339                        None,
1340                        None,
1341                        mz_sql::catalog::ObjectType::Schema,
1342                    )
1343                    .map(|item| item.mz_acl_item(owner_id))
1344                    // Special default privilege on public schemas.
1345                    .chain(std::iter::once(MzAclItem {
1346                        grantee: RoleId::Public,
1347                        grantor: owner_id,
1348                        acl_mode: AclMode::USAGE,
1349                    }));
1350                let schema_privileges: Vec<_> = merge_mz_acl_items(
1351                    schema_owner_privileges
1352                        .into_iter()
1353                        .chain(schema_default_privileges),
1354                )
1355                .collect();
1356
1357                let temporary_oids: HashSet<_> = state.get_temporary_oids().collect();
1358                let (database_id, _) = tx.insert_user_database(
1359                    &name,
1360                    owner_id,
1361                    database_privileges.clone(),
1362                    &temporary_oids,
1363                )?;
1364                let (schema_id, _) = tx.insert_user_schema(
1365                    database_id,
1366                    DEFAULT_SCHEMA,
1367                    owner_id,
1368                    schema_privileges.clone(),
1369                    &temporary_oids,
1370                )?;
1371                CatalogState::add_to_audit_log(
1372                    &state.system_configuration,
1373                    oracle_write_ts,
1374                    session,
1375                    tx,
1376                    audit_events,
1377                    EventType::Create,
1378                    ObjectType::Database,
1379                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
1380                        id: database_id.to_string(),
1381                        name: name.clone(),
1382                    }),
1383                )?;
1384                info!("create database {}", name);
1385
1386                CatalogState::add_to_audit_log(
1387                    &state.system_configuration,
1388                    oracle_write_ts,
1389                    session,
1390                    tx,
1391                    audit_events,
1392                    EventType::Create,
1393                    ObjectType::Schema,
1394                    EventDetails::SchemaV2(mz_audit_log::SchemaV2 {
1395                        id: schema_id.to_string(),
1396                        name: DEFAULT_SCHEMA.to_string(),
1397                        database_name: Some(name),
1398                    }),
1399                )?;
1400            }
1401            Op::CreateSchema {
1402                database_id,
1403                schema_name,
1404                owner_id,
1405            } => {
1406                if is_reserved_name(&schema_name) {
1407                    return Err(AdapterError::Catalog(Error::new(
1408                        ErrorKind::ReservedSchemaName(schema_name),
1409                    )));
1410                }
1411                let database_id = match database_id {
1412                    ResolvedDatabaseSpecifier::Id(id) => id,
1413                    ResolvedDatabaseSpecifier::Ambient => {
1414                        return Err(AdapterError::Catalog(Error::new(
1415                            ErrorKind::ReadOnlySystemSchema(schema_name),
1416                        )));
1417                    }
1418                };
1419                let owner_privileges = vec![rbac::owner_privilege(
1420                    mz_sql::catalog::ObjectType::Schema,
1421                    owner_id,
1422                )];
1423                let default_privileges = state
1424                    .default_privileges
1425                    .get_applicable_privileges(
1426                        owner_id,
1427                        Some(database_id),
1428                        None,
1429                        mz_sql::catalog::ObjectType::Schema,
1430                    )
1431                    .map(|item| item.mz_acl_item(owner_id));
1432                let privileges: Vec<_> =
1433                    merge_mz_acl_items(owner_privileges.into_iter().chain(default_privileges))
1434                        .collect();
1435                let (schema_id, _) = tx.insert_user_schema(
1436                    database_id,
1437                    &schema_name,
1438                    owner_id,
1439                    privileges.clone(),
1440                    &state.get_temporary_oids().collect(),
1441                )?;
1442                CatalogState::add_to_audit_log(
1443                    &state.system_configuration,
1444                    oracle_write_ts,
1445                    session,
1446                    tx,
1447                    audit_events,
1448                    EventType::Create,
1449                    ObjectType::Schema,
1450                    EventDetails::SchemaV2(mz_audit_log::SchemaV2 {
1451                        id: schema_id.to_string(),
1452                        name: schema_name.clone(),
1453                        database_name: Some(state.database_by_id[&database_id].name.clone()),
1454                    }),
1455                )?;
1456            }
1457            Op::CreateRole { name, attributes } => {
1458                if is_reserved_role_name(&name) {
1459                    return Err(AdapterError::Catalog(Error::new(
1460                        ErrorKind::ReservedRoleName(name),
1461                    )));
1462                }
1463                let membership = RoleMembership::new();
1464                let vars = RoleVars::default();
1465                let (id, _) = tx.insert_user_role(
1466                    name.clone(),
1467                    attributes.clone(),
1468                    membership.clone(),
1469                    vars.clone(),
1470                    &state.get_temporary_oids().collect(),
1471                )?;
1472                CatalogState::add_to_audit_log(
1473                    &state.system_configuration,
1474                    oracle_write_ts,
1475                    session,
1476                    tx,
1477                    audit_events,
1478                    EventType::Create,
1479                    ObjectType::Role,
1480                    EventDetails::CreateRoleV1(mz_audit_log::CreateRoleV1 {
1481                        id: id.to_string(),
1482                        name: name.clone(),
1483                        auto_provision_source: attributes.auto_provision_source.map(|s| match s {
1484                            AutoProvisionSource::Oidc => "oidc".to_string(),
1485                            AutoProvisionSource::Frontegg => "frontegg".to_string(),
1486                            AutoProvisionSource::None => "none".to_string(),
1487                        }),
1488                    }),
1489                )?;
1490                info!("create role {}", name);
1491            }
1492            Op::CreateCluster {
1493                id,
1494                name,
1495                introspection_sources,
1496                owner_id,
1497                config,
1498            } => {
1499                if is_reserved_name(&name) {
1500                    return Err(AdapterError::Catalog(Error::new(
1501                        ErrorKind::ReservedClusterName(name),
1502                    )));
1503                }
1504                let owner_privileges = vec![rbac::owner_privilege(
1505                    mz_sql::catalog::ObjectType::Cluster,
1506                    owner_id,
1507                )];
1508                let default_privileges = state
1509                    .default_privileges
1510                    .get_applicable_privileges(
1511                        owner_id,
1512                        None,
1513                        None,
1514                        mz_sql::catalog::ObjectType::Cluster,
1515                    )
1516                    .map(|item| item.mz_acl_item(owner_id));
1517                let privileges: Vec<_> =
1518                    merge_mz_acl_items(owner_privileges.into_iter().chain(default_privileges))
1519                        .collect();
1520                let introspection_source_ids: Vec<_> = introspection_sources
1521                    .iter()
1522                    .map(|introspection_source| {
1523                        Transaction::allocate_introspection_source_index_id(
1524                            &id,
1525                            introspection_source.variant,
1526                        )
1527                    })
1528                    .collect();
1529
1530                let introspection_sources = introspection_sources
1531                    .into_iter()
1532                    .zip_eq(introspection_source_ids)
1533                    .map(|(log, (item_id, gid))| (log, item_id, gid))
1534                    .collect();
1535
1536                tx.insert_user_cluster(
1537                    id,
1538                    &name,
1539                    introspection_sources,
1540                    owner_id,
1541                    privileges.clone(),
1542                    config.clone().into(),
1543                    &state.get_temporary_oids().collect(),
1544                )?;
1545                CatalogState::add_to_audit_log(
1546                    &state.system_configuration,
1547                    oracle_write_ts,
1548                    session,
1549                    tx,
1550                    audit_events,
1551                    EventType::Create,
1552                    ObjectType::Cluster,
1553                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
1554                        id: id.to_string(),
1555                        name: name.clone(),
1556                    }),
1557                )?;
1558                info!("create cluster {}", name);
1559            }
1560            Op::CreateClusterReplica {
1561                cluster_id,
1562                replica_id,
1563                name,
1564                config,
1565                owner_id,
1566                reason,
1567            } => {
1568                if is_reserved_name(&name) {
1569                    return Err(AdapterError::Catalog(Error::new(
1570                        ErrorKind::ReservedReplicaName(name),
1571                    )));
1572                }
1573                let cluster = state.get_cluster(cluster_id);
1574                // The replica id is allocated out-of-band by the durable
1575                // allocator before the transaction, mirroring cluster and item
1576                // ids. Nothing allocates a replica id in-apply.
1577                tx.insert_cluster_replica_with_id(
1578                    cluster_id,
1579                    replica_id,
1580                    &name,
1581                    config.clone().into(),
1582                    owner_id,
1583                )?;
1584                if let ReplicaLocation::Managed(ManagedReplicaLocation {
1585                    size,
1586                    billed_as,
1587                    internal,
1588                    ..
1589                }) = &config.location
1590                {
1591                    let (reason, scheduling_policies) = reason.into_audit_log();
1592                    let details = EventDetails::CreateClusterReplicaV4(
1593                        mz_audit_log::CreateClusterReplicaV4 {
1594                            cluster_id: cluster_id.to_string(),
1595                            cluster_name: cluster.name.clone(),
1596                            replica_id: Some(replica_id.to_string()),
1597                            replica_name: name.clone(),
1598                            logical_size: size.clone(),
1599                            billed_as: billed_as.clone(),
1600                            internal: *internal,
1601                            reason,
1602                            scheduling_policies,
1603                        },
1604                    );
1605                    CatalogState::add_to_audit_log(
1606                        &state.system_configuration,
1607                        oracle_write_ts,
1608                        session,
1609                        tx,
1610                        audit_events,
1611                        EventType::Create,
1612                        ObjectType::ClusterReplica,
1613                        details,
1614                    )?;
1615                }
1616            }
1617            Op::CreateItem {
1618                id,
1619                name,
1620                item,
1621                owner_id,
1622            } => {
1623                state.check_unstable_dependencies(&item)?;
1624
1625                match &item {
1626                    CatalogItem::Table(table) => {
1627                        let gids: Vec<_> = table.global_ids().collect();
1628                        assert_eq!(gids.len(), 1);
1629                        storage_collections_to_create.extend(gids);
1630                    }
1631                    CatalogItem::Source(source) => {
1632                        storage_collections_to_create.insert(source.global_id());
1633                    }
1634                    CatalogItem::MaterializedView(mv) => {
1635                        let mv_gid = mv.global_id_writes();
1636                        if let Some(target_id) = mv.replacement_target {
1637                            let target_gid = state.get_entry(&target_id).latest_global_id();
1638                            let shard_id =
1639                                state.storage_metadata().get_collection_shard(target_gid)?;
1640                            storage_collections_to_register.insert(mv_gid, shard_id);
1641                        } else {
1642                            storage_collections_to_create.insert(mv_gid);
1643                        }
1644                    }
1645                    CatalogItem::Sink(sink) => {
1646                        storage_collections_to_create.insert(sink.global_id());
1647                    }
1648                    CatalogItem::Log(_)
1649                    | CatalogItem::View(_)
1650                    | CatalogItem::Index(_)
1651                    | CatalogItem::Type(_)
1652                    | CatalogItem::Func(_)
1653                    | CatalogItem::Secret(_)
1654                    | CatalogItem::Connection(_) => (),
1655                }
1656
1657                let system_user = session.map_or(false, |s| s.user().is_system_user());
1658                if !system_user {
1659                    if let Some(id @ ClusterId::System(_)) = item.cluster_id() {
1660                        let cluster_name = state.clusters_by_id[&id].name.clone();
1661                        return Err(AdapterError::Catalog(Error::new(
1662                            ErrorKind::ReadOnlyCluster(cluster_name),
1663                        )));
1664                    }
1665                }
1666
1667                let owner_privileges = vec![rbac::owner_privilege(item.typ().into(), owner_id)];
1668                let default_privileges = state
1669                    .default_privileges
1670                    .get_applicable_privileges(
1671                        owner_id,
1672                        name.qualifiers.database_spec.id(),
1673                        Some(name.qualifiers.schema_spec.into()),
1674                        item.typ().into(),
1675                    )
1676                    .map(|item| item.mz_acl_item(owner_id));
1677                // mz_support can read all progress sources.
1678                let progress_source_privilege = if item.is_progress_source() {
1679                    Some(MzAclItem {
1680                        grantee: MZ_SUPPORT_ROLE_ID,
1681                        grantor: owner_id,
1682                        acl_mode: AclMode::SELECT,
1683                    })
1684                } else {
1685                    None
1686                };
1687                let privileges: Vec<_> = merge_mz_acl_items(
1688                    owner_privileges
1689                        .into_iter()
1690                        .chain(default_privileges)
1691                        .chain(progress_source_privilege),
1692                )
1693                .collect();
1694
1695                let temporary_oids = state.get_temporary_oids().collect();
1696
1697                if item.is_temporary() {
1698                    if name.qualifiers.database_spec != ResolvedDatabaseSpecifier::Ambient
1699                        || name.qualifiers.schema_spec != SchemaSpecifier::Temporary
1700                    {
1701                        return Err(AdapterError::Catalog(Error::new(
1702                            ErrorKind::InvalidTemporarySchema,
1703                        )));
1704                    }
1705                    let oid = tx.allocate_oid(&temporary_oids)?;
1706
1707                    let schema_id = name.qualifiers.schema_spec.clone().into();
1708                    let item_type = item.typ();
1709                    let (create_sql, global_id, versions) = item.to_serialized();
1710
1711                    let item = TemporaryItem {
1712                        id,
1713                        oid,
1714                        global_id,
1715                        schema_id,
1716                        name: name.item.clone(),
1717                        create_sql,
1718                        conn_id: item.conn_id().cloned(),
1719                        owner_id,
1720                        privileges: privileges.clone(),
1721                        extra_versions: versions,
1722                    };
1723                    temporary_item_updates.push((item, StateDiff::Addition));
1724
1725                    info!(
1726                        "create temporary {} {} ({})",
1727                        item_type,
1728                        state.resolve_full_name(&name, None),
1729                        id
1730                    );
1731                } else {
1732                    if let Some(temp_id) =
1733                        item.uses()
1734                            .iter()
1735                            .find(|id| match state.try_get_entry(*id) {
1736                                Some(entry) => entry.item().is_temporary(),
1737                                None => temporary_ids.contains(id),
1738                            })
1739                    {
1740                        let temp_item = state.get_entry(temp_id);
1741                        return Err(AdapterError::Catalog(Error::new(
1742                            ErrorKind::InvalidTemporaryDependency(temp_item.name().item.clone()),
1743                        )));
1744                    }
1745                    if name.qualifiers.database_spec == ResolvedDatabaseSpecifier::Ambient
1746                        && !system_user
1747                    {
1748                        let schema_name = state
1749                            .resolve_full_name(&name, session.map(|session| session.conn_id()))
1750                            .schema;
1751                        return Err(AdapterError::Catalog(Error::new(
1752                            ErrorKind::ReadOnlySystemSchema(schema_name),
1753                        )));
1754                    }
1755                    let schema_id = name.qualifiers.schema_spec.clone().into();
1756                    let item_type = item.typ();
1757                    let (create_sql, global_id, versions) = item.to_serialized();
1758                    tx.insert_user_item(
1759                        id,
1760                        global_id,
1761                        schema_id,
1762                        &name.item,
1763                        create_sql,
1764                        owner_id,
1765                        privileges.clone(),
1766                        &temporary_oids,
1767                        versions,
1768                    )?;
1769                    info!(
1770                        "create {} {} ({})",
1771                        item_type,
1772                        state.resolve_full_name(&name, None),
1773                        id
1774                    );
1775                }
1776
1777                if Self::should_audit_log_item(&item) {
1778                    let name = Self::full_name_detail(
1779                        &state.resolve_full_name(&name, session.map(|session| session.conn_id())),
1780                    );
1781                    let details = match &item {
1782                        CatalogItem::Source(s) => {
1783                            let cluster_id = match s.data_source {
1784                                // Ingestion exports don't have their own cluster, but
1785                                // run on their ingestion's cluster.
1786                                DataSourceDesc::IngestionExport { ingestion_id, .. } => {
1787                                    match state.get_entry(&ingestion_id).cluster_id() {
1788                                        Some(cluster_id) => Some(cluster_id.to_string()),
1789                                        None => None,
1790                                    }
1791                                }
1792                                _ => match item.cluster_id() {
1793                                    Some(cluster_id) => Some(cluster_id.to_string()),
1794                                    None => None,
1795                                },
1796                            };
1797
1798                            EventDetails::CreateSourceSinkV4(mz_audit_log::CreateSourceSinkV4 {
1799                                id: id.to_string(),
1800                                cluster_id,
1801                                name,
1802                                external_type: s.source_type().to_string(),
1803                            })
1804                        }
1805                        CatalogItem::Sink(s) => {
1806                            EventDetails::CreateSourceSinkV4(mz_audit_log::CreateSourceSinkV4 {
1807                                id: id.to_string(),
1808                                cluster_id: Some(s.cluster_id.to_string()),
1809                                name,
1810                                external_type: s.sink_type().to_string(),
1811                            })
1812                        }
1813                        CatalogItem::Index(i) => {
1814                            EventDetails::CreateIndexV1(mz_audit_log::CreateIndexV1 {
1815                                id: id.to_string(),
1816                                name,
1817                                cluster_id: i.cluster_id.to_string(),
1818                            })
1819                        }
1820                        CatalogItem::MaterializedView(mv) => {
1821                            EventDetails::CreateMaterializedViewV1(
1822                                mz_audit_log::CreateMaterializedViewV1 {
1823                                    id: id.to_string(),
1824                                    name,
1825                                    cluster_id: mv.cluster_id.to_string(),
1826                                    replacement_target_id: mv
1827                                        .replacement_target
1828                                        .map(|id| id.to_string()),
1829                                },
1830                            )
1831                        }
1832                        CatalogItem::Table(_)
1833                        | CatalogItem::Log(_)
1834                        | CatalogItem::View(_)
1835                        | CatalogItem::Type(_)
1836                        | CatalogItem::Func(_)
1837                        | CatalogItem::Secret(_)
1838                        | CatalogItem::Connection(_) => EventDetails::IdFullNameV1(IdFullNameV1 {
1839                            id: id.to_string(),
1840                            name,
1841                        }),
1842                    };
1843                    CatalogState::add_to_audit_log(
1844                        &state.system_configuration,
1845                        oracle_write_ts,
1846                        session,
1847                        tx,
1848                        audit_events,
1849                        EventType::Create,
1850                        catalog_type_to_audit_object_type(item.typ()),
1851                        details,
1852                    )?;
1853                }
1854            }
1855            Op::CreateNetworkPolicy {
1856                rules,
1857                name,
1858                owner_id,
1859            } => {
1860                if state.network_policies_by_name.contains_key(&name) {
1861                    return Err(AdapterError::PlanError(PlanError::Catalog(
1862                        SqlCatalogError::NetworkPolicyAlreadyExists(name),
1863                    )));
1864                }
1865                if is_reserved_name(&name) {
1866                    return Err(AdapterError::Catalog(Error::new(
1867                        ErrorKind::ReservedNetworkPolicyName(name),
1868                    )));
1869                }
1870
1871                let owner_privileges = vec![rbac::owner_privilege(
1872                    mz_sql::catalog::ObjectType::NetworkPolicy,
1873                    owner_id,
1874                )];
1875                let default_privileges = state
1876                    .default_privileges
1877                    .get_applicable_privileges(
1878                        owner_id,
1879                        None,
1880                        None,
1881                        mz_sql::catalog::ObjectType::NetworkPolicy,
1882                    )
1883                    .map(|item| item.mz_acl_item(owner_id));
1884                let privileges: Vec<_> =
1885                    merge_mz_acl_items(owner_privileges.into_iter().chain(default_privileges))
1886                        .collect();
1887
1888                let temporary_oids: HashSet<_> = state.get_temporary_oids().collect();
1889                let id = tx.insert_user_network_policy(
1890                    name.clone(),
1891                    rules,
1892                    privileges,
1893                    owner_id,
1894                    &temporary_oids,
1895                )?;
1896
1897                CatalogState::add_to_audit_log(
1898                    &state.system_configuration,
1899                    oracle_write_ts,
1900                    session,
1901                    tx,
1902                    audit_events,
1903                    EventType::Create,
1904                    ObjectType::NetworkPolicy,
1905                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
1906                        id: id.to_string(),
1907                        name: name.clone(),
1908                    }),
1909                )?;
1910
1911                info!("created network policy {name} ({id})");
1912            }
1913            Op::Comment {
1914                object_id,
1915                sub_component,
1916                comment,
1917            } => {
1918                tx.update_comment(object_id, sub_component, comment)?;
1919                let entry = state.get_comment_id_entry(&object_id);
1920                let should_log = entry
1921                    .map(|entry| Self::should_audit_log_item(entry.item()))
1922                    // Things that aren't catalog entries can't be temp, so should be logged.
1923                    .unwrap_or(true);
1924                // TODO: We need a conn_id to resolve schema names. This means that system-initiated
1925                // comments won't be logged for now.
1926                if let (Some(conn_id), true) =
1927                    (session.map(|session| session.conn_id()), should_log)
1928                {
1929                    CatalogState::add_to_audit_log(
1930                        &state.system_configuration,
1931                        oracle_write_ts,
1932                        session,
1933                        tx,
1934                        audit_events,
1935                        EventType::Comment,
1936                        comment_id_to_audit_object_type(object_id),
1937                        EventDetails::IdNameV1(IdNameV1 {
1938                            // CommentObjectIds don't have a great string representation, but debug will do for now.
1939                            id: format!("{object_id:?}"),
1940                            name: state.comment_id_to_audit_log_name(object_id, conn_id),
1941                        }),
1942                    )?;
1943                }
1944            }
1945            Op::UpdateSourceReferences {
1946                source_id,
1947                references,
1948            } => {
1949                tx.update_source_references(
1950                    source_id,
1951                    references
1952                        .references
1953                        .into_iter()
1954                        .map(|reference| reference.into())
1955                        .collect(),
1956                    references.updated_at,
1957                )?;
1958            }
1959            Op::DropObjects(drop_object_infos) => {
1960                // Generate all of the objects that need to get dropped.
1961                let delta = ObjectsToDrop::generate(drop_object_infos, state, session)?;
1962
1963                // Drop any associated comments.
1964                tx.drop_comments(&delta.comments)?;
1965
1966                // Drop any items.
1967                let (durable_items_to_drop, temporary_items_to_drop): (BTreeSet<_>, BTreeSet<_>) =
1968                    delta
1969                        .items
1970                        .iter()
1971                        .map(|id| id)
1972                        .partition(|id| !state.get_entry(*id).item().is_temporary());
1973                tx.remove_items(&durable_items_to_drop)?;
1974                temporary_item_updates.extend(temporary_items_to_drop.into_iter().map(|id| {
1975                    let entry = state.get_entry(&id);
1976                    (entry.clone().into(), StateDiff::Retraction)
1977                }));
1978
1979                for item_id in delta.items {
1980                    let entry = state.get_entry(&item_id);
1981
1982                    if entry.item().is_storage_collection() {
1983                        storage_collections_to_drop.extend(entry.global_ids());
1984                    }
1985
1986                    if state.source_references.contains_key(&item_id) {
1987                        tx.remove_source_references(item_id)?;
1988                    }
1989
1990                    if Self::should_audit_log_item(entry.item()) {
1991                        CatalogState::add_to_audit_log(
1992                            &state.system_configuration,
1993                            oracle_write_ts,
1994                            session,
1995                            tx,
1996                            audit_events,
1997                            EventType::Drop,
1998                            catalog_type_to_audit_object_type(entry.item().typ()),
1999                            EventDetails::IdFullNameV1(IdFullNameV1 {
2000                                id: item_id.to_string(),
2001                                name: Self::full_name_detail(&state.resolve_full_name(
2002                                    entry.name(),
2003                                    session.map(|session| session.conn_id()),
2004                                )),
2005                            }),
2006                        )?;
2007                    }
2008                    info!(
2009                        "drop {} {} ({})",
2010                        entry.item_type(),
2011                        state.resolve_full_name(entry.name(), entry.conn_id()),
2012                        item_id
2013                    );
2014                }
2015
2016                // Drop any schemas.
2017                let schemas = delta
2018                    .schemas
2019                    .iter()
2020                    .map(|(schema_spec, database_spec)| {
2021                        (SchemaId::from(schema_spec), *database_spec)
2022                    })
2023                    .collect();
2024                tx.remove_schemas(&schemas)?;
2025
2026                for (schema_spec, database_spec) in delta.schemas {
2027                    let schema = state.get_schema(
2028                        &database_spec,
2029                        &schema_spec,
2030                        session
2031                            .map(|session| session.conn_id())
2032                            .unwrap_or(&SYSTEM_CONN_ID),
2033                    );
2034
2035                    let schema_id = SchemaId::from(schema_spec);
2036                    let database_id = match database_spec {
2037                        ResolvedDatabaseSpecifier::Ambient => None,
2038                        ResolvedDatabaseSpecifier::Id(database_id) => Some(database_id),
2039                    };
2040
2041                    CatalogState::add_to_audit_log(
2042                        &state.system_configuration,
2043                        oracle_write_ts,
2044                        session,
2045                        tx,
2046                        audit_events,
2047                        EventType::Drop,
2048                        ObjectType::Schema,
2049                        EventDetails::SchemaV2(mz_audit_log::SchemaV2 {
2050                            id: schema_id.to_string(),
2051                            name: schema.name.schema.to_string(),
2052                            database_name: database_id
2053                                .map(|database_id| state.database_by_id[&database_id].name.clone()),
2054                        }),
2055                    )?;
2056                }
2057
2058                // Drop any databases.
2059                tx.remove_databases(&delta.databases)?;
2060
2061                for database_id in delta.databases {
2062                    let database = state.get_database(&database_id).clone();
2063
2064                    CatalogState::add_to_audit_log(
2065                        &state.system_configuration,
2066                        oracle_write_ts,
2067                        session,
2068                        tx,
2069                        audit_events,
2070                        EventType::Drop,
2071                        ObjectType::Database,
2072                        EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
2073                            id: database_id.to_string(),
2074                            name: database.name.clone(),
2075                        }),
2076                    )?;
2077                }
2078
2079                // Drop any roles.
2080                tx.remove_user_roles(&delta.roles)?;
2081
2082                for role_id in delta.roles {
2083                    let role = state
2084                        .roles_by_id
2085                        .get(&role_id)
2086                        .expect("catalog out of sync");
2087
2088                    CatalogState::add_to_audit_log(
2089                        &state.system_configuration,
2090                        oracle_write_ts,
2091                        session,
2092                        tx,
2093                        audit_events,
2094                        EventType::Drop,
2095                        ObjectType::Role,
2096                        EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
2097                            id: role.id.to_string(),
2098                            name: role.name.clone(),
2099                        }),
2100                    )?;
2101                    info!("drop role {}", role.name());
2102                }
2103
2104                // Drop any network policies.
2105                tx.remove_network_policies(&delta.network_policies)?;
2106
2107                for network_policy_id in delta.network_policies {
2108                    let policy = state
2109                        .network_policies_by_id
2110                        .get(&network_policy_id)
2111                        .expect("catalog out of sync");
2112
2113                    CatalogState::add_to_audit_log(
2114                        &state.system_configuration,
2115                        oracle_write_ts,
2116                        session,
2117                        tx,
2118                        audit_events,
2119                        EventType::Drop,
2120                        ObjectType::NetworkPolicy,
2121                        EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
2122                            id: policy.id.to_string(),
2123                            name: policy.name.clone(),
2124                        }),
2125                    )?;
2126                    info!("drop network policy {}", policy.name.clone());
2127                }
2128
2129                // Drop any replicas.
2130                let replicas = delta.replicas.keys().copied().collect();
2131                tx.remove_cluster_replicas(&replicas)?;
2132
2133                for (replica_id, (cluster_id, reason)) in delta.replicas {
2134                    let cluster = state.get_cluster(cluster_id);
2135                    let replica = cluster.replica(replica_id).expect("Must exist");
2136
2137                    let (reason, scheduling_policies) = reason.into_audit_log();
2138                    let details =
2139                        EventDetails::DropClusterReplicaV3(mz_audit_log::DropClusterReplicaV3 {
2140                            cluster_id: cluster_id.to_string(),
2141                            cluster_name: cluster.name.clone(),
2142                            replica_id: Some(replica_id.to_string()),
2143                            replica_name: replica.name.clone(),
2144                            reason,
2145                            scheduling_policies,
2146                        });
2147                    CatalogState::add_to_audit_log(
2148                        &state.system_configuration,
2149                        oracle_write_ts,
2150                        session,
2151                        tx,
2152                        audit_events,
2153                        EventType::Drop,
2154                        ObjectType::ClusterReplica,
2155                        details,
2156                    )?;
2157                }
2158
2159                // Drop any clusters.
2160                tx.remove_clusters(&delta.clusters)?;
2161
2162                for cluster_id in delta.clusters {
2163                    let cluster = state.get_cluster(cluster_id);
2164
2165                    CatalogState::add_to_audit_log(
2166                        &state.system_configuration,
2167                        oracle_write_ts,
2168                        session,
2169                        tx,
2170                        audit_events,
2171                        EventType::Drop,
2172                        ObjectType::Cluster,
2173                        EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
2174                            id: cluster.id.to_string(),
2175                            name: cluster.name.clone(),
2176                        }),
2177                    )?;
2178                }
2179            }
2180            Op::GrantRole {
2181                role_id,
2182                member_id,
2183                grantor_id,
2184            } => {
2185                state.ensure_not_reserved_role(&member_id)?;
2186                state.ensure_grantable_role(&role_id)?;
2187                if state.collect_role_membership(&role_id).contains(&member_id) {
2188                    let group_role = state.get_role(&role_id);
2189                    let member_role = state.get_role(&member_id);
2190                    return Err(AdapterError::Catalog(Error::new(
2191                        ErrorKind::CircularRoleMembership {
2192                            role_name: group_role.name().to_string(),
2193                            member_name: member_role.name().to_string(),
2194                        },
2195                    )));
2196                }
2197                let mut member_role = state.get_role(&member_id).clone();
2198                member_role.membership.map.insert(role_id, grantor_id);
2199                tx.update_role(member_id, member_role.into(), PasswordAction::NoChange)?;
2200
2201                CatalogState::add_to_audit_log(
2202                    &state.system_configuration,
2203                    oracle_write_ts,
2204                    session,
2205                    tx,
2206                    audit_events,
2207                    EventType::Grant,
2208                    ObjectType::Role,
2209                    EventDetails::GrantRoleV2(mz_audit_log::GrantRoleV2 {
2210                        role_id: role_id.to_string(),
2211                        member_id: member_id.to_string(),
2212                        grantor_id: grantor_id.to_string(),
2213                        executed_by: session
2214                            .map(|session| session.authenticated_role_id())
2215                            .unwrap_or(&MZ_SYSTEM_ROLE_ID)
2216                            .to_string(),
2217                    }),
2218                )?;
2219            }
2220            Op::RevokeRole {
2221                role_id,
2222                member_id,
2223                grantor_id,
2224            } => {
2225                state.ensure_not_reserved_role(&member_id)?;
2226                state.ensure_grantable_role(&role_id)?;
2227                let mut member_role = state.get_role(&member_id).clone();
2228                member_role.membership.map.remove(&role_id);
2229                tx.update_role(member_id, member_role.into(), PasswordAction::NoChange)?;
2230
2231                CatalogState::add_to_audit_log(
2232                    &state.system_configuration,
2233                    oracle_write_ts,
2234                    session,
2235                    tx,
2236                    audit_events,
2237                    EventType::Revoke,
2238                    ObjectType::Role,
2239                    EventDetails::RevokeRoleV2(mz_audit_log::RevokeRoleV2 {
2240                        role_id: role_id.to_string(),
2241                        member_id: member_id.to_string(),
2242                        grantor_id: grantor_id.to_string(),
2243                        executed_by: session
2244                            .map(|session| session.authenticated_role_id())
2245                            .unwrap_or(&MZ_SYSTEM_ROLE_ID)
2246                            .to_string(),
2247                    }),
2248                )?;
2249            }
2250            Op::UpdatePrivilege {
2251                target_id,
2252                privileges,
2253                variant,
2254            } => {
2255                let update_privilege_fn = |target_privileges: &mut PrivilegeMap| {
2256                    for privilege in &privileges {
2257                        match variant {
2258                            UpdatePrivilegeVariant::Grant => {
2259                                target_privileges.grant(privilege.clone());
2260                            }
2261                            UpdatePrivilegeVariant::Revoke => {
2262                                target_privileges.revoke(privilege);
2263                            }
2264                        }
2265                    }
2266                };
2267                match &target_id {
2268                    SystemObjectId::Object(object_id) => match object_id {
2269                        ObjectId::Cluster(id) => {
2270                            let mut cluster = state.get_cluster(*id).clone();
2271                            update_privilege_fn(&mut cluster.privileges);
2272                            tx.update_cluster(*id, cluster.into())?;
2273                        }
2274                        ObjectId::Database(id) => {
2275                            let mut database = state.get_database(id).clone();
2276                            update_privilege_fn(&mut database.privileges);
2277                            tx.update_database(*id, database.into())?;
2278                        }
2279                        ObjectId::NetworkPolicy(id) => {
2280                            let mut policy = state.get_network_policy(id).clone();
2281                            update_privilege_fn(&mut policy.privileges);
2282                            tx.update_network_policy(*id, policy.into())?;
2283                        }
2284                        ObjectId::Schema((database_spec, schema_spec)) => {
2285                            let schema_id = schema_spec.clone().into();
2286                            let mut schema = state
2287                                .get_schema(
2288                                    database_spec,
2289                                    schema_spec,
2290                                    session
2291                                        .map(|session| session.conn_id())
2292                                        .unwrap_or(&SYSTEM_CONN_ID),
2293                                )
2294                                .clone();
2295                            update_privilege_fn(&mut schema.privileges);
2296                            tx.update_schema(schema_id, schema.into())?;
2297                        }
2298                        ObjectId::Item(id) => {
2299                            let entry = state.get_entry(id);
2300                            let mut new_entry = entry.clone();
2301                            update_privilege_fn(&mut new_entry.privileges);
2302                            if !new_entry.item().is_temporary() {
2303                                tx.update_item(*id, new_entry.into())?;
2304                            } else {
2305                                temporary_item_updates
2306                                    .push((entry.clone().into(), StateDiff::Retraction));
2307                                temporary_item_updates
2308                                    .push((new_entry.into(), StateDiff::Addition));
2309                            }
2310                        }
2311                        ObjectId::Role(_) | ObjectId::ClusterReplica(_) => {}
2312                    },
2313                    SystemObjectId::System => {
2314                        let mut system_privileges = PrivilegeMap::clone(&state.system_privileges);
2315                        update_privilege_fn(&mut system_privileges);
2316                        for privilege in &privileges {
2317                            let new_privilege = system_privileges
2318                                .get_acl_item(&privilege.grantee, &privilege.grantor);
2319                            tx.set_system_privilege(
2320                                privilege.grantee,
2321                                privilege.grantor,
2322                                new_privilege.map(|new_privilege| new_privilege.acl_mode),
2323                            )?;
2324                        }
2325                    }
2326                }
2327                let object_type = state.get_system_object_type(&target_id);
2328                let object_id_str = match &target_id {
2329                    SystemObjectId::System => "SYSTEM".to_string(),
2330                    SystemObjectId::Object(id) => id.to_string(),
2331                };
2332                // One audit event per grantee, even though the batch is a single durable write.
2333                for privilege in &privileges {
2334                    CatalogState::add_to_audit_log(
2335                        &state.system_configuration,
2336                        oracle_write_ts,
2337                        session,
2338                        tx,
2339                        audit_events,
2340                        variant.into(),
2341                        system_object_type_to_audit_object_type(&object_type),
2342                        EventDetails::UpdatePrivilegeV1(mz_audit_log::UpdatePrivilegeV1 {
2343                            object_id: object_id_str.clone(),
2344                            grantee_id: privilege.grantee.to_string(),
2345                            grantor_id: privilege.grantor.to_string(),
2346                            privileges: privilege.acl_mode.to_string(),
2347                        }),
2348                    )?;
2349                }
2350            }
2351            Op::UpdateDefaultPrivilege {
2352                privilege_object,
2353                privilege_acl_item,
2354                variant,
2355            } => {
2356                let mut default_privileges = DefaultPrivileges::clone(&state.default_privileges);
2357                match variant {
2358                    UpdatePrivilegeVariant::Grant => default_privileges
2359                        .grant(privilege_object.clone(), privilege_acl_item.clone()),
2360                    UpdatePrivilegeVariant::Revoke => {
2361                        default_privileges.revoke(&privilege_object, &privilege_acl_item)
2362                    }
2363                }
2364                let new_acl_mode = default_privileges
2365                    .get_privileges_for_grantee(&privilege_object, &privilege_acl_item.grantee);
2366                tx.set_default_privilege(
2367                    privilege_object.role_id,
2368                    privilege_object.database_id,
2369                    privilege_object.schema_id,
2370                    privilege_object.object_type,
2371                    privilege_acl_item.grantee,
2372                    new_acl_mode.cloned(),
2373                )?;
2374                CatalogState::add_to_audit_log(
2375                    &state.system_configuration,
2376                    oracle_write_ts,
2377                    session,
2378                    tx,
2379                    audit_events,
2380                    variant.into(),
2381                    object_type_to_audit_object_type(privilege_object.object_type),
2382                    EventDetails::AlterDefaultPrivilegeV1(mz_audit_log::AlterDefaultPrivilegeV1 {
2383                        role_id: privilege_object.role_id.to_string(),
2384                        database_id: privilege_object.database_id.map(|id| id.to_string()),
2385                        schema_id: privilege_object.schema_id.map(|id| id.to_string()),
2386                        grantee_id: privilege_acl_item.grantee.to_string(),
2387                        privileges: privilege_acl_item.acl_mode.to_string(),
2388                    }),
2389                )?;
2390            }
2391            Op::RenameCluster {
2392                id,
2393                name,
2394                to_name,
2395                check_reserved_names,
2396            } => {
2397                if id.is_system() {
2398                    return Err(AdapterError::Catalog(Error::new(
2399                        ErrorKind::ReadOnlyCluster(name.clone()),
2400                    )));
2401                }
2402                if check_reserved_names && is_reserved_name(&to_name) {
2403                    return Err(AdapterError::Catalog(Error::new(
2404                        ErrorKind::ReservedClusterName(to_name),
2405                    )));
2406                }
2407                tx.rename_cluster(id, &name, &to_name)?;
2408                CatalogState::add_to_audit_log(
2409                    &state.system_configuration,
2410                    oracle_write_ts,
2411                    session,
2412                    tx,
2413                    audit_events,
2414                    EventType::Alter,
2415                    ObjectType::Cluster,
2416                    EventDetails::RenameClusterV1(mz_audit_log::RenameClusterV1 {
2417                        id: id.to_string(),
2418                        old_name: name.clone(),
2419                        new_name: to_name.clone(),
2420                    }),
2421                )?;
2422                info!("rename cluster {name} to {to_name}");
2423            }
2424            Op::RenameClusterReplica {
2425                cluster_id,
2426                replica_id,
2427                name,
2428                to_name,
2429            } => {
2430                if is_reserved_name(&to_name) {
2431                    return Err(AdapterError::Catalog(Error::new(
2432                        ErrorKind::ReservedReplicaName(to_name),
2433                    )));
2434                }
2435                tx.rename_cluster_replica(replica_id, &name, &to_name)?;
2436                CatalogState::add_to_audit_log(
2437                    &state.system_configuration,
2438                    oracle_write_ts,
2439                    session,
2440                    tx,
2441                    audit_events,
2442                    EventType::Alter,
2443                    ObjectType::ClusterReplica,
2444                    EventDetails::RenameClusterReplicaV1(mz_audit_log::RenameClusterReplicaV1 {
2445                        cluster_id: cluster_id.to_string(),
2446                        replica_id: replica_id.to_string(),
2447                        old_name: name.replica.as_str().to_string(),
2448                        new_name: to_name.clone(),
2449                    }),
2450                )?;
2451                info!("rename cluster replica {name} to {to_name}");
2452            }
2453            Op::RenameItem {
2454                id,
2455                to_name,
2456                current_full_name,
2457            } => {
2458                let mut updates = Vec::new();
2459
2460                let entry = state.get_entry(&id);
2461                if let CatalogItem::Type(_) = entry.item() {
2462                    return Err(AdapterError::Catalog(Error::new(ErrorKind::TypeRename(
2463                        current_full_name.to_string(),
2464                    ))));
2465                }
2466
2467                if entry.id().is_system() {
2468                    let name = state
2469                        .resolve_full_name(entry.name(), session.map(|session| session.conn_id()));
2470                    return Err(AdapterError::Catalog(Error::new(ErrorKind::ReadOnlyItem(
2471                        name.to_string(),
2472                    ))));
2473                }
2474
2475                let mut to_full_name = current_full_name.clone();
2476                to_full_name.item.clone_from(&to_name);
2477
2478                let mut to_qualified_name = entry.name().clone();
2479                to_qualified_name.item.clone_from(&to_name);
2480
2481                let details = EventDetails::RenameItemV1(mz_audit_log::RenameItemV1 {
2482                    id: id.to_string(),
2483                    old_name: Self::full_name_detail(&current_full_name),
2484                    new_name: Self::full_name_detail(&to_full_name),
2485                });
2486                if Self::should_audit_log_item(entry.item()) {
2487                    CatalogState::add_to_audit_log(
2488                        &state.system_configuration,
2489                        oracle_write_ts,
2490                        session,
2491                        tx,
2492                        audit_events,
2493                        EventType::Alter,
2494                        catalog_type_to_audit_object_type(entry.item().typ()),
2495                        details,
2496                    )?;
2497                }
2498
2499                // Rename item itself.
2500                let mut new_entry = entry.clone();
2501                new_entry.name.item.clone_from(&to_name);
2502                new_entry.item = entry
2503                    .item()
2504                    .rename_item_refs(current_full_name.clone(), to_full_name.item.clone(), true)
2505                    .map_err(|e| {
2506                        Error::new(ErrorKind::from(AmbiguousRename {
2507                            depender: state
2508                                .resolve_full_name(entry.name(), entry.conn_id())
2509                                .to_string(),
2510                            dependee: state
2511                                .resolve_full_name(entry.name(), entry.conn_id())
2512                                .to_string(),
2513                            message: e,
2514                        }))
2515                    })?;
2516
2517                for id in entry.referenced_by() {
2518                    let dependent_item = state.get_entry(id);
2519                    let mut to_entry = dependent_item.clone();
2520                    to_entry.item = dependent_item
2521                        .item()
2522                        .rename_item_refs(
2523                            current_full_name.clone(),
2524                            to_full_name.item.clone(),
2525                            false,
2526                        )
2527                        .map_err(|e| {
2528                            Error::new(ErrorKind::from(AmbiguousRename {
2529                                depender: state
2530                                    .resolve_full_name(
2531                                        dependent_item.name(),
2532                                        dependent_item.conn_id(),
2533                                    )
2534                                    .to_string(),
2535                                dependee: state
2536                                    .resolve_full_name(entry.name(), entry.conn_id())
2537                                    .to_string(),
2538                                message: e,
2539                            }))
2540                        })?;
2541
2542                    if !to_entry.item().is_temporary() {
2543                        tx.update_item(*id, to_entry.into())?;
2544                    } else {
2545                        temporary_item_updates
2546                            .push((dependent_item.clone().into(), StateDiff::Retraction));
2547                        temporary_item_updates.push((to_entry.into(), StateDiff::Addition));
2548                    }
2549                    updates.push(*id);
2550                }
2551                if !new_entry.item().is_temporary() {
2552                    tx.update_item(id, new_entry.into())?;
2553                } else {
2554                    temporary_item_updates.push((entry.clone().into(), StateDiff::Retraction));
2555                    temporary_item_updates.push((new_entry.into(), StateDiff::Addition));
2556                }
2557
2558                updates.push(id);
2559                for id in updates {
2560                    Self::log_update(state, &id);
2561                }
2562            }
2563            Op::RenameSchema {
2564                database_spec,
2565                schema_spec,
2566                new_name,
2567                check_reserved_names,
2568            } => {
2569                if check_reserved_names && is_reserved_name(&new_name) {
2570                    return Err(AdapterError::Catalog(Error::new(
2571                        ErrorKind::ReservedSchemaName(new_name),
2572                    )));
2573                }
2574
2575                let conn_id = session
2576                    .map(|session| session.conn_id())
2577                    .unwrap_or(&SYSTEM_CONN_ID);
2578
2579                let schema = state.get_schema(&database_spec, &schema_spec, conn_id);
2580                let cur_name = schema.name().schema.clone();
2581
2582                let ResolvedDatabaseSpecifier::Id(database_id) = database_spec else {
2583                    return Err(AdapterError::Catalog(Error::new(
2584                        ErrorKind::AmbientSchemaRename(cur_name),
2585                    )));
2586                };
2587                let database = state.get_database(&database_id);
2588                let database_name = &database.name;
2589
2590                let mut updates: Vec<CatalogItemId> = Vec::new();
2591                let mut items_to_update = BTreeMap::new();
2592                // Dedup guard covering both persistent and temporary items. An
2593                // item can be reached more than once: once as a member of the
2594                // schema and again for each object in the schema it depends on.
2595                // Persistent items are also tracked in `items_to_update`, but
2596                // temporary items only land in `temporary_item_updates` (a Vec
2597                // with no dedup), so a temp dependent referencing multiple
2598                // objects in the renamed schema would otherwise be enqueued once
2599                // per reference. That produces duplicate retraction/addition
2600                // updates that consolidate to an invalid diff (e.g. -2) and
2601                // panic catalog apply.
2602                let mut seen: BTreeSet<CatalogItemId> = BTreeSet::new();
2603
2604                let mut update_item = |id: &CatalogItemId| {
2605                    if !seen.insert(*id) {
2606                        return Ok(());
2607                    }
2608
2609                    let entry = state.get_entry(id);
2610
2611                    // Update our item.
2612                    let mut new_entry = entry.clone();
2613                    new_entry.item = entry
2614                        .item
2615                        .rename_schema_refs(database_name, &cur_name, &new_name)
2616                        .map_err(|(s, _i)| {
2617                            Error::new(ErrorKind::from(AmbiguousRename {
2618                                depender: state
2619                                    .resolve_full_name(entry.name(), entry.conn_id())
2620                                    .to_string(),
2621                                dependee: format!("{database_name}.{cur_name}"),
2622                                message: format!("ambiguous reference to schema named {s}"),
2623                            }))
2624                        })?;
2625
2626                    // Queue updates for Catalog storage and Builtin Tables.
2627                    if !new_entry.item().is_temporary() {
2628                        items_to_update.insert(*id, new_entry.into());
2629                    } else {
2630                        temporary_item_updates.push((entry.clone().into(), StateDiff::Retraction));
2631                        temporary_item_updates.push((new_entry.into(), StateDiff::Addition));
2632                    }
2633                    updates.push(*id);
2634
2635                    Ok::<_, AdapterError>(())
2636                };
2637
2638                // Update all of the items in the schema. A schema holds items,
2639                // types, and functions in separate maps, and any of them may be
2640                // referenced by another object's create_sql via a schema-qualified
2641                // name, so all three must be rewritten.
2642                for (_name, item_id) in schema
2643                    .items
2644                    .iter()
2645                    .chain(schema.types.iter())
2646                    .chain(schema.functions.iter())
2647                {
2648                    // Update the item itself.
2649                    update_item(item_id)?;
2650
2651                    // Update everything that depends on this item.
2652                    for id in state.get_entry(item_id).referenced_by() {
2653                        update_item(id)?;
2654                    }
2655                }
2656                // Note: When updating the transaction it's very important that we update the
2657                // items as a whole group, otherwise we exhibit quadratic behavior.
2658                tx.update_items(items_to_update)?;
2659
2660                // Renaming temporary schemas is not supported.
2661                let SchemaSpecifier::Id(schema_id) = *schema.id() else {
2662                    let schema_name = schema.name().schema.clone();
2663                    return Err(AdapterError::Catalog(crate::catalog::Error::new(
2664                        crate::catalog::ErrorKind::ReadOnlySystemSchema(schema_name),
2665                    )));
2666                };
2667
2668                // Add an entry to the audit log.
2669                let database_name = database_spec
2670                    .id()
2671                    .map(|id| state.get_database(&id).name.clone());
2672                let details = EventDetails::RenameSchemaV1(mz_audit_log::RenameSchemaV1 {
2673                    id: schema_id.to_string(),
2674                    old_name: schema.name().schema.clone(),
2675                    new_name: new_name.clone(),
2676                    database_name,
2677                });
2678                CatalogState::add_to_audit_log(
2679                    &state.system_configuration,
2680                    oracle_write_ts,
2681                    session,
2682                    tx,
2683                    audit_events,
2684                    EventType::Alter,
2685                    mz_audit_log::ObjectType::Schema,
2686                    details,
2687                )?;
2688
2689                // Update the schema itself.
2690                let mut new_schema = schema.clone();
2691                new_schema.name.schema.clone_from(&new_name);
2692                tx.update_schema(schema_id, new_schema.into())?;
2693
2694                for id in updates {
2695                    Self::log_update(state, &id);
2696                }
2697            }
2698            Op::UpdateOwner { id, new_owner } => {
2699                let conn_id = session
2700                    .map(|session| session.conn_id())
2701                    .unwrap_or(&SYSTEM_CONN_ID);
2702                let old_owner = state
2703                    .get_owner_id(&id, conn_id)
2704                    .expect("cannot update the owner of an object without an owner");
2705                match &id {
2706                    ObjectId::Cluster(id) => {
2707                        let mut cluster = state.get_cluster(*id).clone();
2708                        if id.is_system() {
2709                            return Err(AdapterError::Catalog(Error::new(
2710                                ErrorKind::ReadOnlyCluster(cluster.name),
2711                            )));
2712                        }
2713                        Self::update_privilege_owners(
2714                            &mut cluster.privileges,
2715                            cluster.owner_id,
2716                            new_owner,
2717                        );
2718                        cluster.owner_id = new_owner;
2719                        tx.update_cluster(*id, cluster.into())?;
2720                    }
2721                    ObjectId::ClusterReplica((cluster_id, replica_id)) => {
2722                        let cluster = state.get_cluster(*cluster_id);
2723                        let mut replica = cluster
2724                            .replica(*replica_id)
2725                            .expect("catalog out of sync")
2726                            .clone();
2727                        if replica_id.is_system() {
2728                            return Err(AdapterError::Catalog(Error::new(
2729                                ErrorKind::ReadOnlyClusterReplica(replica.name),
2730                            )));
2731                        }
2732                        replica.owner_id = new_owner;
2733                        tx.update_cluster_replica(*replica_id, replica.into())?;
2734                    }
2735                    ObjectId::Database(id) => {
2736                        let mut database = state.get_database(id).clone();
2737                        if id.is_system() {
2738                            return Err(AdapterError::Catalog(Error::new(
2739                                ErrorKind::ReadOnlyDatabase(database.name),
2740                            )));
2741                        }
2742                        Self::update_privilege_owners(
2743                            &mut database.privileges,
2744                            database.owner_id,
2745                            new_owner,
2746                        );
2747                        database.owner_id = new_owner;
2748                        tx.update_database(*id, database.clone().into())?;
2749                    }
2750                    ObjectId::Schema((database_spec, schema_spec)) => {
2751                        let schema_id: SchemaId = schema_spec.clone().into();
2752                        let mut schema = state
2753                            .get_schema(database_spec, schema_spec, conn_id)
2754                            .clone();
2755                        if schema_id.is_system() {
2756                            let name = schema.name();
2757                            let full_name = state.resolve_full_schema_name(name);
2758                            return Err(AdapterError::Catalog(Error::new(
2759                                ErrorKind::ReadOnlySystemSchema(full_name.to_string()),
2760                            )));
2761                        }
2762                        Self::update_privilege_owners(
2763                            &mut schema.privileges,
2764                            schema.owner_id,
2765                            new_owner,
2766                        );
2767                        schema.owner_id = new_owner;
2768                        tx.update_schema(schema_id, schema.into())?;
2769                    }
2770                    ObjectId::Item(id) => {
2771                        let entry = state.get_entry(id);
2772                        let mut new_entry = entry.clone();
2773                        if id.is_system() {
2774                            let full_name = state.resolve_full_name(
2775                                new_entry.name(),
2776                                session.map(|session| session.conn_id()),
2777                            );
2778                            return Err(AdapterError::Catalog(Error::new(
2779                                ErrorKind::ReadOnlyItem(full_name.to_string()),
2780                            )));
2781                        }
2782                        Self::update_privilege_owners(
2783                            &mut new_entry.privileges,
2784                            new_entry.owner_id,
2785                            new_owner,
2786                        );
2787                        new_entry.owner_id = new_owner;
2788                        if !new_entry.item().is_temporary() {
2789                            tx.update_item(*id, new_entry.into())?;
2790                        } else {
2791                            temporary_item_updates
2792                                .push((entry.clone().into(), StateDiff::Retraction));
2793                            temporary_item_updates.push((new_entry.into(), StateDiff::Addition));
2794                        }
2795                    }
2796                    ObjectId::NetworkPolicy(id) => {
2797                        let mut policy = state.get_network_policy(id).clone();
2798                        if id.is_system() {
2799                            return Err(AdapterError::Catalog(Error::new(
2800                                ErrorKind::ReadOnlyNetworkPolicy(policy.name),
2801                            )));
2802                        }
2803                        Self::update_privilege_owners(
2804                            &mut policy.privileges,
2805                            policy.owner_id,
2806                            new_owner,
2807                        );
2808                        policy.owner_id = new_owner;
2809                        tx.update_network_policy(*id, policy.into())?;
2810                    }
2811                    ObjectId::Role(_) => unreachable!("roles have no owner"),
2812                }
2813                let object_type = state.get_object_type(&id);
2814                CatalogState::add_to_audit_log(
2815                    &state.system_configuration,
2816                    oracle_write_ts,
2817                    session,
2818                    tx,
2819                    audit_events,
2820                    EventType::Alter,
2821                    object_type_to_audit_object_type(object_type),
2822                    EventDetails::UpdateOwnerV1(mz_audit_log::UpdateOwnerV1 {
2823                        object_id: id.to_string(),
2824                        old_owner_id: old_owner.to_string(),
2825                        new_owner_id: new_owner.to_string(),
2826                    }),
2827                )?;
2828            }
2829            Op::UpdateClusterConfig {
2830                id,
2831                name,
2832                config,
2833                reconfiguration_audit,
2834                burst_audit,
2835            } => {
2836                let mut cluster = state.get_cluster(id).clone();
2837                // Writes that declare no reconfiguration intent must not move
2838                // the reconfiguration lifecycle, or the audit log would
2839                // silently lose the transition. Writer bug, fails the
2840                // transaction.
2841                if reconfiguration_audit.is_none()
2842                    && Self::reconfiguration_lifecycle_moved(&cluster.config, &config)
2843                {
2844                    return Err(AdapterError::Internal(format!(
2845                        "cluster {name} reconfiguration record moved without a declared \
2846                         audit intent"
2847                    )));
2848                }
2849                let reconfiguration_event = reconfiguration_audit
2850                    .map(|audit| Self::reconfiguration_audit_details(&config, id, &name, audit))
2851                    .transpose()?;
2852                let burst_event = burst_audit
2853                    .map(|audit| {
2854                        Self::burst_audit_details(&cluster.config, &config, id, &name, audit)
2855                    })
2856                    .transpose()?;
2857                cluster.config = config;
2858                tx.update_cluster(id, cluster.into())?;
2859                info!("update cluster {}", name);
2860
2861                CatalogState::add_to_audit_log(
2862                    &state.system_configuration,
2863                    oracle_write_ts,
2864                    session,
2865                    tx,
2866                    audit_events,
2867                    EventType::Alter,
2868                    ObjectType::Cluster,
2869                    EventDetails::IdNameV1(mz_audit_log::IdNameV1 {
2870                        id: id.to_string(),
2871                        name,
2872                    }),
2873                )?;
2874
2875                if let Some(details) = reconfiguration_event {
2876                    CatalogState::add_to_audit_log(
2877                        &state.system_configuration,
2878                        oracle_write_ts,
2879                        session,
2880                        tx,
2881                        audit_events,
2882                        EventType::Alter,
2883                        ObjectType::Cluster,
2884                        EventDetails::AlterClusterReconfigurationV1(details),
2885                    )?;
2886                }
2887
2888                if let Some(details) = burst_event {
2889                    CatalogState::add_to_audit_log(
2890                        &state.system_configuration,
2891                        oracle_write_ts,
2892                        session,
2893                        tx,
2894                        audit_events,
2895                        EventType::Alter,
2896                        ObjectType::Cluster,
2897                        EventDetails::ClusterHydrationBurstV1(details),
2898                    )?;
2899                }
2900            }
2901            Op::UpdateClusterReplicaConfig {
2902                replica_id,
2903                cluster_id,
2904                config,
2905            } => {
2906                let replica = state.get_cluster_replica(cluster_id, replica_id).to_owned();
2907                info!("update replica {}", replica.name);
2908                tx.update_cluster_replica(
2909                    replica_id,
2910                    mz_catalog::durable::ClusterReplica {
2911                        cluster_id,
2912                        replica_id,
2913                        name: replica.name.clone(),
2914                        config: config.clone().into(),
2915                        owner_id: replica.owner_id,
2916                    },
2917                )?;
2918            }
2919            Op::UpdateItem { id, name, to_item } => {
2920                // A non-temporary item must not depend on a temporary one.
2921                // Temporary objects are session-scoped and never persisted, so
2922                // a durable item referencing one is a dangling reference that
2923                // panics the coordinator when its create_sql is re-planned on
2924                // catalog apply (apply emits durable items before temporary
2925                // ones, relying on this invariant). `Op::CreateItem` enforces
2926                // it; mirror it here so ALTER paths (e.g. ALTER SINK ... SET
2927                // FROM) cannot repoint a persistent item at a temporary one.
2928                if !to_item.is_temporary() {
2929                    if let Some(temp_id) =
2930                        to_item
2931                            .uses()
2932                            .iter()
2933                            .find(|id| match state.try_get_entry(*id) {
2934                                Some(entry) => entry.item().is_temporary(),
2935                                None => temporary_ids.contains(id),
2936                            })
2937                    {
2938                        let temp_item = state.get_entry(temp_id);
2939                        return Err(AdapterError::Catalog(Error::new(
2940                            ErrorKind::InvalidTemporaryDependency(temp_item.name().item.clone()),
2941                        )));
2942                    }
2943                }
2944
2945                let mut entry = state.get_entry(&id).clone();
2946                entry.name = name.clone();
2947                entry.item = to_item.clone();
2948                tx.update_item(id, entry.into())?;
2949
2950                if Self::should_audit_log_item(&to_item) {
2951                    let mut full_name = Self::full_name_detail(
2952                        &state.resolve_full_name(&name, session.map(|session| session.conn_id())),
2953                    );
2954                    full_name.item = name.item;
2955
2956                    CatalogState::add_to_audit_log(
2957                        &state.system_configuration,
2958                        oracle_write_ts,
2959                        session,
2960                        tx,
2961                        audit_events,
2962                        EventType::Alter,
2963                        catalog_type_to_audit_object_type(to_item.typ()),
2964                        EventDetails::UpdateItemV1(mz_audit_log::UpdateItemV1 {
2965                            id: id.to_string(),
2966                            name: full_name,
2967                        }),
2968                    )?;
2969                }
2970
2971                Self::log_update(state, &id);
2972            }
2973            Op::UpdateSystemConfiguration { name, value } => {
2974                let parsed_value = state.parse_system_configuration(&name, value.borrow())?;
2975                tx.upsert_system_config(&name, parsed_value.clone())?;
2976                // This mirrors some "system vars" into the catalog storage
2977                // "config" collection so that we can toggle the flag with
2978                // Launch Darkly, but use it in boot before Launch Darkly is
2979                // available.
2980                if name == WITH_0DT_DEPLOYMENT_MAX_WAIT.name() {
2981                    let with_0dt_deployment_max_wait =
2982                        Duration::parse(VarInput::Flat(&parsed_value))
2983                            .expect("parsing succeeded above");
2984                    tx.set_0dt_deployment_max_wait(with_0dt_deployment_max_wait)?;
2985                } else if name == WITH_0DT_DEPLOYMENT_DDL_CHECK_INTERVAL.name() {
2986                    let with_0dt_deployment_ddl_check_interval =
2987                        Duration::parse(VarInput::Flat(&parsed_value))
2988                            .expect("parsing succeeded above");
2989                    tx.set_0dt_deployment_ddl_check_interval(
2990                        with_0dt_deployment_ddl_check_interval,
2991                    )?;
2992                } else if name == ENABLE_0DT_DEPLOYMENT_PANIC_AFTER_TIMEOUT.name() {
2993                    let panic_after_timeout =
2994                        strconv::parse_bool(&parsed_value).expect("parsing succeeded above");
2995                    tx.set_enable_0dt_deployment_panic_after_timeout(panic_after_timeout)?;
2996                }
2997
2998                CatalogState::add_to_audit_log(
2999                    &state.system_configuration,
3000                    oracle_write_ts,
3001                    session,
3002                    tx,
3003                    audit_events,
3004                    EventType::Alter,
3005                    ObjectType::System,
3006                    EventDetails::SetV1(mz_audit_log::SetV1 {
3007                        name,
3008                        value: Some(value.borrow().to_vec().join(", ")),
3009                    }),
3010                )?;
3011            }
3012            Op::ResetSystemConfiguration { name } => {
3013                tx.remove_system_config(&name);
3014                // This mirrors some "system vars" into the catalog storage
3015                // "config" collection so that we can toggle the flag with
3016                // Launch Darkly, but use it in boot before Launch Darkly is
3017                // available.
3018                if name == WITH_0DT_DEPLOYMENT_MAX_WAIT.name() {
3019                    tx.reset_0dt_deployment_max_wait()?;
3020                } else if name == WITH_0DT_DEPLOYMENT_DDL_CHECK_INTERVAL.name() {
3021                    tx.reset_0dt_deployment_ddl_check_interval()?;
3022                } else if name == ENABLE_0DT_DEPLOYMENT_PANIC_AFTER_TIMEOUT.name() {
3023                    tx.reset_enable_0dt_deployment_panic_after_timeout()?;
3024                }
3025
3026                CatalogState::add_to_audit_log(
3027                    &state.system_configuration,
3028                    oracle_write_ts,
3029                    session,
3030                    tx,
3031                    audit_events,
3032                    EventType::Alter,
3033                    ObjectType::System,
3034                    EventDetails::SetV1(mz_audit_log::SetV1 { name, value: None }),
3035                )?;
3036            }
3037            Op::ResetAllSystemConfiguration => {
3038                tx.clear_system_configs();
3039                tx.reset_0dt_deployment_max_wait()?;
3040                tx.reset_0dt_deployment_ddl_check_interval()?;
3041                tx.reset_enable_0dt_deployment_panic_after_timeout()?;
3042
3043                CatalogState::add_to_audit_log(
3044                    &state.system_configuration,
3045                    oracle_write_ts,
3046                    session,
3047                    tx,
3048                    audit_events,
3049                    EventType::Alter,
3050                    ObjectType::System,
3051                    EventDetails::ResetAllV1,
3052                )?;
3053            }
3054            Op::UpdateScopedSystemParameters {
3055                scoped,
3056                prune_scope,
3057            } => {
3058                // Diff `scoped` against the durable cache and persist only the
3059                // delta: upsert changed/added entries, then remove entries the
3060                // update no longer serves. A removal for a still-live object is
3061                // bounded by `prune_scope` so this update does not delete a row
3062                // for an object it was not evaluated for (e.g. one created after
3063                // the update's snapshot, whose override rode its own create
3064                // transaction). `None` prunes unconditionally (the
3065                // disabled-feature clear path). Rows whose owning object is no
3066                // longer live are always pruned regardless of `prune_scope`,
3067                // because nothing else garbage collects them and ids are never
3068                // reused, so this lazily reclaims orphans left by dropped
3069                // objects.
3070                let live_clusters: BTreeSet<ClusterId> =
3071                    tx.get_clusters().map(|cluster| cluster.id).collect();
3072                let live_replicas: BTreeSet<ReplicaId> = tx
3073                    .get_cluster_replicas()
3074                    .map(|replica| replica.replica_id)
3075                    .collect();
3076                let prune_cluster = |id: &ClusterId| {
3077                    prune_scope
3078                        .as_ref()
3079                        .map_or(true, |s| s.clusters.contains(id))
3080                };
3081                let prune_replica = |id: &ReplicaId| {
3082                    prune_scope
3083                        .as_ref()
3084                        .map_or(true, |s| s.replicas.contains(id))
3085                };
3086
3087                // Cluster-coherent scope.
3088                let existing_cluster: BTreeMap<(ClusterId, String), String> = tx
3089                    .get_cluster_system_configurations()
3090                    .map(|c| ((c.cluster_id, c.name), c.value))
3091                    .collect();
3092                let mut desired_cluster: BTreeSet<(ClusterId, String)> = BTreeSet::new();
3093                for (cluster_id, values) in &scoped.cluster {
3094                    if !live_clusters.contains(cluster_id) {
3095                        continue;
3096                    }
3097                    for (name, value) in values {
3098                        desired_cluster.insert((*cluster_id, name.clone()));
3099                        if existing_cluster.get(&(*cluster_id, name.clone())) != Some(value) {
3100                            tx.upsert_cluster_system_config(*cluster_id, name, value.clone())?;
3101                        }
3102                    }
3103                }
3104                for (cluster_id, name) in existing_cluster.into_keys() {
3105                    if (!live_clusters.contains(&cluster_id) || prune_cluster(&cluster_id))
3106                        && !desired_cluster.contains(&(cluster_id, name.clone()))
3107                    {
3108                        tx.remove_cluster_system_config(cluster_id, &name);
3109                    }
3110                }
3111
3112                // Replica-local scope.
3113                let existing_replica: BTreeMap<(ReplicaId, String), String> = tx
3114                    .get_replica_system_configurations()
3115                    .map(|r| ((r.replica_id, r.name), r.value))
3116                    .collect();
3117                let mut desired_replica: BTreeSet<(ReplicaId, String)> = BTreeSet::new();
3118                for (replica_id, values) in &scoped.replica {
3119                    if !live_replicas.contains(replica_id) {
3120                        continue;
3121                    }
3122                    for (name, value) in values {
3123                        desired_replica.insert((*replica_id, name.clone()));
3124                        if existing_replica.get(&(*replica_id, name.clone())) != Some(value) {
3125                            tx.upsert_replica_system_config(*replica_id, name, value.clone())?;
3126                        }
3127                    }
3128                }
3129                for (replica_id, name) in existing_replica.into_keys() {
3130                    if (!live_replicas.contains(&replica_id) || prune_replica(&replica_id))
3131                        && !desired_replica.contains(&(replica_id, name.clone()))
3132                    {
3133                        tx.remove_replica_system_config(replica_id, &name);
3134                    }
3135                }
3136            }
3137            Op::InjectAuditEvents { events } => {
3138                for event in events {
3139                    let id = tx.allocate_audit_log_id()?;
3140                    let ev = VersionedEvent::new(
3141                        id,
3142                        event.event_type,
3143                        event.object_type,
3144                        event.details,
3145                        event.user,
3146                        oracle_write_ts.into(),
3147                    );
3148                    audit_events.push(ev.clone());
3149                    tx.insert_audit_log_event(ev);
3150                }
3151            }
3152        };
3153        Ok(temporary_item_updates)
3154    }
3155
3156    fn log_update(state: &CatalogState, id: &CatalogItemId) {
3157        let entry = state.get_entry(id);
3158        info!(
3159            "update {} {} ({})",
3160            entry.item_type(),
3161            state.resolve_full_name(entry.name(), entry.conn_id()),
3162            id
3163        );
3164    }
3165
3166    /// Update privileges to reflect the new owner. Based off of PostgreSQL's
3167    /// implementation:
3168    /// <https://github.com/postgres/postgres/blob/43a33ef54e503b61f269d088f2623ba3b9484ad7/src/backend/utils/adt/acl.c#L1078-L1177>
3169    fn update_privilege_owners(
3170        privileges: &mut PrivilegeMap,
3171        old_owner: RoleId,
3172        new_owner: RoleId,
3173    ) {
3174        // TODO(jkosh44) Would be nice not to clone every privilege.
3175        let mut flat_privileges: Vec<_> = privileges.all_values_owned().collect();
3176
3177        let mut new_present = false;
3178        for privilege in flat_privileges.iter_mut() {
3179            // Old owner's granted privilege are updated to be granted by the new
3180            // owner.
3181            if privilege.grantor == old_owner {
3182                privilege.grantor = new_owner;
3183            } else if privilege.grantor == new_owner {
3184                new_present = true;
3185            }
3186            // Old owner's privileges is given to the new owner.
3187            if privilege.grantee == old_owner {
3188                privilege.grantee = new_owner;
3189            } else if privilege.grantee == new_owner {
3190                new_present = true;
3191            }
3192        }
3193
3194        // If the old privilege list contained references to the new owner, we may
3195        // have created duplicate entries. Here we try and consolidate them. This
3196        // is inspired by PostgreSQL's algorithm but not identical.
3197        if new_present {
3198            // Group privileges by (grantee, grantor).
3199            let privilege_map: BTreeMap<_, Vec<_>> =
3200                flat_privileges
3201                    .into_iter()
3202                    .fold(BTreeMap::new(), |mut accum, privilege| {
3203                        accum
3204                            .entry((privilege.grantee, privilege.grantor))
3205                            .or_default()
3206                            .push(privilege);
3207                        accum
3208                    });
3209
3210            // Consolidate and update all privileges.
3211            flat_privileges = privilege_map
3212                .into_iter()
3213                .map(|((grantee, grantor), values)|
3214                    // Combine the acl_mode of all mz_aclitems with the same grantee and grantor.
3215                    values.into_iter().fold(
3216                        MzAclItem::empty(grantee, grantor),
3217                        |mut accum, mz_aclitem| {
3218                            accum.acl_mode =
3219                                accum.acl_mode.union(mz_aclitem.acl_mode);
3220                            accum
3221                        },
3222                    ))
3223                .collect();
3224        }
3225
3226        *privileges = PrivilegeMap::from_mz_acl_items(flat_privileges);
3227    }
3228}
3229
3230/// Prepare the given transaction for replacing a catalog item with a new version.
3231///
3232/// The new version gets a new `CatalogItemId`, which requires rewriting the `create_sql` of all
3233/// dependent objects to refer to that new ID (at a previous version).
3234///
3235/// Note that here is where we break the assumption that the `CatalogItemId` is a stable identifier
3236/// for catalog items. We currently think that there are no use cases that require this assumption,
3237/// but no way to know for sure.
3238fn tx_replace_item(
3239    tx: &mut Transaction<'_>,
3240    state: &CatalogState,
3241    id: CatalogItemId,
3242    new_entry: CatalogEntry,
3243    temporary_item_updates: &mut Vec<(TemporaryItem, StateDiff)>,
3244) -> Result<(), AdapterError> {
3245    let new_id = new_entry.id;
3246
3247    // Rewrite dependent objects to point to the new ID.
3248    for use_id in new_entry.referenced_by() {
3249        let dependent = state.get_entry(use_id);
3250
3251        // Temporary items live only in the in-memory catalog, never in the durable
3252        // transaction. They must be rewritten via `temporary_item_updates`. Routing them
3253        // through `tx.update_item` would be a no-op (they are not in `tx`), leaving the
3254        // temporary object referencing the removed `id` and tripping the catalog
3255        // consistency check. Mirrors how `Op::RenameItem` handles temporary dependents.
3256        if dependent.item().is_temporary() {
3257            let mut rewritten = dependent.clone();
3258            rewritten.item = rewritten.item.replace_item_refs(id, new_id);
3259            temporary_item_updates.push((dependent.clone().into(), StateDiff::Retraction));
3260            temporary_item_updates.push((rewritten.into(), StateDiff::Addition));
3261            continue;
3262        }
3263
3264        // The dependent might be dropped in the same tx, so check.
3265        if tx.get_item(use_id).is_none() {
3266            continue;
3267        }
3268
3269        let mut dependent = dependent.clone();
3270        dependent.item = dependent.item.replace_item_refs(id, new_id);
3271        tx.update_item(*use_id, dependent.into())?;
3272    }
3273
3274    // Move comments to the new ID.
3275    let old_comment_id = state.get_comment_id(ObjectId::Item(id));
3276    let new_comment_id = new_entry.comment_object_id();
3277    if let Some(comments) = state.comments.get_object_comments(old_comment_id) {
3278        tx.drop_comments(&[old_comment_id].into())?;
3279        for (sub, comment) in comments {
3280            tx.update_comment(new_comment_id, *sub, Some(comment.clone()))?;
3281        }
3282    }
3283
3284    let mz_catalog::durable::Item {
3285        id: _,
3286        oid,
3287        global_id,
3288        schema_id,
3289        name,
3290        create_sql,
3291        owner_id,
3292        privileges,
3293        extra_versions,
3294    } = new_entry.into();
3295
3296    tx.remove_item(id)?;
3297    tx.insert_item(
3298        new_id,
3299        oid,
3300        global_id,
3301        schema_id,
3302        &name,
3303        create_sql,
3304        owner_id,
3305        privileges,
3306        extra_versions,
3307    )?;
3308
3309    Ok(())
3310}
3311
3312/// Generate audit events for a replacement apply operation.
3313fn apply_replacement_audit_events(
3314    state: &CatalogState,
3315    target: &CatalogEntry,
3316    replacement: &CatalogEntry,
3317) -> Vec<(EventType, EventDetails)> {
3318    let mut events = Vec::new();
3319
3320    let target_name = state.resolve_full_name(target.name(), target.conn_id());
3321    let target_id_name = IdFullNameV1 {
3322        id: target.id().to_string(),
3323        name: Catalog::full_name_detail(&target_name),
3324    };
3325    let replacement_name = state.resolve_full_name(replacement.name(), replacement.conn_id());
3326    let replacement_id_name = IdFullNameV1 {
3327        id: replacement.id().to_string(),
3328        name: Catalog::full_name_detail(&replacement_name),
3329    };
3330
3331    if Catalog::should_audit_log_item(&replacement.item) {
3332        events.push((
3333            EventType::Drop,
3334            EventDetails::IdFullNameV1(replacement_id_name.clone()),
3335        ));
3336    }
3337
3338    if Catalog::should_audit_log_item(&target.item) {
3339        events.push((
3340            EventType::Alter,
3341            EventDetails::AlterApplyReplacementV1(mz_audit_log::AlterApplyReplacementV1 {
3342                target: target_id_name.clone(),
3343                replacement: replacement_id_name,
3344            }),
3345        ));
3346
3347        if let Some(old_cluster_id) = target.cluster_id()
3348            && let Some(new_cluster_id) = replacement.cluster_id()
3349            && old_cluster_id != new_cluster_id
3350        {
3351            // When the replacement is applied, the target takes on the ID of the replacement, so
3352            // we should use that ID for subsequent events.
3353            events.push((
3354                EventType::Alter,
3355                EventDetails::AlterSetClusterV1(mz_audit_log::AlterSetClusterV1 {
3356                    id: replacement.id().to_string(),
3357                    name: target_id_name.name,
3358                    old_cluster_id: old_cluster_id.to_string(),
3359                    new_cluster_id: new_cluster_id.to_string(),
3360                }),
3361            ));
3362        }
3363    }
3364
3365    events
3366}
3367
3368/// All of the objects that need to be removed in response to an [`Op::DropObjects`].
3369///
3370/// Note: Previously we used to omit a single `Op::DropObject` for every object
3371/// we needed to drop. But removing a batch of objects from a durable Catalog
3372/// Transaction is O(n) where `n` is the number of objects that exist in the
3373/// Catalog. This resulted in an unacceptable `O(m * n)` performance for a
3374/// `DROP ... CASCADE` statement.
3375#[derive(Debug, Default)]
3376pub(crate) struct ObjectsToDrop {
3377    pub comments: BTreeSet<CommentObjectId>,
3378    pub databases: BTreeSet<DatabaseId>,
3379    pub schemas: BTreeMap<SchemaSpecifier, ResolvedDatabaseSpecifier>,
3380    pub clusters: BTreeSet<ClusterId>,
3381    pub replicas: BTreeMap<ReplicaId, (ClusterId, ReplicaCreateDropReason)>,
3382    pub roles: BTreeSet<RoleId>,
3383    pub items: Vec<CatalogItemId>,
3384    pub network_policies: BTreeSet<NetworkPolicyId>,
3385}
3386
3387impl ObjectsToDrop {
3388    pub fn generate(
3389        drop_object_infos: impl IntoIterator<Item = DropObjectInfo>,
3390        state: &CatalogState,
3391        session: Option<&ConnMeta>,
3392    ) -> Result<Self, AdapterError> {
3393        let mut delta = ObjectsToDrop::default();
3394
3395        for drop_object_info in drop_object_infos {
3396            delta.add_item(drop_object_info, state, session)?;
3397        }
3398
3399        Ok(delta)
3400    }
3401
3402    fn add_item(
3403        &mut self,
3404        drop_object_info: DropObjectInfo,
3405        state: &CatalogState,
3406        session: Option<&ConnMeta>,
3407    ) -> Result<(), AdapterError> {
3408        self.comments
3409            .insert(state.get_comment_id(drop_object_info.to_object_id()));
3410
3411        match drop_object_info {
3412            DropObjectInfo::Database(database_id) => {
3413                let database = &state.database_by_id[&database_id];
3414                if database_id.is_system() {
3415                    return Err(AdapterError::Catalog(Error::new(
3416                        ErrorKind::ReadOnlyDatabase(database.name().to_string()),
3417                    )));
3418                }
3419
3420                self.databases.insert(database_id);
3421            }
3422            DropObjectInfo::Schema((database_spec, schema_spec)) => {
3423                let schema = state.get_schema(
3424                    &database_spec,
3425                    &schema_spec,
3426                    session
3427                        .map(|session| session.conn_id())
3428                        .unwrap_or(&SYSTEM_CONN_ID),
3429                );
3430                let schema_id: SchemaId = schema_spec.into();
3431                if schema_id.is_system() {
3432                    let name = schema.name();
3433                    let full_name = state.resolve_full_schema_name(name);
3434                    return Err(AdapterError::Catalog(Error::new(
3435                        ErrorKind::ReadOnlySystemSchema(full_name.to_string()),
3436                    )));
3437                }
3438
3439                self.schemas.insert(schema_spec, database_spec);
3440            }
3441            DropObjectInfo::Role(role_id) => {
3442                let name = state.get_role(&role_id).name().to_string();
3443                if role_id.is_system() || role_id.is_predefined() {
3444                    return Err(AdapterError::Catalog(Error::new(
3445                        ErrorKind::ReservedRoleName(name.clone()),
3446                    )));
3447                }
3448                state.ensure_not_reserved_role(&role_id)?;
3449
3450                self.roles.insert(role_id);
3451            }
3452            DropObjectInfo::Cluster(cluster_id) => {
3453                let cluster = state.get_cluster(cluster_id);
3454                let name = &cluster.name;
3455                if cluster_id.is_system() {
3456                    return Err(AdapterError::Catalog(Error::new(
3457                        ErrorKind::ReadOnlyCluster(name.clone()),
3458                    )));
3459                }
3460
3461                self.clusters.insert(cluster_id);
3462            }
3463            DropObjectInfo::ClusterReplica((cluster_id, replica_id, reason)) => {
3464                let cluster = state.get_cluster(cluster_id);
3465                let replica = cluster.replica(replica_id).expect("Must exist");
3466
3467                self.replicas
3468                    .insert(replica.replica_id, (cluster.id, reason));
3469
3470                // Implicitly drop materialized views that target this replica.
3471                // When the target replica is gone, no replica advances the
3472                // persist shard's upper frontier, causing reads to hang. Cascade
3473                // to anything depending on the implicitly-dropped MV so we don't
3474                // leave dangling references in the catalog.
3475                //
3476                // Plan-driven drops already include these dependents as their
3477                // own `DropObjectInfo::Item` entries (the plan stage expands
3478                // them via `cluster_replica_dependents`), so each one's comment
3479                // is recorded by the top-of-function `self.comments` insert.
3480                // This branch handles internal callers that build
3481                // `Op::DropObjects` directly with only the replica, so we expand
3482                // the dependents and record their comments ourselves.
3483                //
3484                // `seen` is seeded from the items already collected so that the
3485                // plan-driven path (where the dependents are processed before
3486                // the replica, in reverse-dependency order) does not re-add them
3487                // here.
3488                let mut seen: BTreeSet<ObjectId> =
3489                    self.items.iter().copied().map(ObjectId::Item).collect();
3490                for dep in state.cluster_replica_dependents(cluster_id, replica_id, &mut seen) {
3491                    if let ObjectId::Item(dep_id) = dep {
3492                        info!(
3493                            "implicitly dropping {} because target replica was dropped",
3494                            state.get_entry(&dep_id).name().item,
3495                        );
3496                        self.comments
3497                            .insert(state.get_comment_id(ObjectId::Item(dep_id)));
3498                        self.items.push(dep_id);
3499                    }
3500                }
3501            }
3502            DropObjectInfo::Item(item_id) => {
3503                let entry = state.get_entry(&item_id);
3504                if item_id.is_system() {
3505                    let name = entry.name();
3506                    let full_name =
3507                        state.resolve_full_name(name, session.map(|session| session.conn_id()));
3508                    return Err(AdapterError::Catalog(Error::new(ErrorKind::ReadOnlyItem(
3509                        full_name.to_string(),
3510                    ))));
3511                }
3512
3513                self.items.push(item_id);
3514            }
3515            DropObjectInfo::NetworkPolicy(network_policy_id) => {
3516                let policy = state.get_network_policy(&network_policy_id);
3517                let name = &policy.name;
3518                if network_policy_id.is_system() {
3519                    return Err(AdapterError::Catalog(Error::new(
3520                        ErrorKind::ReadOnlyNetworkPolicy(name.clone()),
3521                    )));
3522                }
3523
3524                self.network_policies.insert(network_policy_id);
3525            }
3526        }
3527
3528        Ok(())
3529    }
3530}
3531
3532#[cfg(test)]
3533mod tests {
3534    use mz_catalog::SYSTEM_CONN_ID;
3535    use mz_catalog::memory::objects::{CatalogItem, Table, TableDataSource};
3536    use mz_repr::adt::mz_acl_item::{AclMode, MzAclItem, PrivilegeMap};
3537    use mz_repr::role_id::RoleId;
3538    use mz_repr::{RelationDesc, RelationVersion, VersionedRelationDesc};
3539    use mz_sql::DEFAULT_SCHEMA;
3540    use mz_sql::catalog::CatalogDatabase;
3541    use mz_sql::names::{
3542        ItemQualifiers, QualifiedItemName, ResolvedDatabaseSpecifier, ResolvedIds,
3543    };
3544    use mz_sql::session::user::MZ_SYSTEM_ROLE_ID;
3545
3546    use crate::catalog::{Catalog, Op};
3547    use crate::session::DEFAULT_DATABASE_NAME;
3548
3549    #[mz_ore::test]
3550    fn test_replica_create_drop_reason_into_audit_log() {
3551        use mz_audit_log::CreateOrDropClusterReplicaReasonV1;
3552
3553        use crate::catalog::ReplicaCreateDropReason;
3554
3555        // `Retired` is the uniform word for every controller drop, with no
3556        // `scheduling_policies` blob: a drop happens exactly when no strategy
3557        // desires the replica, so there is no decision to record.
3558        let (reason, scheduling_policies) = ReplicaCreateDropReason::Retired.into_audit_log();
3559        assert_eq!(reason, CreateOrDropClusterReplicaReasonV1::Retired);
3560        assert!(scheduling_policies.is_none());
3561    }
3562
3563    #[mz_ore::test]
3564    fn test_reconfiguration_audit_details() {
3565        use std::time::Duration;
3566
3567        use mz_adapter_types::cluster_state::ReconfigurationAudit;
3568        use mz_audit_log::ReconfigurationLifecycleV1;
3569        use mz_catalog::memory::objects::{
3570            ClusterConfig, ClusterVariant, ClusterVariantManaged, ReconfigurationState,
3571            ReconfigurationStatus, ReconfigurationTarget,
3572        };
3573        use mz_controller::clusters::ReplicaLogging;
3574        use mz_controller_types::ClusterId;
3575        use mz_repr::Timestamp;
3576        use mz_repr::optimize::OptimizerFeatureOverrides;
3577
3578        let cluster_id = ClusterId::user(1).expect("valid id");
3579        let logging = ReplicaLogging {
3580            log_logging: false,
3581            interval: None,
3582        };
3583        let managed = |reconfiguration: Option<ReconfigurationState>| ClusterConfig {
3584            variant: ClusterVariant::Managed(ClusterVariantManaged {
3585                size: "small".into(),
3586                availability_zones: Vec::new(),
3587                logging: logging.clone(),
3588                replication_factor: 1,
3589                optimizer_feature_overrides: OptimizerFeatureOverrides::default(),
3590                schedule: Default::default(),
3591                auto_scaling_strategy: None,
3592                reconfiguration,
3593                burst: None,
3594            }),
3595            workload_class: None,
3596        };
3597        let record = |status| ReconfigurationState {
3598            target: ReconfigurationTarget {
3599                size: "large".into(),
3600                replication_factor: 2,
3601                availability_zones: vec!["az1".into(), "az2".into()],
3602                logging: ReplicaLogging {
3603                    log_logging: true,
3604                    interval: Some(Duration::from_secs(5)),
3605                },
3606            },
3607            deadline: Timestamp::from(400u64),
3608            on_timeout: mz_sql::plan::OnTimeoutAction::Rollback,
3609            status,
3610        };
3611
3612        let details = Catalog::reconfiguration_audit_details(
3613            &managed(Some(record(ReconfigurationStatus::InProgress))),
3614            cluster_id,
3615            "c",
3616            ReconfigurationAudit::Started,
3617        )
3618        .expect("record supplies audit details");
3619        assert_eq!(details.cluster_id, cluster_id.to_string());
3620        assert_eq!(details.cluster_name, "c");
3621        assert_eq!(details.transition, ReconfigurationLifecycleV1::Started);
3622        assert_eq!(details.forced, None);
3623        assert_eq!(details.target_size, "large");
3624        assert_eq!(details.target_replication_factor, 2);
3625        assert_eq!(
3626            details.target_availability_zones,
3627            vec!["az1".to_string(), "az2".to_string()]
3628        );
3629        assert_eq!(details.target_logging.log_logging, true);
3630        assert_eq!(
3631            details.target_logging.interval,
3632            Some(Duration::from_secs(5))
3633        );
3634        assert_eq!(details.deadline, Some(400));
3635
3636        // The `forced` bit exists only on the intent. The durable status reads
3637        // `Finalized` for both a hydrated and a forced cut-over, and the event
3638        // preserves the distinction.
3639        let forced = Catalog::reconfiguration_audit_details(
3640            &managed(Some(record(ReconfigurationStatus::Finalized))),
3641            cluster_id,
3642            "c",
3643            ReconfigurationAudit::Finalized { forced: true },
3644        )
3645        .expect("a coherent finalize supplies audit details");
3646        assert_eq!(forced.transition, ReconfigurationLifecycleV1::Finalized);
3647        assert_eq!(forced.forced, Some(true));
3648
3649        // A declared transition that contradicts the written record's status is
3650        // a writer bug and must fail the transaction.
3651        let incoherent = Catalog::reconfiguration_audit_details(
3652            &managed(Some(record(ReconfigurationStatus::TimedOut))),
3653            cluster_id,
3654            "c",
3655            ReconfigurationAudit::ResourceExhausted,
3656        );
3657        assert!(incoherent.is_err());
3658
3659        let missing = Catalog::reconfiguration_audit_details(
3660            &managed(None),
3661            cluster_id,
3662            "c",
3663            ReconfigurationAudit::Started,
3664        );
3665        assert!(missing.is_err());
3666    }
3667
3668    #[mz_ore::test]
3669    fn test_reconfiguration_lifecycle_moved() {
3670        use std::time::Duration;
3671
3672        use mz_catalog::memory::objects::{
3673            ClusterConfig, ClusterVariant, ClusterVariantManaged, ReconfigurationState,
3674            ReconfigurationStatus, ReconfigurationTarget,
3675        };
3676        use mz_controller::clusters::ReplicaLogging;
3677        use mz_repr::Timestamp;
3678        use mz_repr::optimize::OptimizerFeatureOverrides;
3679
3680        let managed = |reconfiguration: Option<ReconfigurationState>| ClusterConfig {
3681            variant: ClusterVariant::Managed(ClusterVariantManaged {
3682                size: "small".into(),
3683                availability_zones: Vec::new(),
3684                logging: ReplicaLogging {
3685                    log_logging: false,
3686                    interval: None,
3687                },
3688                replication_factor: 1,
3689                optimizer_feature_overrides: OptimizerFeatureOverrides::default(),
3690                schedule: Default::default(),
3691                auto_scaling_strategy: None,
3692                reconfiguration,
3693                burst: None,
3694            }),
3695            workload_class: None,
3696        };
3697        let unmanaged = ClusterConfig {
3698            variant: ClusterVariant::Unmanaged,
3699            workload_class: None,
3700        };
3701        let record = |status| ReconfigurationState {
3702            target: ReconfigurationTarget {
3703                size: "large".into(),
3704                replication_factor: 2,
3705                availability_zones: Vec::new(),
3706                logging: ReplicaLogging {
3707                    log_logging: false,
3708                    interval: Some(Duration::from_secs(1)),
3709                },
3710            },
3711            deadline: Timestamp::from(400u64),
3712            on_timeout: mz_sql::plan::OnTimeoutAction::Rollback,
3713            status,
3714        };
3715
3716        // Lifecycle movements: these writes must declare an audit intent.
3717        // A fresh record appears.
3718        assert!(Catalog::reconfiguration_lifecycle_moved(
3719            &managed(None),
3720            &managed(Some(record(ReconfigurationStatus::InProgress))),
3721        ));
3722        // The status changes.
3723        assert!(Catalog::reconfiguration_lifecycle_moved(
3724            &managed(Some(record(ReconfigurationStatus::InProgress))),
3725            &managed(Some(record(ReconfigurationStatus::Finalized))),
3726        ));
3727        // An in-progress record is dropped, e.g. by converting the cluster to
3728        // unmanaged (which the sequencer refuses, and this guard backstops).
3729        assert!(Catalog::reconfiguration_lifecycle_moved(
3730            &managed(Some(record(ReconfigurationStatus::InProgress))),
3731            &unmanaged,
3732        ));
3733
3734        // Not movements: no record at all, a status-preserving copy (legacy
3735        // paths carry the record forward), and dropping a settled record.
3736        assert!(!Catalog::reconfiguration_lifecycle_moved(
3737            &managed(None),
3738            &managed(None),
3739        ));
3740        assert!(!Catalog::reconfiguration_lifecycle_moved(
3741            &managed(Some(record(ReconfigurationStatus::InProgress))),
3742            &managed(Some(record(ReconfigurationStatus::InProgress))),
3743        ));
3744        assert!(!Catalog::reconfiguration_lifecycle_moved(
3745            &managed(Some(record(ReconfigurationStatus::Cancelled))),
3746            &unmanaged,
3747        ));
3748    }
3749
3750    #[mz_ore::test]
3751    fn test_burst_audit_details() {
3752        use std::time::Duration;
3753
3754        use mz_adapter_types::cluster_state::{BurstAudit, BurstFinishCause};
3755        use mz_audit_log::{BurstFinishCauseV1, HydrationBurstLifecycleV1};
3756        use mz_catalog::memory::objects::{
3757            BurstState, ClusterConfig, ClusterVariant, ClusterVariantManaged,
3758        };
3759        use mz_controller::clusters::ReplicaLogging;
3760        use mz_controller_types::ClusterId;
3761        use mz_repr::optimize::OptimizerFeatureOverrides;
3762
3763        let cluster_id = ClusterId::user(1).expect("valid id");
3764        let managed = |burst: Option<BurstState>| ClusterConfig {
3765            variant: ClusterVariant::Managed(ClusterVariantManaged {
3766                size: "small".into(),
3767                availability_zones: Vec::new(),
3768                logging: ReplicaLogging {
3769                    log_logging: false,
3770                    interval: None,
3771                },
3772                replication_factor: 1,
3773                optimizer_feature_overrides: OptimizerFeatureOverrides::default(),
3774                schedule: Default::default(),
3775                auto_scaling_strategy: None,
3776                reconfiguration: None,
3777                burst,
3778            }),
3779            workload_class: None,
3780        };
3781        let record = || BurstState {
3782            burst_size: "large".into(),
3783            linger_duration: Duration::from_secs(60),
3784            steady_hydrated_at: None,
3785        };
3786
3787        // A started intent reads the armed record from the new config.
3788        let started = Catalog::burst_audit_details(
3789            &managed(None),
3790            &managed(Some(record())),
3791            cluster_id,
3792            "c",
3793            BurstAudit::Started,
3794        )
3795        .expect("armed record supplies audit details");
3796        assert_eq!(started.transition, HydrationBurstLifecycleV1::Started);
3797        assert_eq!(started.finish_cause, None);
3798        assert_eq!(started.burst_size, "large");
3799
3800        // A finished intent reads the cleared record from the old config and
3801        // carries the writer-declared cause.
3802        let finished = Catalog::burst_audit_details(
3803            &managed(Some(record())),
3804            &managed(None),
3805            cluster_id,
3806            "c",
3807            BurstAudit::Finished {
3808                cause: BurstFinishCause::LingerElapsed,
3809            },
3810        )
3811        .expect("cleared record supplies audit details");
3812        assert_eq!(finished.transition, HydrationBurstLifecycleV1::Finished);
3813        assert_eq!(
3814            finished.finish_cause,
3815            Some(BurstFinishCauseV1::LingerElapsed)
3816        );
3817        assert_eq!(finished.burst_size, "large");
3818
3819        // An intent without a record on the corresponding side contradicts the
3820        // write and must fail the transaction.
3821        let incoherent = Catalog::burst_audit_details(
3822            &managed(None),
3823            &managed(None),
3824            cluster_id,
3825            "c",
3826            BurstAudit::Started,
3827        );
3828        assert!(incoherent.is_err());
3829    }
3830
3831    #[mz_ore::test]
3832    fn test_update_privilege_owners() {
3833        let old_owner = RoleId::User(1);
3834        let new_owner = RoleId::User(2);
3835        let other_role = RoleId::User(3);
3836
3837        // older owner exists as grantor.
3838        let mut privileges = PrivilegeMap::from_mz_acl_items(vec![
3839            MzAclItem {
3840                grantee: other_role,
3841                grantor: old_owner,
3842                acl_mode: AclMode::UPDATE,
3843            },
3844            MzAclItem {
3845                grantee: other_role,
3846                grantor: new_owner,
3847                acl_mode: AclMode::SELECT,
3848            },
3849        ]);
3850        Catalog::update_privilege_owners(&mut privileges, old_owner, new_owner);
3851        assert_eq!(1, privileges.all_values().count());
3852        assert_eq!(
3853            vec![MzAclItem {
3854                grantee: other_role,
3855                grantor: new_owner,
3856                acl_mode: AclMode::SELECT.union(AclMode::UPDATE)
3857            }],
3858            privileges.all_values_owned().collect::<Vec<_>>()
3859        );
3860
3861        // older owner exists as grantee.
3862        let mut privileges = PrivilegeMap::from_mz_acl_items(vec![
3863            MzAclItem {
3864                grantee: old_owner,
3865                grantor: other_role,
3866                acl_mode: AclMode::UPDATE,
3867            },
3868            MzAclItem {
3869                grantee: new_owner,
3870                grantor: other_role,
3871                acl_mode: AclMode::SELECT,
3872            },
3873        ]);
3874        Catalog::update_privilege_owners(&mut privileges, old_owner, new_owner);
3875        assert_eq!(1, privileges.all_values().count());
3876        assert_eq!(
3877            vec![MzAclItem {
3878                grantee: new_owner,
3879                grantor: other_role,
3880                acl_mode: AclMode::SELECT.union(AclMode::UPDATE)
3881            }],
3882            privileges.all_values_owned().collect::<Vec<_>>()
3883        );
3884
3885        // older owner exists as grantee and grantor.
3886        let mut privileges = PrivilegeMap::from_mz_acl_items(vec![
3887            MzAclItem {
3888                grantee: old_owner,
3889                grantor: old_owner,
3890                acl_mode: AclMode::UPDATE,
3891            },
3892            MzAclItem {
3893                grantee: new_owner,
3894                grantor: new_owner,
3895                acl_mode: AclMode::SELECT,
3896            },
3897        ]);
3898        Catalog::update_privilege_owners(&mut privileges, old_owner, new_owner);
3899        assert_eq!(1, privileges.all_values().count());
3900        assert_eq!(
3901            vec![MzAclItem {
3902                grantee: new_owner,
3903                grantor: new_owner,
3904                acl_mode: AclMode::SELECT.union(AclMode::UPDATE)
3905            }],
3906            privileges.all_values_owned().collect::<Vec<_>>()
3907        );
3908    }
3909
3910    /// Verifies that `transact_incremental_dry_run` processes only new ops
3911    /// against the accumulated state, not all ops from scratch. Two paths are
3912    /// compared:
3913    ///   - Incremental: two separate calls, each with one op
3914    ///   - All-at-once: one call with both ops
3915    /// Both must produce equivalent catalog state.
3916    #[mz_ore::test(tokio::test)]
3917    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
3918    async fn test_transact_incremental_dry_run_processes_only_new_ops() {
3919        Catalog::with_debug(|catalog| async move {
3920            // Resolve default database and schema.
3921            let database = catalog
3922                .resolve_database(DEFAULT_DATABASE_NAME)
3923                .expect("default database");
3924            let database_name = database.name.clone();
3925            let database_spec = ResolvedDatabaseSpecifier::Id(database.id());
3926            let schema = catalog
3927                .resolve_schema_in_database(&database_spec, DEFAULT_SCHEMA, &SYSTEM_CONN_ID)
3928                .expect("default schema");
3929            let schema_name = schema.name.schema.clone();
3930            let schema_spec = schema.id.clone();
3931
3932            // Allocate IDs for two tables.
3933            let (id_t1, global_id_t1) = catalog
3934                .allocate_user_id_for_test()
3935                .await
3936                .expect("allocate id for t1");
3937            let (id_t2, global_id_t2) = catalog
3938                .allocate_user_id_for_test()
3939                .await
3940                .expect("allocate id for t2");
3941
3942            let oracle_write_ts = catalog.current_upper().await;
3943
3944            // Build two CreateItem ops.
3945            let make_table_op = |id, global_id, name: &str| Op::CreateItem {
3946                id,
3947                name: QualifiedItemName {
3948                    qualifiers: ItemQualifiers {
3949                        database_spec: database_spec.clone(),
3950                        schema_spec: schema_spec.clone(),
3951                    },
3952                    item: name.to_string(),
3953                },
3954                item: CatalogItem::Table(Table {
3955                    create_sql: Some(format!(
3956                        "CREATE TABLE {database_name}.{schema_name}.{name} ()"
3957                    )),
3958                    desc: VersionedRelationDesc::new(RelationDesc::empty()),
3959                    collections: [(RelationVersion::root(), global_id)].into_iter().collect(),
3960                    conn_id: None,
3961                    resolved_ids: ResolvedIds::empty(),
3962                    custom_logical_compaction_window: None,
3963                    is_retained_metrics_object: false,
3964                    data_source: TableDataSource::TableWrites { defaults: vec![] },
3965                }),
3966                owner_id: MZ_SYSTEM_ROLE_ID,
3967            };
3968
3969            let op_t1 = make_table_op(id_t1, global_id_t1, "t1");
3970            let op_t2 = make_table_op(id_t2, global_id_t2, "t2");
3971
3972            let base_state = catalog.state().clone();
3973
3974            // --- Path A: Incremental (two separate dry-run calls) ---
3975
3976            // First call: only op_t1, no previous snapshot.
3977            let (state_after_t1, snapshot_after_t1) = catalog
3978                .transact_incremental_dry_run(
3979                    &base_state,
3980                    vec![op_t1.clone()],
3981                    None,
3982                    None,
3983                    oracle_write_ts,
3984                )
3985                .await
3986                .expect("first dry run");
3987
3988            // After first dry run: t1 exists, t2 does not.
3989            assert!(
3990                state_after_t1.try_get_entry(&id_t1).is_some(),
3991                "t1 should exist after first dry run"
3992            );
3993            assert_eq!(
3994                state_after_t1
3995                    .try_get_entry(&id_t1)
3996                    .expect("t1 entry")
3997                    .name()
3998                    .item,
3999                "t1"
4000            );
4001            assert!(
4002                state_after_t1.try_get_entry(&id_t2).is_none(),
4003                "t2 should NOT exist after first dry run"
4004            );
4005
4006            // Second call: only op_t2, using state/snapshot from first call.
4007            let (state_incremental, _) = catalog
4008                .transact_incremental_dry_run(
4009                    &state_after_t1,
4010                    vec![op_t2.clone()],
4011                    None,
4012                    Some(snapshot_after_t1),
4013                    oracle_write_ts,
4014                )
4015                .await
4016                .expect("second dry run");
4017
4018            // After second dry run: both t1 and t2 exist.
4019            assert!(
4020                state_incremental.try_get_entry(&id_t1).is_some(),
4021                "t1 should exist in incremental result"
4022            );
4023            assert!(
4024                state_incremental.try_get_entry(&id_t2).is_some(),
4025                "t2 should exist in incremental result"
4026            );
4027
4028            // --- Path B: All-at-once (single dry-run call with both ops) ---
4029
4030            let (state_all_at_once, _) = catalog
4031                .transact_incremental_dry_run(
4032                    &base_state,
4033                    vec![op_t1.clone(), op_t2.clone()],
4034                    None,
4035                    None,
4036                    oracle_write_ts,
4037                )
4038                .await
4039                .expect("all-at-once dry run");
4040
4041            assert!(
4042                state_all_at_once.try_get_entry(&id_t1).is_some(),
4043                "t1 should exist in all-at-once result"
4044            );
4045            assert!(
4046                state_all_at_once.try_get_entry(&id_t2).is_some(),
4047                "t2 should exist in all-at-once result"
4048            );
4049
4050            // --- Compare: both paths produce equivalent items ---
4051
4052            let inc_t1 = state_incremental.try_get_entry(&id_t1).expect("inc t1");
4053            let all_t1 = state_all_at_once.try_get_entry(&id_t1).expect("all t1");
4054            assert_eq!(inc_t1.name(), all_t1.name());
4055            assert_eq!(inc_t1.owner_id, all_t1.owner_id);
4056
4057            let inc_t2 = state_incremental.try_get_entry(&id_t2).expect("inc t2");
4058            let all_t2 = state_all_at_once.try_get_entry(&id_t2).expect("all t2");
4059            assert_eq!(inc_t2.name(), all_t2.name());
4060            assert_eq!(inc_t2.owner_id, all_t2.owner_id);
4061
4062            catalog.expire().await;
4063        })
4064        .await
4065    }
4066
4067    /// Exercises the diff-and-prune semantics of the
4068    /// `Op::UpdateScopedSystemParameters` apply handler over the cluster scope.
4069    /// Covers four behaviors: upsert of a desired row, the bounded-prune
4070    /// protection that spares a live cluster outside `prune_scope`, removal of a
4071    /// stale in-scope row, and the orphan-prune that reclaims a row whose owning
4072    /// cluster was dropped even though that id is absent from `prune_scope`.
4073    #[mz_ore::test(tokio::test)]
4074    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
4075    async fn test_transact_update_scoped_system_parameters_prune() {
4076        use std::collections::{BTreeMap, BTreeSet};
4077
4078        use mz_catalog::memory::objects::{ClusterConfig, ClusterVariant};
4079        use mz_controller_types::ClusterId;
4080
4081        use crate::catalog::DropObjectInfo;
4082        use crate::config::{ScopedParameters, ScopedParametersScope};
4083
4084        Catalog::with_debug(|mut catalog| async move {
4085            let param = "max_query_result_size".to_string();
4086            let value = "1MB".to_string();
4087
4088            // Allocate and create two live clusters, A and B, the same way
4089            // production code does.
4090            let commit_ts = catalog.current_upper().await;
4091            let cluster_a = catalog
4092                .allocate_user_cluster_id(commit_ts)
4093                .await
4094                .expect("allocate cluster A");
4095            let commit_ts = catalog.current_upper().await;
4096            let cluster_b = catalog
4097                .allocate_user_cluster_id(commit_ts)
4098                .await
4099                .expect("allocate cluster B");
4100
4101            let make_cluster_op = |id: ClusterId, name: &str| Op::CreateCluster {
4102                id,
4103                name: name.to_string(),
4104                introspection_sources: Vec::new(),
4105                owner_id: MZ_SYSTEM_ROLE_ID,
4106                config: ClusterConfig {
4107                    variant: ClusterVariant::Unmanaged,
4108                    workload_class: None,
4109                },
4110            };
4111
4112            let oracle_write_ts = catalog.current_upper().await;
4113            catalog
4114                .transact(
4115                    None,
4116                    oracle_write_ts,
4117                    None,
4118                    vec![
4119                        make_cluster_op(cluster_a, "test_cluster_a"),
4120                        make_cluster_op(cluster_b, "test_cluster_b"),
4121                    ],
4122                )
4123                .await
4124                .expect("create clusters");
4125
4126            // Reads the durable cluster system configuration rows.
4127            async fn rows(catalog: &Catalog) -> BTreeSet<(ClusterId, String, String)> {
4128                let mut storage = catalog.storage().await;
4129                let tx = storage.transaction().await.expect("open transaction");
4130                tx.get_cluster_system_configurations()
4131                    .map(|c| (c.cluster_id, c.name, c.value))
4132                    .collect()
4133            }
4134
4135            let scope_with = |ids: &[ClusterId]| ScopedParametersScope {
4136                clusters: ids.iter().copied().collect(),
4137                replicas: BTreeSet::new(),
4138            };
4139            let cluster_scoped = |id: ClusterId| {
4140                let mut cluster = BTreeMap::new();
4141                let mut overrides = BTreeMap::new();
4142                overrides.insert(param.clone(), value.clone());
4143                cluster.insert(id, overrides);
4144                ScopedParameters {
4145                    cluster,
4146                    replica: BTreeMap::new(),
4147                }
4148            };
4149            let empty_scoped = || ScopedParameters {
4150                cluster: BTreeMap::new(),
4151                replica: BTreeMap::new(),
4152            };
4153
4154            // Behavior 1: upsert. A is live and in scope, so the row is created.
4155            let oracle_write_ts = catalog.current_upper().await;
4156            catalog
4157                .transact(
4158                    None,
4159                    oracle_write_ts,
4160                    None,
4161                    vec![Op::UpdateScopedSystemParameters {
4162                        scoped: cluster_scoped(cluster_a),
4163                        prune_scope: Some(scope_with(&[cluster_a])),
4164                    }],
4165                )
4166                .await
4167                .expect("upsert A");
4168            assert!(
4169                rows(&catalog)
4170                    .await
4171                    .contains(&(cluster_a, param.clone(), value.clone())),
4172                "upsert must create the row for A"
4173            );
4174
4175            // Set up an additional row for B so the prune cases have something to
4176            // act on.
4177            let oracle_write_ts = catalog.current_upper().await;
4178            catalog
4179                .transact(
4180                    None,
4181                    oracle_write_ts,
4182                    None,
4183                    vec![Op::UpdateScopedSystemParameters {
4184                        scoped: cluster_scoped(cluster_b),
4185                        prune_scope: Some(scope_with(&[cluster_b])),
4186                    }],
4187                )
4188                .await
4189                .expect("upsert B");
4190            assert!(
4191                rows(&catalog)
4192                    .await
4193                    .contains(&(cluster_b, param.clone(), value.clone())),
4194                "setup must create the row for B"
4195            );
4196
4197            // Behavior 2: bounded prune. Running with empty `scoped` and a scope
4198            // that excludes the live cluster B must leave B's row intact, because
4199            // the update was not authoritative for B.
4200            let oracle_write_ts = catalog.current_upper().await;
4201            catalog
4202                .transact(
4203                    None,
4204                    oracle_write_ts,
4205                    None,
4206                    vec![Op::UpdateScopedSystemParameters {
4207                        scoped: empty_scoped(),
4208                        prune_scope: Some(scope_with(&[cluster_a])),
4209                    }],
4210                )
4211                .await
4212                .expect("bounded prune");
4213            assert!(
4214                rows(&catalog)
4215                    .await
4216                    .contains(&(cluster_b, param.clone(), value.clone())),
4217                "a live cluster outside prune_scope must keep its row"
4218            );
4219
4220            // Behavior 3: stale in-scope prune. A is live and in scope but not
4221            // desired, so its row is removed.
4222            let oracle_write_ts = catalog.current_upper().await;
4223            catalog
4224                .transact(
4225                    None,
4226                    oracle_write_ts,
4227                    None,
4228                    vec![Op::UpdateScopedSystemParameters {
4229                        scoped: empty_scoped(),
4230                        prune_scope: Some(scope_with(&[cluster_a])),
4231                    }],
4232                )
4233                .await
4234                .expect("stale in-scope prune");
4235            assert!(
4236                !rows(&catalog)
4237                    .await
4238                    .iter()
4239                    .any(|(id, _, _)| *id == cluster_a),
4240                "a live in-scope undesired row must be removed"
4241            );
4242
4243            // Behavior 4: orphan prune. Re-create A's row, drop cluster A, then run
4244            // the update with a scope that does NOT contain A. The sync loop only
4245            // ever places live ids in prune_scope, so the orphan must still be
4246            // reclaimed because its owning cluster is no longer live.
4247            let oracle_write_ts = catalog.current_upper().await;
4248            catalog
4249                .transact(
4250                    None,
4251                    oracle_write_ts,
4252                    None,
4253                    vec![Op::UpdateScopedSystemParameters {
4254                        scoped: cluster_scoped(cluster_a),
4255                        prune_scope: Some(scope_with(&[cluster_a])),
4256                    }],
4257                )
4258                .await
4259                .expect("re-create A row");
4260            assert!(
4261                rows(&catalog)
4262                    .await
4263                    .contains(&(cluster_a, param.clone(), value.clone())),
4264                "re-created row for A must exist"
4265            );
4266
4267            let oracle_write_ts = catalog.current_upper().await;
4268            catalog
4269                .transact(
4270                    None,
4271                    oracle_write_ts,
4272                    None,
4273                    vec![Op::DropObjects(vec![DropObjectInfo::Cluster(cluster_a)])],
4274                )
4275                .await
4276                .expect("drop cluster A");
4277
4278            // B is the only live cluster the sync loop would have evaluated, so
4279            // only B appears in prune_scope. A's id is deliberately absent.
4280            let oracle_write_ts = catalog.current_upper().await;
4281            catalog
4282                .transact(
4283                    None,
4284                    oracle_write_ts,
4285                    None,
4286                    vec![Op::UpdateScopedSystemParameters {
4287                        scoped: cluster_scoped(cluster_b),
4288                        prune_scope: Some(scope_with(&[cluster_b])),
4289                    }],
4290                )
4291                .await
4292                .expect("orphan prune");
4293            assert!(
4294                !rows(&catalog)
4295                    .await
4296                    .iter()
4297                    .any(|(id, _, _)| *id == cluster_a),
4298                "orphan row for a dropped cluster must be removed even when absent from prune_scope"
4299            );
4300
4301            catalog.expire().await;
4302        })
4303        .await
4304    }
4305}