123 lines
4.7 KiB
Ruby
123 lines
4.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Database
|
|
module PreventCrossDatabaseModification
|
|
CrossDatabaseModificationAcrossUnsupportedTablesError = Class.new(StandardError)
|
|
|
|
module GitlabDatabaseMixin
|
|
def allow_cross_database_modification_within_transaction(url:)
|
|
cross_database_context = Database::PreventCrossDatabaseModification.cross_database_context
|
|
return yield unless cross_database_context && cross_database_context[:enabled]
|
|
|
|
transaction_tracker_enabled_was = cross_database_context[:enabled]
|
|
cross_database_context[:enabled] = false
|
|
|
|
yield
|
|
ensure
|
|
cross_database_context[:enabled] = transaction_tracker_enabled_was if cross_database_context
|
|
end
|
|
end
|
|
|
|
module SpecHelpers
|
|
def with_cross_database_modification_prevented
|
|
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |name, start, finish, id, payload|
|
|
PreventCrossDatabaseModification.prevent_cross_database_modification!(payload[:connection], payload[:sql])
|
|
end
|
|
|
|
PreventCrossDatabaseModification.reset_cross_database_context!
|
|
PreventCrossDatabaseModification.cross_database_context.merge!(enabled: true, subscriber: subscriber)
|
|
|
|
yield if block_given?
|
|
ensure
|
|
cleanup_with_cross_database_modification_prevented if block_given?
|
|
end
|
|
|
|
def cleanup_with_cross_database_modification_prevented
|
|
if PreventCrossDatabaseModification.cross_database_context
|
|
ActiveSupport::Notifications.unsubscribe(PreventCrossDatabaseModification.cross_database_context[:subscriber])
|
|
PreventCrossDatabaseModification.cross_database_context[:enabled] = false
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.cross_database_context
|
|
Thread.current[:transaction_tracker]
|
|
end
|
|
|
|
def self.reset_cross_database_context!
|
|
Thread.current[:transaction_tracker] = initial_data
|
|
end
|
|
|
|
def self.initial_data
|
|
{
|
|
enabled: false,
|
|
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.prevent_cross_database_modification!(connection, sql)
|
|
return unless cross_database_context
|
|
return unless cross_database_context[:enabled]
|
|
|
|
return if connection.pool.instance_of?(ActiveRecord::ConnectionAdapters::NullPool)
|
|
|
|
database = connection.pool.db_config.name
|
|
|
|
if sql.start_with?('SAVEPOINT')
|
|
cross_database_context[:transaction_depth_by_db][database] += 1
|
|
|
|
return
|
|
elsif sql.start_with?('RELEASE SAVEPOINT', 'ROLLBACK TO SAVEPOINT')
|
|
cross_database_context[:transaction_depth_by_db][database] -= 1
|
|
if cross_database_context[:transaction_depth_by_db][database] <= 0
|
|
cross_database_context[:modified_tables_by_db][database].clear
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
return if cross_database_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
|
|
parsed_query = PgQuery.parse(sql)
|
|
tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables
|
|
|
|
return if tables.empty?
|
|
|
|
cross_database_context[:modified_tables_by_db][database].merge(tables)
|
|
|
|
all_tables = cross_database_context[:modified_tables_by_db].values.map(&:to_a).flatten
|
|
schemas = Database::GitlabSchema.table_schemas(all_tables)
|
|
|
|
if schemas.many?
|
|
raise Database::PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError,
|
|
"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."
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Gitlab::Database.singleton_class.prepend(
|
|
Database::PreventCrossDatabaseModification::GitlabDatabaseMixin)
|
|
|
|
CROSS_DB_MODIFICATION_ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-database-modification-allowlist.yml'))).freeze
|
|
|
|
RSpec.configure do |config|
|
|
config.include(::Database::PreventCrossDatabaseModification::SpecHelpers)
|
|
|
|
# Using before and after blocks because the around block causes problems with the let_it_be
|
|
# record creations. It makes an extra savepoint which breaks the transaction count logic.
|
|
config.before do |example_file|
|
|
if CROSS_DB_MODIFICATION_ALLOW_LIST.exclude?(example_file.file_path)
|
|
with_cross_database_modification_prevented
|
|
end
|
|
end
|
|
|
|
config.after do |example_file|
|
|
cleanup_with_cross_database_modification_prevented
|
|
end
|
|
end
|