Skip to main content

mz_adapter/coord/
validity.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
10use std::collections::{BTreeMap, BTreeSet};
11use std::hash::{Hash, Hasher};
12
13use mz_cluster_client::ReplicaId;
14use mz_compute_types::ComputeInstanceId;
15use mz_repr::CatalogItemId;
16use mz_sql::catalog::CatalogItem;
17use mz_sql::rbac::UnauthorizedError;
18use mz_sql::session::user::RoleMetadata;
19
20use crate::AdapterError;
21use crate::catalog::Catalog;
22
23// The inner fields of PlanValidity are not pub to prevent callers from using them in SQL logic.
24// Callers are responsible for tracking their own needed IDs explicitly and not using
25// PlanValidity as a logic sidecar.
26
27/// A struct to hold information about the validity of plans and if they should be abandoned after
28/// doing work off of the Coordinator thread.
29///
30/// Concurrent DDLs (those returning `false` from `must_serialize_ddl()`) can mutate the catalog
31/// while a staged statement is being optimized off-thread. `check` is called at every off-thread
32/// → on-thread hop in `sequence_staged` so that dropped dependencies / clusters / replicas /
33/// roles surface as a user-facing error instead of panicking later when the persisted SQL is
34/// re-parsed during catalog application.
35///
36/// Opt-in: callers that perform a read-modify-write of a dependency's `create_sql` (e.g.
37/// `ALTER CONNECTION ... ROTATE KEYS`, see SQL-272) can additionally arm a `create_sql`-hash
38/// check via [`Self::with_dependency_hash_check`]. When armed, `check` re-hashes each
39/// dependency's `create_sql` and fails with [`AdapterError::ConcurrentDependencyMutation`] on a
40/// mismatch. The check must stay opt-in: a benign concurrent `RENAME` rewrites the dependency's
41/// `create_sql` and would otherwise spuriously fail read-only plans like peeks and SUBSCRIBE,
42/// whose optimized dataflows are unaffected by a rename (dependencies are referenced by stable
43/// id).
44#[derive(Debug, Clone)]
45pub struct PlanValidity {
46    /// The most recent revision at which this plan was verified as valid.
47    transient_revision: u64,
48    /// Objects on which the plan depends.
49    dependency_ids: BTreeSet<CatalogItemId>,
50    /// Hash of each dependency's `create_sql` at the time this plan was built. Empty unless
51    /// `check_dependency_hashes` is set. When set, `check` compares the live hash against the
52    /// captured one; a mismatch means the dependency was concurrently modified.
53    dependency_hashes: BTreeMap<CatalogItemId, u64>,
54    /// Whether `check` should compare `dependency_hashes` against the catalog. Off by default;
55    /// armed by [`Self::with_dependency_hash_check`] for the narrow set of plans that
56    /// read-modify-write a dependency's `create_sql`.
57    check_dependency_hashes: bool,
58    cluster_id: Option<ComputeInstanceId>,
59    replica_id: Option<ReplicaId>,
60    role_metadata: RoleMetadata,
61}
62
63impl PlanValidity {
64    pub fn new(
65        catalog: &Catalog,
66        dependency_ids: BTreeSet<CatalogItemId>,
67        cluster_id: Option<ComputeInstanceId>,
68        replica_id: Option<ReplicaId>,
69        role_metadata: RoleMetadata,
70    ) -> Self {
71        PlanValidity {
72            transient_revision: catalog.transient_revision(),
73            dependency_ids,
74            dependency_hashes: BTreeMap::new(),
75            check_dependency_hashes: false,
76            cluster_id,
77            replica_id,
78            role_metadata,
79        }
80    }
81
82    /// Arm the `create_sql`-hash check on this validity. Use only for plans that
83    /// read-modify-write a dependency's `create_sql` (e.g. `ALTER CONNECTION ... ROTATE KEYS`).
84    /// Snapshots the current `create_sql` of every id already in `dependency_ids`; subsequent
85    /// calls to [`Self::extend_dependencies`] will also capture hashes.
86    pub fn with_dependency_hash_check(mut self, catalog: &Catalog) -> Self {
87        self.check_dependency_hashes = true;
88        self.dependency_hashes = self
89            .dependency_ids
90            .iter()
91            .filter_map(|id| hash_item_create_sql(catalog, *id).map(|h| (*id, h)))
92            .collect();
93        self
94    }
95
96    pub fn extend_dependencies(
97        &mut self,
98        catalog: &Catalog,
99        ids: impl IntoIterator<Item = CatalogItemId>,
100    ) {
101        for id in ids {
102            if self.dependency_ids.insert(id) && self.check_dependency_hashes {
103                if let Some(hash) = hash_item_create_sql(catalog, id) {
104                    self.dependency_hashes.insert(id, hash);
105                }
106            }
107        }
108    }
109
110    /// Returns an error if the current catalog no longer has all dependencies, or — when the
111    /// hash check is armed via [`Self::with_dependency_hash_check`] — if any dependency's
112    /// `create_sql` has changed since this `PlanValidity` was built.
113    pub fn check(&mut self, catalog: &Catalog) -> Result<(), AdapterError> {
114        if self.transient_revision == catalog.transient_revision() {
115            return Ok(());
116        }
117        // If the transient revision changed, we have to recheck. If successful, bump the revision
118        // so next check uses the above fast path.
119        if let Some(cluster_id) = self.cluster_id {
120            let Some(cluster) = catalog.try_get_cluster(cluster_id) else {
121                return Err(AdapterError::ConcurrentDependencyDrop {
122                    dependency_kind: "cluster",
123                    dependency_id: cluster_id.to_string(),
124                });
125            };
126
127            if let Some(replica_id) = self.replica_id {
128                if cluster.replica(replica_id).is_none() {
129                    return Err(AdapterError::ConcurrentDependencyDrop {
130                        dependency_kind: "cluster replica",
131                        dependency_id: format!("{replica_id} of cluster {cluster_id}"),
132                    });
133                }
134            }
135        }
136        // Ids don't mutate and aren't reused, so a missing entry means the dependency was
137        // dropped. When the hash check is armed, a `create_sql` mismatch additionally means the
138        // dependency was concurrently modified between plan time and now.
139        for id in self.dependency_ids.iter() {
140            let Some(entry) = catalog.try_get_entry(id) else {
141                return Err(AdapterError::ConcurrentDependencyDrop {
142                    dependency_kind: "catalog item",
143                    dependency_id: id.to_string(),
144                });
145            };
146            if self.check_dependency_hashes {
147                if let Some(expected) = self.dependency_hashes.get(id) {
148                    let current = hash_create_sql(entry.create_sql());
149                    if current != *expected {
150                        return Err(AdapterError::ConcurrentDependencyMutation {
151                            dependency_id: id.to_string(),
152                        });
153                    }
154                }
155            }
156        }
157        if catalog
158            .try_get_role(&self.role_metadata.current_role)
159            .is_none()
160        {
161            return Err(AdapterError::Unauthorized(
162                UnauthorizedError::ConcurrentRoleDrop(self.role_metadata.current_role.clone()),
163            ));
164        }
165        if catalog
166            .try_get_role(&self.role_metadata.session_role)
167            .is_none()
168        {
169            return Err(AdapterError::Unauthorized(
170                UnauthorizedError::ConcurrentRoleDrop(self.role_metadata.session_role.clone()),
171            ));
172        }
173
174        if catalog
175            .try_get_role(&self.role_metadata.authenticated_role)
176            .is_none()
177        {
178            return Err(AdapterError::Unauthorized(
179                UnauthorizedError::ConcurrentRoleDrop(
180                    self.role_metadata.authenticated_role.clone(),
181                ),
182            ));
183        }
184        self.transient_revision = catalog.transient_revision();
185        Ok(())
186    }
187}
188
189/// Returns the hash of `id`'s `create_sql`, or `None` if the catalog has no such entry.
190fn hash_item_create_sql(catalog: &Catalog, id: CatalogItemId) -> Option<u64> {
191    catalog
192        .try_get_entry(&id)
193        .map(|entry| hash_create_sql(entry.create_sql()))
194}
195
196fn hash_create_sql(sql: &str) -> u64 {
197    let mut h = std::collections::hash_map::DefaultHasher::new();
198    sql.hash(&mut h);
199    h.finish()
200}
201
202#[cfg(test)]
203mod tests {
204    use std::collections::BTreeSet;
205
206    use mz_adapter_types::connection::ConnectionId;
207    use mz_auth::AuthenticatorKind;
208    use mz_cluster_client::ReplicaId;
209    use mz_controller_types::ClusterId;
210    use mz_ore::metrics::MetricsRegistry;
211    use mz_ore::{assert_contains, assert_ok};
212    use mz_repr::CatalogItemId;
213    use mz_repr::role_id::RoleId;
214    use mz_sql::catalog::RoleAttributesRaw;
215    use mz_sql::session::metadata::SessionMetadata;
216    use uuid::Uuid;
217
218    use crate::AdapterError;
219    use crate::catalog::{Catalog, Op};
220    use crate::coord::validity::PlanValidity;
221    use crate::metrics::Metrics;
222    use crate::session::{Session, SessionConfig};
223
224    #[mz_ore::test(tokio::test)]
225    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
226    async fn test_plan_validity() {
227        Catalog::with_debug(|mut catalog| async move {
228            let conn_id = ConnectionId::Static(1);
229            let user = String::from("validity_user");
230            let role = "validity_role";
231            let metrics_registry = MetricsRegistry::new();
232            let metrics = Metrics::register_into(&metrics_registry);
233
234            let commit_ts = catalog.current_upper().await;
235            catalog
236                .transact(
237                    None,
238                    commit_ts,
239                    None,
240                    vec![Op::CreateRole {
241                        name: role.into(),
242                        attributes: RoleAttributesRaw::new(),
243                    }],
244                )
245                .await
246                .expect("is ok");
247            let role = catalog.try_get_role_by_name(role).expect("must exist");
248            // Can't use a dummy session because we need a valid role for the validity check.
249            let mut session = Session::new(
250                &mz_build_info::DUMMY_BUILD_INFO,
251                SessionConfig {
252                    conn_id,
253                    uuid: Uuid::new_v4(),
254                    user,
255                    client_ip: None,
256                    external_metadata_rx: None,
257                    helm_chart_version: None,
258                    authenticator_kind: AuthenticatorKind::None,
259                    groups: None,
260                },
261                metrics.session_metrics(),
262            );
263            session.initialize_role_metadata(role.id);
264            let mut empty = PlanValidity::new(
265                &catalog,
266                BTreeSet::new(),
267                None,
268                None,
269                session.role_metadata().clone(),
270            );
271            // Push the revision back by 1 so check() takes the slow path instead of returning
272            // early on revision equality.
273            empty.transient_revision = empty
274                .transient_revision
275                .checked_sub(1)
276                .expect("must subtract");
277            let some_system_cluster = catalog
278                .clusters()
279                .find(|c| matches!(c.id, ClusterId::System(_)))
280                .expect("must exist");
281
282            // Plan generation and result assertion closures.
283            let tests: &[(
284                Box<dyn Fn(&mut PlanValidity, &Catalog)>,
285                Box<dyn Fn(Result<(), AdapterError>)>,
286            )] = &[
287                (
288                    Box::new(|_validity, _catalog| {}),
289                    Box::new(|res| assert_ok!(res)),
290                ),
291                (
292                    Box::new(|validity, _catalog| {
293                        validity.cluster_id = Some(ClusterId::user(3).expect("3 is a valid ID"));
294                    }),
295                    Box::new(|res| {
296                        assert_contains!(
297                            res.expect_err("must err").to_string(),
298                            "cluster 'u3' was dropped"
299                        )
300                    }),
301                ),
302                (
303                    Box::new(|validity, _catalog| {
304                        validity.cluster_id = Some(some_system_cluster.id);
305                        validity.replica_id = Some(ReplicaId::User(4));
306                    }),
307                    Box::new(|res| {
308                        assert_contains!(
309                            res.expect_err("must err").to_string(),
310                            format!(
311                                "cluster replica 'u4 of cluster {}' was dropped",
312                                some_system_cluster.id
313                            ),
314                        )
315                    }),
316                ),
317                (
318                    Box::new(|validity, catalog| {
319                        validity.extend_dependencies(catalog, vec![CatalogItemId::User(6)]);
320                    }),
321                    Box::new(|res| {
322                        assert_contains!(
323                            res.expect_err("must err").to_string(),
324                            "catalog item 'u6' was dropped"
325                        )
326                    }),
327                ),
328                (
329                    Box::new(|validity, _catalog| {
330                        validity.role_metadata.current_role = RoleId::User(5);
331                    }),
332                    Box::new(|res| {
333                        assert_contains!(
334                            res.expect_err("must err").to_string(),
335                            "role u5 was concurrently dropped"
336                        )
337                    }),
338                ),
339                (
340                    Box::new(|validity, _catalog| {
341                        validity.role_metadata.session_role = RoleId::User(5);
342                    }),
343                    Box::new(|res| {
344                        assert_contains!(
345                            res.expect_err("must err").to_string(),
346                            "role u5 was concurrently dropped"
347                        )
348                    }),
349                ),
350                (
351                    Box::new(|validity, _catalog| {
352                        validity.role_metadata.authenticated_role = RoleId::User(5);
353                    }),
354                    Box::new(|res| {
355                        assert_contains!(
356                            res.expect_err("must err").to_string(),
357                            "role u5 was concurrently dropped"
358                        )
359                    }),
360                ),
361            ];
362            for (get_validity, check_res) in tests {
363                let mut validity = empty.clone();
364                get_validity(&mut validity, &catalog);
365                let res = validity.check(&catalog);
366                check_res(res);
367            }
368
369            // Pick any existing catalog entry to stand in as a "live" dependency. A real id is
370            // required so `check`'s drop probe (`try_get_entry`) succeeds and we exercise the
371            // hash branch.
372            let live_id = catalog
373                .entries()
374                .next()
375                .expect("debug catalog must have at least one entry")
376                .id();
377
378            // Armed + hash mismatch → ConcurrentDependencyMutation.
379            let mut armed = PlanValidity::new(
380                &catalog,
381                BTreeSet::from_iter(std::iter::once(live_id)),
382                None,
383                None,
384                session.role_metadata().clone(),
385            )
386            .with_dependency_hash_check(&catalog);
387            armed.transient_revision = armed
388                .transient_revision
389                .checked_sub(1)
390                .expect("must subtract");
391            // Corrupt the captured hash to simulate a concurrent mutation of the dependency's
392            // `create_sql`.
393            armed.dependency_hashes.insert(live_id, u64::MAX);
394            assert_contains!(
395                armed.check(&catalog).expect_err("must err").to_string(),
396                "was concurrently modified"
397            );
398
399            // Unarmed (default) + same hash mismatch → ignored, returns Ok. Proves the opt-in
400            // gate keeps the check off the shared read-only paths.
401            let mut unarmed = PlanValidity::new(
402                &catalog,
403                BTreeSet::from_iter(std::iter::once(live_id)),
404                None,
405                None,
406                session.role_metadata().clone(),
407            );
408            unarmed.transient_revision = unarmed
409                .transient_revision
410                .checked_sub(1)
411                .expect("must subtract");
412            // Stuff a bogus hash in. The flag is off, so `check` must not look at it.
413            unarmed.dependency_hashes.insert(live_id, u64::MAX);
414            assert_ok!(unarmed.check(&catalog));
415        })
416        .await
417    }
418}