1use 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#[derive(Debug, Clone)]
45pub struct PlanValidity {
46 transient_revision: u64,
48 dependency_ids: BTreeSet<CatalogItemId>,
50 dependency_hashes: BTreeMap<CatalogItemId, u64>,
54 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 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 pub fn check(&mut self, catalog: &Catalog) -> Result<(), AdapterError> {
114 if self.transient_revision == catalog.transient_revision() {
115 return Ok(());
116 }
117 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 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
189fn 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)] 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 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 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 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 let live_id = catalog
373 .entries()
374 .next()
375 .expect("debug catalog must have at least one entry")
376 .id();
377
378 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 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 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 unarmed.dependency_hashes.insert(live_id, u64::MAX);
414 assert_ok!(unarmed.check(&catalog));
415 })
416 .await
417 }
418}