diff options
Diffstat (limited to 'lib/gitlab/database/query_analyzers/prevent_cross_database_modification.rb')
-rw-r--r-- | lib/gitlab/database/query_analyzers/prevent_cross_database_modification.rb | 119 |
1 files changed, 119 insertions, 0 deletions
diff --git a/lib/gitlab/database/query_analyzers/prevent_cross_database_modification.rb b/lib/gitlab/database/query_analyzers/prevent_cross_database_modification.rb new file mode 100644 index 00000000000..2233f3c4646 --- /dev/null +++ b/lib/gitlab/database/query_analyzers/prevent_cross_database_modification.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module QueryAnalyzers + class PreventCrossDatabaseModification < Database::QueryAnalyzers::Base + CrossDatabaseModificationAcrossUnsupportedTablesError = Class.new(StandardError) + + # This method will allow cross database modifications within the block + # Example: + # + # allow_cross_database_modification_within_transaction(url: 'url-to-an-issue') do + # create(:build) # inserts ci_build and project record in one transaction + # end + def self.allow_cross_database_modification_within_transaction(url:, &blk) + self.with_suppressed(true, &blk) + end + + # This method will prevent cross database modifications within the block + # if it was allowed previously + def self.with_cross_database_modification_prevented(&blk) + self.with_suppressed(false, &blk) + end + + def self.begin! + super + + context.merge!({ + transaction_depth_by_db: Hash.new { |h, k| h[k] = 0 }, + modified_tables_by_db: Hash.new { |h, k| h[k] = Set.new } + }) + end + + def self.enabled? + ::Feature::FlipperFeature.table_exists? && + Feature.enabled?(:detect_cross_database_modification, default_enabled: :yaml) + end + + # rubocop:disable Metrics/AbcSize + def self.analyze(parsed) + return if in_factory_bot_create? + + database = ::Gitlab::Database.db_config_name(parsed.connection) + sql = parsed.sql + + # We ignore BEGIN in tests as this is the outer transaction for + # DatabaseCleaner + if sql.start_with?('SAVEPOINT') || (!Rails.env.test? && sql.start_with?('BEGIN')) + context[:transaction_depth_by_db][database] += 1 + + return + elsif sql.start_with?('RELEASE SAVEPOINT', 'ROLLBACK TO SAVEPOINT') || (!Rails.env.test? && sql.start_with?('ROLLBACK', 'COMMIT')) + context[:transaction_depth_by_db][database] -= 1 + if context[:transaction_depth_by_db][database] <= 0 + context[:modified_tables_by_db][database].clear + end + + return + end + + return if context[:transaction_depth_by_db].values.all?(&:zero?) + + # PgQuery might fail in some cases due to limited nesting: + # https://github.com/pganalyze/pg_query/issues/209 + tables = sql.downcase.include?(' for update') ? parsed.pg.tables : parsed.pg.dml_tables + + # We have some code where plans and gitlab_subscriptions are lazily + # created and this causes lots of spec failures + # https://gitlab.com/gitlab-org/gitlab/-/issues/343394 + tables -= %w[plans gitlab_subscriptions] + + return if tables.empty? + + # All migrations will write to schema_migrations in the same transaction. + # It's safe to ignore this since schema_migrations exists in all + # databases + return if tables == ['schema_migrations'] + + context[:modified_tables_by_db][database].merge(tables) + all_tables = context[:modified_tables_by_db].values.map(&:to_a).flatten + schemas = ::Gitlab::Database::GitlabSchema.table_schemas(all_tables) + + if schemas.many? + message = "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \ + "a transaction modifying the '#{all_tables.to_a.join(", ")}' tables." \ + "Please refer to https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-cross-database-transactions for details on how to resolve this exception." + + if schemas.any? { |s| s.to_s.start_with?("undefined") } + message += " The gitlab_schema was undefined for one or more of the tables in this transaction. Any new tables must be added to lib/gitlab/database/gitlab_schemas.yml ." + end + + raise CrossDatabaseModificationAcrossUnsupportedTablesError, message + end + rescue CrossDatabaseModificationAcrossUnsupportedTablesError => e + ::Gitlab::ErrorTracking.track_exception(e, { gitlab_schemas: schemas, tables: all_tables, query: parsed.sql }) + raise if raise_exception? + end + # rubocop:enable Metrics/AbcSize + + # We only raise in tests for now otherwise some features will be broken + # in development. For now we've mostly only added allowlist based on + # spec names. Until we have allowed all the violations inline we don't + # want to raise in development. + def self.raise_exception? + Rails.env.test? + end + + # We ignore execution in the #create method from FactoryBot + # because it is not representative of real code we run in + # production. There are far too many false positives caused + # by instantiating objects in different `gitlab_schema` in a + # FactoryBot `create`. + def self.in_factory_bot_create? + Rails.env.test? && caller_locations.any? { |l| l.path.end_with?('lib/factory_bot/evaluation.rb') && l.label == 'create' } + end + end + end + end +end |