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