mirror of
https://gitlab.com/gitlab-org/gitlab-foss.git
synced 2025-08-03 16:04:30 +00:00
202 lines
8.0 KiB
Ruby
202 lines
8.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Gitlab
|
|
module Database
|
|
module QueryAnalyzers
|
|
class PreventCrossDatabaseModification < Database::QueryAnalyzers::Base
|
|
CrossDatabaseModificationAcrossUnsupportedTablesError = Class.new(QueryAnalyzerError)
|
|
QUERY_LIMIT = 10
|
|
|
|
# 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
|
|
|
|
# This method will temporary ignore the given tables in a current transaction
|
|
# This is meant to disable `PreventCrossDB` check for some well known failures
|
|
def self.temporary_ignore_tables_in_transaction(tables, url:, &blk)
|
|
return yield unless context&.dig(:ignored_tables)
|
|
|
|
begin
|
|
prev_ignored_tables = context[:ignored_tables]
|
|
context[:ignored_tables] = prev_ignored_tables + tables.map(&:to_s)
|
|
yield
|
|
ensure
|
|
context[:ignored_tables] = prev_ignored_tables
|
|
end
|
|
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 },
|
|
ignored_tables: [],
|
|
queries: []
|
|
})
|
|
end
|
|
|
|
def self.enabled?
|
|
::Feature::FlipperFeature.table_exists? &&
|
|
Feature.enabled?(:detect_cross_database_modification, type: :ops)
|
|
end
|
|
|
|
def self.requires_tracking?(parsed)
|
|
# The transaction boundaries always needs to be tracked regardless of suppress behavior
|
|
self.transaction_begin?(parsed) || self.transaction_end?(parsed)
|
|
end
|
|
|
|
# rubocop:disable Metrics/AbcSize
|
|
def self.analyze(parsed)
|
|
# This analyzer requires the PgQuery parsed query to be present
|
|
return unless parsed.pg
|
|
|
|
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 self.transaction_begin?(parsed)
|
|
context[:transaction_depth_by_db][database] += 1
|
|
|
|
return
|
|
elsif self.transaction_end?(parsed)
|
|
context[:transaction_depth_by_db][database] -= 1
|
|
if context[:transaction_depth_by_db][database] == 0
|
|
context[:modified_tables_by_db][database].clear
|
|
clear_queries
|
|
|
|
# Attempt to troubleshoot https://gitlab.com/gitlab-org/gitlab/-/issues/351531
|
|
::CrossDatabaseModification::TransactionStackTrackRecord.log_gitlab_transactions_stack(action: :end_of_transaction)
|
|
elsif context[:transaction_depth_by_db][database] < 0
|
|
context[:transaction_depth_by_db][database] = 0
|
|
raise CrossDatabaseModificationAcrossUnsupportedTablesError, "Misaligned cross-DB transactions discovered at query #{sql}. This could be a bug in #{self.class} or a valid issue to investigate. Read more at https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-cross-database-transactions ."
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
return unless self.in_transaction?
|
|
return if Thread.current[:factory_bot_objects] && Thread.current[:factory_bot_objects] > 0
|
|
|
|
# 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]
|
|
|
|
# Ignore some tables
|
|
tables -= context[:ignored_tables].to_a
|
|
|
|
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']
|
|
|
|
add_to_queries(sql)
|
|
context[:modified_tables_by_db][database].merge(tables)
|
|
all_tables = context[:modified_tables_by_db].values.flat_map(&:to_a)
|
|
schemas = ::Gitlab::Database::GitlabSchema.table_schemas!(all_tables)
|
|
schemas += ApplicationRecord.gitlab_transactions_stack
|
|
|
|
unless ::Gitlab::Database::GitlabSchema.cross_transactions_allowed?(schemas, all_tables)
|
|
messages = []
|
|
messages << "Cross-database data modification of '#{schemas.to_a.join(', ')}' were detected within " \
|
|
"a transaction modifying the '#{all_tables.to_a.join(', ')}' tables. "
|
|
messages << "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."
|
|
messages += cleaned_queries
|
|
|
|
raise CrossDatabaseModificationAcrossUnsupportedTablesError, messages.join("\n\n")
|
|
end
|
|
rescue CrossDatabaseModificationAcrossUnsupportedTablesError => e
|
|
::Gitlab::ErrorTracking.track_exception(e, { gitlab_schemas: schemas, tables: all_tables, query: parsed.sql })
|
|
raise if dev_or_test_env?
|
|
end
|
|
# rubocop:enable Metrics/AbcSize
|
|
|
|
def self.transaction_begin?(parsed)
|
|
# We ignore BEGIN or START in tests
|
|
unless Rails.env.test?
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_BEGIN)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_START)
|
|
end
|
|
|
|
# SAVEPOINT
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_SAVEPOINT)
|
|
|
|
false
|
|
end
|
|
|
|
def self.transaction_end?(parsed)
|
|
# We ignore ROLLBACK or COMMIT in tests
|
|
unless Rails.env.test?
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_COMMIT)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_COMMIT_PREPARED)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_ROLLBACK)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_ROLLBACK_PREPARED)
|
|
end
|
|
|
|
# RELEASE (SAVEPOINT) or ROLLBACK TO (SAVEPOINT)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_RELEASE)
|
|
return true if transaction_stmt?(parsed, :TRANS_STMT_ROLLBACK_TO)
|
|
|
|
false
|
|
end
|
|
|
|
# Known kinds: https://github.com/pganalyze/pg_query/blob/f6588703deb9d7a94b87b34b7c3bab240087fbc4/ext/pg_query/include/nodes/parsenodes.h#L3050
|
|
def self.transaction_stmt?(parsed, kind)
|
|
parsed.pg.tree.stmts.map(&:stmt).any? do |stmt|
|
|
stmt.node == :transaction_stmt && stmt.transaction_stmt.kind == kind
|
|
end
|
|
end
|
|
|
|
def self.dev_or_test_env?
|
|
Gitlab.dev_or_test_env?
|
|
end
|
|
|
|
def self.clear_queries
|
|
return unless dev_or_test_env?
|
|
|
|
context[:queries].clear
|
|
end
|
|
|
|
def self.add_to_queries(sql)
|
|
return unless dev_or_test_env?
|
|
|
|
context[:queries].push(sql)
|
|
end
|
|
|
|
def self.cleaned_queries
|
|
return [] unless dev_or_test_env?
|
|
|
|
context[:queries].last(QUERY_LIMIT).each_with_index.map do |sql, i|
|
|
"#{i}: #{sql}"
|
|
end
|
|
end
|
|
|
|
def self.in_transaction?
|
|
context[:transaction_depth_by_db].values.any?(&:positive?)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|