Skip to main content

mz_adapter/catalog/
limits.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;
11
12use mz_adapter_types::connection::ConnectionId;
13use mz_catalog::memory::objects::CatalogItem;
14use mz_controller::clusters::{ManagedReplicaLocation, ReplicaLocation};
15use mz_controller_types::ClusterId;
16use mz_ore::soft_panic_or_log;
17use mz_repr::adt::numeric::Numeric;
18use mz_sql::names::ResolvedDatabaseSpecifier;
19use mz_sql::plan::ConnectionDetails;
20use mz_sql::session::vars::{
21    MAX_AWS_PRIVATELINK_CONNECTIONS, MAX_CLUSTERS, MAX_CREDIT_CONSUMPTION_RATE, MAX_DATABASES,
22    MAX_KAFKA_CONNECTIONS, MAX_MATERIALIZED_VIEWS, MAX_MYSQL_CONNECTIONS, MAX_NETWORK_POLICIES,
23    MAX_OBJECTS_PER_SCHEMA, MAX_POSTGRES_CONNECTIONS, MAX_REPLICAS_PER_CLUSTER, MAX_ROLES,
24    MAX_SCHEMAS_PER_DATABASE, MAX_SECRETS, MAX_SINKS, MAX_SOURCES, MAX_SQL_SERVER_CONNECTIONS,
25    MAX_TABLES, SystemVars, Var,
26};
27
28use crate::AdapterError;
29use crate::catalog::{CatalogState, DropObjectInfo, Op};
30
31impl CatalogState {
32    /// Validate all resource limits in a catalog transaction and return an error if that limit is
33    /// exceeded.
34    pub(crate) fn validate_resource_limits(
35        &self,
36        ops: &[Op],
37        conn_id: &ConnectionId,
38    ) -> Result<(), AdapterError> {
39        let mut new_kafka_connections = 0;
40        let mut new_postgres_connections = 0;
41        let mut new_mysql_connections = 0;
42        let mut new_sql_server_connections = 0;
43        let mut new_aws_privatelink_connections = 0;
44        let mut new_tables = 0;
45        let mut new_sources = 0;
46        let mut new_sinks = 0;
47        let mut new_materialized_views = 0;
48        let mut new_clusters = 0;
49        let mut new_replicas_per_cluster = BTreeMap::new();
50        let mut new_credit_consumption_rate = Numeric::zero();
51        let mut new_databases = 0;
52        let mut new_schemas_per_database = BTreeMap::new();
53        let mut new_objects_per_schema = BTreeMap::new();
54        let mut new_secrets = 0;
55        let mut new_roles = 0;
56        let mut new_network_policies = 0;
57        for op in ops {
58            match op {
59                Op::CreateDatabase { .. } => {
60                    new_databases += 1;
61                }
62                Op::CreateSchema { database_id, .. } => {
63                    if let ResolvedDatabaseSpecifier::Id(database_id) = database_id {
64                        *new_schemas_per_database.entry(database_id).or_insert(0) += 1;
65                    }
66                }
67                Op::CreateRole { .. } => {
68                    new_roles += 1;
69                }
70                Op::CreateNetworkPolicy { .. } => {
71                    new_network_policies += 1;
72                }
73                Op::CreateCluster { .. } => {
74                    // TODO(benesch): having deprecated linked clusters, remove
75                    // the `max_sources` and `max_sinks` limit, and set a higher
76                    // max cluster limit?
77                    new_clusters += 1;
78                }
79                Op::CreateClusterReplica {
80                    cluster_id, config, ..
81                } => {
82                    if cluster_id.is_user() {
83                        *new_replicas_per_cluster.entry(*cluster_id).or_insert(0) += 1;
84                        if let ReplicaLocation::Managed(location) = &config.location {
85                            new_credit_consumption_rate += self.replica_credits_per_hour(location);
86                        }
87                    }
88                }
89                Op::CreateItem { name, item, .. } => {
90                    *new_objects_per_schema
91                        .entry((
92                            name.qualifiers.database_spec.clone(),
93                            name.qualifiers.schema_spec.clone(),
94                        ))
95                        .or_insert(0) += 1;
96                    match item {
97                        CatalogItem::Connection(connection) => match connection.details {
98                            ConnectionDetails::Kafka(_) => new_kafka_connections += 1,
99                            ConnectionDetails::Postgres(_) => new_postgres_connections += 1,
100                            ConnectionDetails::MySql(_) => new_mysql_connections += 1,
101                            ConnectionDetails::SqlServer(_) => new_sql_server_connections += 1,
102                            ConnectionDetails::AwsPrivatelink(_) => {
103                                new_aws_privatelink_connections += 1
104                            }
105                            ConnectionDetails::Csr(_)
106                            | ConnectionDetails::GlueSchemaRegistry(_)
107                            | ConnectionDetails::Ssh { .. }
108                            | ConnectionDetails::Aws(_)
109                            | ConnectionDetails::Gcp(_)
110                            | ConnectionDetails::IcebergCatalog(_) => {}
111                        },
112                        CatalogItem::Table(_) => {
113                            new_tables += 1;
114                        }
115                        CatalogItem::Source(source) => {
116                            new_sources += source.user_controllable_persist_shard_count()
117                        }
118                        CatalogItem::Sink(_) => new_sinks += 1,
119                        CatalogItem::MaterializedView(_) => {
120                            new_materialized_views += 1;
121                        }
122                        CatalogItem::Secret(_) => {
123                            new_secrets += 1;
124                        }
125                        CatalogItem::Log(_)
126                        | CatalogItem::View(_)
127                        | CatalogItem::Index(_)
128                        | CatalogItem::Type(_)
129                        | CatalogItem::Func(_)
130                        | CatalogItem::MetricSink(_) => {}
131                    }
132                }
133                Op::DropObjects(drop_object_infos) => {
134                    for drop_object_info in drop_object_infos {
135                        match drop_object_info {
136                            DropObjectInfo::Cluster(_) => {
137                                new_clusters -= 1;
138                            }
139                            DropObjectInfo::ClusterReplica((cluster_id, replica_id, _reason)) => {
140                                if cluster_id.is_user() {
141                                    *new_replicas_per_cluster.entry(*cluster_id).or_insert(0) -= 1;
142                                    let cluster =
143                                        self.get_cluster_replica(*cluster_id, *replica_id);
144                                    if let ReplicaLocation::Managed(location) =
145                                        &cluster.config.location
146                                    {
147                                        new_credit_consumption_rate -=
148                                            self.replica_credits_per_hour(location);
149                                    }
150                                }
151                            }
152                            DropObjectInfo::Database(_) => {
153                                new_databases -= 1;
154                            }
155                            DropObjectInfo::Schema((database_spec, _)) => {
156                                if let ResolvedDatabaseSpecifier::Id(database_id) = database_spec {
157                                    *new_schemas_per_database.entry(database_id).or_insert(0) -= 1;
158                                }
159                            }
160                            DropObjectInfo::Role(_) => {
161                                new_roles -= 1;
162                            }
163                            DropObjectInfo::NetworkPolicy(_) => {
164                                new_network_policies -= 1;
165                            }
166                            DropObjectInfo::Item(id) => {
167                                let entry = self.get_entry(id);
168                                *new_objects_per_schema
169                                    .entry((
170                                        entry.name().qualifiers.database_spec.clone(),
171                                        entry.name().qualifiers.schema_spec.clone(),
172                                    ))
173                                    .or_insert(0) -= 1;
174                                match entry.item() {
175                                    CatalogItem::Connection(connection) => match connection.details
176                                    {
177                                        ConnectionDetails::AwsPrivatelink(_) => {
178                                            new_aws_privatelink_connections -= 1;
179                                        }
180                                        _ => (),
181                                    },
182                                    CatalogItem::Table(_) => {
183                                        new_tables -= 1;
184                                    }
185                                    CatalogItem::Source(source) => {
186                                        new_sources -=
187                                            source.user_controllable_persist_shard_count()
188                                    }
189                                    CatalogItem::Sink(_) => new_sinks -= 1,
190                                    CatalogItem::MaterializedView(_) => {
191                                        new_materialized_views -= 1;
192                                    }
193                                    CatalogItem::Secret(_) => {
194                                        new_secrets -= 1;
195                                    }
196                                    CatalogItem::Log(_)
197                                    | CatalogItem::View(_)
198                                    | CatalogItem::Index(_)
199                                    | CatalogItem::Type(_)
200                                    | CatalogItem::Func(_)
201                                    | CatalogItem::MetricSink(_) => {}
202                                }
203                            }
204                        }
205                    }
206                }
207                Op::UpdateItem {
208                    name: _,
209                    id,
210                    to_item,
211                } => match to_item {
212                    CatalogItem::Source(source) => {
213                        let current_source = self
214                            .get_entry(id)
215                            .source()
216                            .expect("source update is for source item");
217
218                        new_sources += source.user_controllable_persist_shard_count()
219                            - current_source.user_controllable_persist_shard_count();
220                    }
221                    CatalogItem::Connection(_)
222                    | CatalogItem::Table(_)
223                    | CatalogItem::Sink(_)
224                    | CatalogItem::MaterializedView(_)
225                    | CatalogItem::Secret(_)
226                    | CatalogItem::Log(_)
227                    | CatalogItem::View(_)
228                    | CatalogItem::Index(_)
229                    | CatalogItem::Type(_)
230                    | CatalogItem::Func(_)
231                    | CatalogItem::MetricSink(_) => {}
232                },
233                Op::AlterRole { .. }
234                | Op::AlterRetainHistory { .. }
235                | Op::AlterSourceTimestampInterval { .. }
236                | Op::AlterNetworkPolicy { .. }
237                | Op::AlterAddColumn { .. }
238                | Op::AlterMaterializedViewApplyReplacement { .. }
239                | Op::UpdatePrivilege { .. }
240                | Op::UpdateDefaultPrivilege { .. }
241                | Op::GrantRole { .. }
242                | Op::RenameCluster { .. }
243                | Op::RenameClusterReplica { .. }
244                | Op::RenameItem { .. }
245                | Op::RenameSchema { .. }
246                | Op::UpdateOwner { .. }
247                | Op::RevokeRole { .. }
248                | Op::UpdateClusterConfig { .. }
249                | Op::UpdateSourceReferences { .. }
250                | Op::UpdateSystemConfiguration { .. }
251                | Op::ResetSystemConfiguration { .. }
252                | Op::ResetAllSystemConfiguration { .. }
253                | Op::UpdateScopedSystemParameters { .. }
254                | Op::Comment { .. }
255                | Op::RenameCommand(_)
256                | Op::CheckClusterState { .. }
257                | Op::InjectAuditEvents { .. } => {}
258            }
259        }
260
261        let mut current_aws_privatelink_connections = 0;
262        let mut current_postgres_connections = 0;
263        let mut current_mysql_connections = 0;
264        let mut current_sql_server_connections = 0;
265        let mut current_kafka_connections = 0;
266        for c in self.user_connections() {
267            let connection = c
268                .connection()
269                .expect("`user_connections()` only returns connection objects");
270
271            match connection.details {
272                ConnectionDetails::AwsPrivatelink(_) => current_aws_privatelink_connections += 1,
273                ConnectionDetails::Postgres(_) => current_postgres_connections += 1,
274                ConnectionDetails::MySql(_) => current_mysql_connections += 1,
275                ConnectionDetails::SqlServer(_) => current_sql_server_connections += 1,
276                ConnectionDetails::Kafka(_) => current_kafka_connections += 1,
277                ConnectionDetails::Csr(_)
278                | ConnectionDetails::GlueSchemaRegistry(_)
279                | ConnectionDetails::Ssh { .. }
280                | ConnectionDetails::Aws(_)
281                | ConnectionDetails::Gcp(_)
282                | ConnectionDetails::IcebergCatalog(_) => {}
283            }
284        }
285        self.validate_resource_limit(
286            current_kafka_connections,
287            new_kafka_connections,
288            SystemVars::max_kafka_connections,
289            "Kafka Connection",
290            MAX_KAFKA_CONNECTIONS.name(),
291        )?;
292        self.validate_resource_limit(
293            current_postgres_connections,
294            new_postgres_connections,
295            SystemVars::max_postgres_connections,
296            "PostgreSQL Connection",
297            MAX_POSTGRES_CONNECTIONS.name(),
298        )?;
299        self.validate_resource_limit(
300            current_mysql_connections,
301            new_mysql_connections,
302            SystemVars::max_mysql_connections,
303            "MySQL Connection",
304            MAX_MYSQL_CONNECTIONS.name(),
305        )?;
306        self.validate_resource_limit(
307            current_sql_server_connections,
308            new_sql_server_connections,
309            SystemVars::max_sql_server_connections,
310            "SQL Server Connection",
311            MAX_SQL_SERVER_CONNECTIONS.name(),
312        )?;
313        self.validate_resource_limit(
314            current_aws_privatelink_connections,
315            new_aws_privatelink_connections,
316            SystemVars::max_aws_privatelink_connections,
317            "AWS PrivateLink Connection",
318            MAX_AWS_PRIVATELINK_CONNECTIONS.name(),
319        )?;
320        self.validate_resource_limit(
321            self.user_tables().count(),
322            new_tables,
323            SystemVars::max_tables,
324            "table",
325            MAX_TABLES.name(),
326        )?;
327
328        let current_sources: usize = self
329            .user_sources()
330            .filter_map(|source| source.source())
331            .map(|source| source.user_controllable_persist_shard_count())
332            .sum::<i64>()
333            .try_into()
334            .expect("non-negative sum of sources");
335
336        self.validate_resource_limit(
337            current_sources,
338            new_sources,
339            SystemVars::max_sources,
340            "source",
341            MAX_SOURCES.name(),
342        )?;
343        self.validate_resource_limit(
344            self.user_sinks().count(),
345            new_sinks,
346            SystemVars::max_sinks,
347            "sink",
348            MAX_SINKS.name(),
349        )?;
350        self.validate_resource_limit(
351            self.user_materialized_views().count(),
352            new_materialized_views,
353            SystemVars::max_materialized_views,
354            "materialized view",
355            MAX_MATERIALIZED_VIEWS.name(),
356        )?;
357        self.validate_resource_limit(
358            // Linked compute clusters don't count against the limit, since
359            // we have a separate sources and sinks limit.
360            //
361            // TODO(benesch): remove the `max_sources` and `max_sinks` limit,
362            // and set a higher max cluster limit?
363            self.user_clusters().count(),
364            new_clusters,
365            SystemVars::max_clusters,
366            "cluster",
367            MAX_CLUSTERS.name(),
368        )?;
369        for (cluster_id, new_replicas) in new_replicas_per_cluster {
370            // It's possible that the cluster hasn't been created yet.
371            let current_amount = self
372                .try_get_cluster(cluster_id)
373                .map(|instance| instance.user_replicas().count())
374                .unwrap_or(0);
375            self.validate_resource_limit(
376                current_amount,
377                new_replicas,
378                SystemVars::max_replicas_per_cluster,
379                "cluster replica",
380                MAX_REPLICAS_PER_CLUSTER.name(),
381            )?;
382        }
383        self.validate_resource_limit_numeric(
384            self.current_credit_consumption_rate(None),
385            new_credit_consumption_rate,
386            |system_vars| {
387                self.license_key
388                    .max_credit_consumption_rate()
389                    .map_or_else(|| system_vars.max_credit_consumption_rate(), Numeric::from)
390            },
391            "cluster replica",
392            MAX_CREDIT_CONSUMPTION_RATE.name(),
393        )?;
394        self.validate_resource_limit(
395            self.databases().count(),
396            new_databases,
397            SystemVars::max_databases,
398            "database",
399            MAX_DATABASES.name(),
400        )?;
401        for (database_id, new_schemas) in new_schemas_per_database {
402            self.validate_resource_limit(
403                self.get_database(database_id).schemas_by_id.len(),
404                new_schemas,
405                SystemVars::max_schemas_per_database,
406                "schema",
407                MAX_SCHEMAS_PER_DATABASE.name(),
408            )?;
409        }
410        for ((database_spec, schema_spec), new_objects) in new_objects_per_schema {
411            // For temporary schemas that don't exist yet (lazy creation),
412            // treat them as having 0 items.
413            let current_items = self
414                .try_get_schema(&database_spec, &schema_spec, conn_id)
415                .map(|schema| schema.items.len())
416                .unwrap_or(0);
417            self.validate_resource_limit(
418                current_items,
419                new_objects,
420                SystemVars::max_objects_per_schema,
421                "object",
422                MAX_OBJECTS_PER_SCHEMA.name(),
423            )?;
424        }
425        self.validate_resource_limit(
426            self.user_secrets().count(),
427            new_secrets,
428            SystemVars::max_secrets,
429            "secret",
430            MAX_SECRETS.name(),
431        )?;
432        self.validate_resource_limit(
433            self.user_roles().count(),
434            new_roles,
435            SystemVars::max_roles,
436            "role",
437            MAX_ROLES.name(),
438        )?;
439        self.validate_resource_limit(
440            self.user_network_policies().count(),
441            new_network_policies,
442            SystemVars::max_network_policies,
443            "network_policy",
444            MAX_NETWORK_POLICIES.name(),
445        )?;
446        Ok(())
447    }
448
449    /// Validate a specific type of resource limit and return an error if that limit is exceeded.
450    pub(crate) fn validate_resource_limit<F>(
451        &self,
452        current_amount: usize,
453        new_instances: i64,
454        resource_limit: F,
455        resource_type: &str,
456        limit_name: &str,
457    ) -> Result<(), AdapterError>
458    where
459        F: Fn(&SystemVars) -> u32,
460    {
461        if new_instances <= 0 {
462            return Ok(());
463        }
464
465        let limit: i64 = resource_limit(self.system_config()).into();
466        let current_amount: Option<i64> = current_amount.try_into().ok();
467        let desired =
468            current_amount.and_then(|current_amount| current_amount.checked_add(new_instances));
469
470        let exceeds_limit = if let Some(desired) = desired {
471            desired > limit
472        } else {
473            true
474        };
475
476        let desired = desired
477            .map(|desired| desired.to_string())
478            .unwrap_or_else(|| format!("more than {}", i64::MAX));
479        let current = current_amount
480            .map(|current| current.to_string())
481            .unwrap_or_else(|| format!("more than {}", i64::MAX));
482        if exceeds_limit {
483            Err(AdapterError::ResourceExhaustion {
484                resource_type: resource_type.to_string(),
485                limit_name: limit_name.to_string(),
486                desired,
487                limit: limit.to_string(),
488                current,
489            })
490        } else {
491            Ok(())
492        }
493    }
494
495    /// Validate a specific type of float resource limit and return an error if that limit is exceeded.
496    ///
497    /// This is very similar to [`Self::validate_resource_limit`] but for numerics.
498    pub(crate) fn validate_resource_limit_numeric<F>(
499        &self,
500        current_amount: Numeric,
501        new_amount: Numeric,
502        resource_limit: F,
503        resource_type: &str,
504        limit_name: &str,
505    ) -> Result<(), AdapterError>
506    where
507        F: Fn(&SystemVars) -> Numeric,
508    {
509        if new_amount <= Numeric::zero() {
510            return Ok(());
511        }
512
513        let limit = resource_limit(self.system_config());
514        // Floats will overflow to infinity instead of panicking, which has the correct comparison
515        // semantics.
516        // NaN should be impossible here since both values are positive.
517        let desired = current_amount + new_amount;
518        if desired > limit {
519            Err(AdapterError::ResourceExhaustion {
520                resource_type: resource_type.to_string(),
521                limit_name: limit_name.to_string(),
522                desired: desired.to_string(),
523                limit: limit.to_string(),
524                current: current_amount.to_string(),
525            })
526        } else {
527            Ok(())
528        }
529    }
530    /// The environment's current credit consumption rate, summed over all user
531    /// cluster replicas except those of `exclude_cluster`.
532    pub(crate) fn current_credit_consumption_rate(
533        &self,
534        exclude_cluster: Option<ClusterId>,
535    ) -> Numeric {
536        self.user_cluster_replicas()
537            .filter(|replica| Some(replica.cluster_id) != exclude_cluster)
538            .filter_map(|replica| match &replica.config.location {
539                ReplicaLocation::Managed(location) => Some(self.replica_credits_per_hour(location)),
540                ReplicaLocation::Unmanaged(_) => None,
541            })
542            .sum()
543    }
544
545    /// The credit rate of a managed replica, read from the size map by its billing size.
546    ///
547    /// An unknown billing size counts as free. DDL validates `SIZE` and `BILLED AS` against
548    /// the map at replica creation, but the map is external configuration and can lose a
549    /// size later. That case is a soft panic rather than a hard one, so that in production
550    /// such a replica can still be dropped from SQL.
551    pub(crate) fn replica_credits_per_hour(&self, location: &ManagedReplicaLocation) -> Numeric {
552        let size = location.size_for_billing();
553        match self.cluster_replica_sizes().0.get(size) {
554            Some(allocation) => allocation.credits_per_hour,
555            None => {
556                soft_panic_or_log!(
557                    "replica of size {:?} bills as unknown replica size {:?}, counting it as free",
558                    location.size,
559                    size,
560                );
561                Numeric::zero()
562            }
563        }
564    }
565}