Skip to main content

mz_adapter/catalog/
transact.rs

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