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