Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-09-20 16:18:24 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-20 16:18:24 +0300
commit0653e08efd039a5905f3fa4f6e9cef9f5d2f799c (patch)
tree4dcc884cf6d81db44adae4aa99f8ec1233a41f55 /lib/gitlab/database
parent744144d28e3e7fddc117924fef88de5d9674fe4c (diff)
Add latest changes from gitlab-org/gitlab@14-3-stable-eev14.3.0-rc42
Diffstat (limited to 'lib/gitlab/database')
-rw-r--r--lib/gitlab/database/async_indexes/migration_helpers.rb7
-rw-r--r--lib/gitlab/database/background_migration/batched_job.rb1
-rw-r--r--lib/gitlab/database/background_migration/batched_migration.rb11
-rw-r--r--lib/gitlab/database/connection.rb53
-rw-r--r--lib/gitlab/database/load_balancing.rb97
-rw-r--r--lib/gitlab/database/load_balancing/action_cable_callbacks.rb26
-rw-r--r--lib/gitlab/database/load_balancing/configuration.rb85
-rw-r--r--lib/gitlab/database/load_balancing/connection_proxy.rb29
-rw-r--r--lib/gitlab/database/load_balancing/host.rb14
-rw-r--r--lib/gitlab/database/load_balancing/host_list.rb6
-rw-r--r--lib/gitlab/database/load_balancing/load_balancer.rb38
-rw-r--r--lib/gitlab/database/load_balancing/primary_host.rb81
-rw-r--r--lib/gitlab/database/load_balancing/service_discovery.rb55
-rw-r--r--lib/gitlab/database/load_balancing/sidekiq_client_middleware.rb27
-rw-r--r--lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb44
-rw-r--r--lib/gitlab/database/migration.rb53
-rw-r--r--lib/gitlab/database/migration_helpers.rb111
-rw-r--r--lib/gitlab/database/migration_helpers/cascading_namespace_settings.rb12
-rw-r--r--lib/gitlab/database/migration_helpers/loose_foreign_key_helpers.rb32
-rw-r--r--lib/gitlab/database/migration_helpers/v2.rb112
-rw-r--r--lib/gitlab/database/migrations/lock_retry_mixin.rb43
-rw-r--r--lib/gitlab/database/partitioning.rb19
-rw-r--r--lib/gitlab/database/partitioning/monthly_strategy.rb10
-rw-r--r--lib/gitlab/database/partitioning/multi_database_partition_manager.rb37
-rw-r--r--lib/gitlab/database/partitioning/partition_manager.rb72
-rw-r--r--lib/gitlab/database/partitioning/partition_monitoring.rb2
-rw-r--r--lib/gitlab/database/partitioning/time_partition.rb4
-rw-r--r--lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers.rb4
-rw-r--r--lib/gitlab/database/partitioning_migration_helpers/index_helpers.rb6
-rw-r--r--lib/gitlab/database/partitioning_migration_helpers/table_management_helpers.rb2
-rw-r--r--lib/gitlab/database/postgres_foreign_key.rb2
-rw-r--r--lib/gitlab/database/postgres_partition.rb2
-rw-r--r--lib/gitlab/database/postgres_partitioned_table.rb2
-rw-r--r--lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb2
-rw-r--r--lib/gitlab/database/rename_table_helpers.rb8
-rw-r--r--lib/gitlab/database/schema_migrations/context.rb4
-rw-r--r--lib/gitlab/database/shared_model.rb39
-rw-r--r--lib/gitlab/database/transaction/context.rb45
-rw-r--r--lib/gitlab/database/transaction/observer.rb3
-rw-r--r--lib/gitlab/database/with_lock_retries.rb16
40 files changed, 908 insertions, 308 deletions
diff --git a/lib/gitlab/database/async_indexes/migration_helpers.rb b/lib/gitlab/database/async_indexes/migration_helpers.rb
index dff6376270a..2f990aba2fb 100644
--- a/lib/gitlab/database/async_indexes/migration_helpers.rb
+++ b/lib/gitlab/database/async_indexes/migration_helpers.rb
@@ -55,11 +55,14 @@ module Gitlab
schema_creation = ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaCreation.new(ApplicationRecord.connection)
definition = schema_creation.accept(create_index)
- async_index = PostgresAsyncIndex.safe_find_or_create_by!(name: index_name) do |rec|
+ async_index = PostgresAsyncIndex.find_or_create_by!(name: index_name) do |rec|
rec.table_name = table_name
rec.definition = definition
end
+ async_index.definition = definition
+ async_index.save! # No-op if definition is not changed
+
Gitlab::AppLogger.info(
message: 'Prepared index for async creation',
table_name: async_index.table_name,
@@ -68,8 +71,6 @@ module Gitlab
async_index
end
- private
-
def async_index_creation_available?
ApplicationRecord.connection.table_exists?(:postgres_async_indexes) &&
Feature.enabled?(:database_async_index_creation, type: :ops)
diff --git a/lib/gitlab/database/background_migration/batched_job.rb b/lib/gitlab/database/background_migration/batched_job.rb
index 03bd02d7554..32765cb6a56 100644
--- a/lib/gitlab/database/background_migration/batched_job.rb
+++ b/lib/gitlab/database/background_migration/batched_job.rb
@@ -4,6 +4,7 @@ module Gitlab
module Database
module BackgroundMigration
class BatchedJob < ActiveRecord::Base # rubocop:disable Rails/ApplicationRecord
+ include EachBatch
include FromUnion
self.table_name = :batched_background_migration_jobs
diff --git a/lib/gitlab/database/background_migration/batched_migration.rb b/lib/gitlab/database/background_migration/batched_migration.rb
index 9d66824da51..d9fc2ea48f6 100644
--- a/lib/gitlab/database/background_migration/batched_migration.rb
+++ b/lib/gitlab/database/background_migration/batched_migration.rb
@@ -68,6 +68,17 @@ module Gitlab
)
end
+ def retry_failed_jobs!
+ batched_jobs.failed.each_batch(of: 100) do |batch|
+ self.class.transaction do
+ batch.lock.each(&:split_and_retry!)
+ self.active!
+ end
+ end
+
+ self.active!
+ end
+
def next_min_value
last_job&.max_value&.next || min_value
end
diff --git a/lib/gitlab/database/connection.rb b/lib/gitlab/database/connection.rb
index 21861e4fba8..cda6220ee6c 100644
--- a/lib/gitlab/database/connection.rb
+++ b/lib/gitlab/database/connection.rb
@@ -5,8 +5,6 @@ module Gitlab
# Configuration settings and methods for interacting with a PostgreSQL
# database, with support for multiple databases.
class Connection
- DEFAULT_POOL_HEADROOM = 10
-
attr_reader :scope
# Initializes a new `Database`.
@@ -20,20 +18,6 @@ module Gitlab
@open_transactions_baseline = 0
end
- # We configure the database connection pool size automatically based on
- # the configured concurrency. We also add some headroom, to make sure we
- # don't run out of connections when more threads besides the 'user-facing'
- # ones are running.
- #
- # Read more about this in
- # doc/development/database/client_side_connection_pool.md
- def default_pool_size
- headroom =
- (ENV["DB_POOL_HEADROOM"].presence || DEFAULT_POOL_HEADROOM).to_i
-
- Gitlab::Runtime.max_threads + headroom
- end
-
def config
# The result of this method must not be cached, as other methods may use
# it after making configuration changes and expect those changes to be
@@ -48,7 +32,7 @@ module Gitlab
end
def pool_size
- config[:pool] || default_pool_size
+ config[:pool] || Database.default_pool_size
end
def username
@@ -77,7 +61,9 @@ module Gitlab
def db_config_with_default_pool_size
db_config_object = scope.connection_db_config
- config = db_config_object.configuration_hash.merge(pool: default_pool_size)
+ config = db_config_object
+ .configuration_hash
+ .merge(pool: Database.default_pool_size)
ActiveRecord::DatabaseConfigurations::HashConfig.new(
db_config_object.env_name,
@@ -88,7 +74,16 @@ module Gitlab
# Disables prepared statements for the current database connection.
def disable_prepared_statements
- scope.establish_connection(config.merge(prepared_statements: false))
+ db_config_object = scope.connection_db_config
+ config = db_config_object.configuration_hash.merge(prepared_statements: false)
+
+ hash_config = ActiveRecord::DatabaseConfigurations::HashConfig.new(
+ db_config_object.env_name,
+ db_config_object.name,
+ config
+ )
+
+ scope.establish_connection(hash_config)
end
# Check whether the underlying database is in read-only mode
@@ -174,8 +169,11 @@ module Gitlab
end
def exists?
- connection
-
+ # We can't _just_ check if `connection` raises an error, as it will
+ # point to a `ConnectionProxy`, and obtaining those doesn't involve any
+ # database queries. So instead we obtain the database version, which is
+ # cached after the first call.
+ connection.schema_cache.database_version
true
rescue StandardError
false
@@ -189,6 +187,19 @@ module Gitlab
row['system_identifier']
end
+ def pg_wal_lsn_diff(location1, location2)
+ lsn1 = connection.quote(location1)
+ lsn2 = connection.quote(location2)
+
+ query = <<-SQL.squish
+ SELECT pg_wal_lsn_diff(#{lsn1}, #{lsn2})
+ AS result
+ SQL
+
+ row = connection.select_all(query).first
+ row['result'] if row
+ end
+
# @param [ActiveRecord::Connection] ar_connection
# @return [String]
def get_write_location(ar_connection)
diff --git a/lib/gitlab/database/load_balancing.rb b/lib/gitlab/database/load_balancing.rb
index 08f108eb8e4..bbfbf83222f 100644
--- a/lib/gitlab/database/load_balancing.rb
+++ b/lib/gitlab/database/load_balancing.rb
@@ -36,89 +36,42 @@ module Gitlab
# Returns a Hash containing the load balancing configuration.
def self.configuration
- Gitlab::Database.main.config[:load_balancing] || {}
- end
-
- # Returns the maximum replica lag size in bytes.
- def self.max_replication_difference
- (configuration['max_replication_difference'] || 8.megabytes).to_i
- end
-
- # Returns the maximum lag time for a replica.
- def self.max_replication_lag_time
- (configuration['max_replication_lag_time'] || 60.0).to_f
- end
-
- # Returns the interval (in seconds) to use for checking the status of a
- # replica.
- def self.replica_check_interval
- (configuration['replica_check_interval'] || 60).to_f
- end
-
- # Returns the additional hosts to use for load balancing.
- def self.hosts
- configuration['hosts'] || []
- end
-
- def self.service_discovery_enabled?
- configuration.dig('discover', 'record').present?
- end
-
- def self.service_discovery_configuration
- conf = configuration['discover'] || {}
-
- {
- nameserver: conf['nameserver'] || 'localhost',
- port: conf['port'] || 8600,
- record: conf['record'],
- record_type: conf['record_type'] || 'A',
- interval: conf['interval'] || 60,
- disconnect_timeout: conf['disconnect_timeout'] || 120,
- use_tcp: conf['use_tcp'] || false
- }
- end
-
- def self.pool_size
- Gitlab::Database.main.pool_size
+ @configuration ||= Configuration.for_model(ActiveRecord::Base)
end
# Returns true if load balancing is to be enabled.
def self.enable?
return false if Gitlab::Runtime.rake?
- return false unless self.configured?
- true
+ configured?
end
- # Returns true if load balancing has been configured. Since
- # Sidekiq does not currently use load balancing, we
- # may want Web application servers to detect replication lag by
- # posting the write location of the database if load balancing is
- # configured.
def self.configured?
- hosts.any? || service_discovery_enabled?
+ configuration.load_balancing_enabled? ||
+ configuration.service_discovery_enabled?
end
def self.start_service_discovery
- return unless service_discovery_enabled?
+ return unless configuration.service_discovery_enabled?
- ServiceDiscovery.new(service_discovery_configuration).start
+ ServiceDiscovery
+ .new(proxy.load_balancer, **configuration.service_discovery)
+ .start
end
# Configures proxying of requests.
- def self.configure_proxy(proxy = ConnectionProxy.new(hosts))
- ActiveRecord::Base.load_balancing_proxy = proxy
+ def self.configure_proxy
+ lb = LoadBalancer.new(configuration, primary_only: !enable?)
+ ActiveRecord::Base.load_balancing_proxy = ConnectionProxy.new(lb)
# Populate service discovery immediately if it is configured
- if service_discovery_enabled?
- ServiceDiscovery.new(service_discovery_configuration).perform_service_discovery
+ if configuration.service_discovery_enabled?
+ ServiceDiscovery
+ .new(lb, **configuration.service_discovery)
+ .perform_service_discovery
end
end
- def self.active_record_models
- ActiveRecord::Base.descendants
- end
-
DB_ROLES = [
ROLE_PRIMARY = :primary,
ROLE_REPLICA = :replica,
@@ -126,24 +79,12 @@ module Gitlab
].freeze
# Returns the role (primary/replica) of the database the connection is
- # connecting to. At the moment, the connection can only be retrieved by
- # Gitlab::Database::LoadBalancer#read or #read_write or from the
- # ActiveRecord directly. Therefore, if the load balancer doesn't
- # recognize the connection, this method returns the primary role
- # directly. In future, we may need to check for other sources.
+ # connecting to.
def self.db_role_for_connection(connection)
- return ROLE_UNKNOWN unless connection
-
- # The connection proxy does not have a role assigned
- # as this is dependent on a execution context
- return ROLE_UNKNOWN if connection.is_a?(ConnectionProxy)
-
- # During application init we might receive `NullPool`
- return ROLE_UNKNOWN unless connection.respond_to?(:pool) &&
- connection.pool.respond_to?(:db_config) &&
- connection.pool.db_config.respond_to?(:name)
+ db_config = Database.db_config_for_connection(connection)
+ return ROLE_UNKNOWN unless db_config
- if connection.pool.db_config.name.ends_with?(LoadBalancer::REPLICA_SUFFIX)
+ if db_config.name.ends_with?(LoadBalancer::REPLICA_SUFFIX)
ROLE_REPLICA
else
ROLE_PRIMARY
diff --git a/lib/gitlab/database/load_balancing/action_cable_callbacks.rb b/lib/gitlab/database/load_balancing/action_cable_callbacks.rb
new file mode 100644
index 00000000000..4feba989a0a
--- /dev/null
+++ b/lib/gitlab/database/load_balancing/action_cable_callbacks.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module LoadBalancing
+ module ActionCableCallbacks
+ def self.install
+ ::ActionCable::Server::Worker.set_callback :work, :around, &wrapper
+ ::ActionCable::Channel::Base.set_callback :subscribe, :around, &wrapper
+ ::ActionCable::Channel::Base.set_callback :unsubscribe, :around, &wrapper
+ end
+
+ def self.wrapper
+ lambda do |_, inner|
+ ::Gitlab::Database::LoadBalancing::Session.current.use_primary!
+
+ inner.call
+ ensure
+ ::Gitlab::Database::LoadBalancing.proxy.load_balancer.release_host
+ ::Gitlab::Database::LoadBalancing::Session.clear_session
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/load_balancing/configuration.rb b/lib/gitlab/database/load_balancing/configuration.rb
new file mode 100644
index 00000000000..238f55fd98e
--- /dev/null
+++ b/lib/gitlab/database/load_balancing/configuration.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module LoadBalancing
+ # Configuration settings for a single LoadBalancer instance.
+ class Configuration
+ attr_accessor :hosts, :max_replication_difference,
+ :max_replication_lag_time, :replica_check_interval,
+ :service_discovery, :model
+
+ # Creates a configuration object for the given ActiveRecord model.
+ def self.for_model(model)
+ cfg = model.connection_db_config.configuration_hash.deep_symbolize_keys
+ lb_cfg = cfg[:load_balancing] || {}
+ config = new(model)
+
+ if (diff = lb_cfg[:max_replication_difference])
+ config.max_replication_difference = diff
+ end
+
+ if (lag = lb_cfg[:max_replication_lag_time])
+ config.max_replication_lag_time = lag.to_f
+ end
+
+ if (interval = lb_cfg[:replica_check_interval])
+ config.replica_check_interval = interval.to_f
+ end
+
+ if (hosts = lb_cfg[:hosts])
+ config.hosts = hosts
+ end
+
+ discover = lb_cfg[:discover] || {}
+
+ # We iterate over the known/default keys so we don't end up with
+ # random keys in our configuration hash.
+ config.service_discovery.each do |key, _|
+ if (value = discover[key])
+ config.service_discovery[key] = value
+ end
+ end
+
+ config
+ end
+
+ def initialize(model, hosts = [])
+ @max_replication_difference = 8.megabytes
+ @max_replication_lag_time = 60.0
+ @replica_check_interval = 60.0
+ @model = model
+ @hosts = hosts
+ @service_discovery = {
+ nameserver: 'localhost',
+ port: 8600,
+ record: nil,
+ record_type: 'A',
+ interval: 60,
+ disconnect_timeout: 120,
+ use_tcp: false
+ }
+ end
+
+ def pool_size
+ # The pool size may change when booting up GitLab, as GitLab enforces
+ # a certain number of threads. If a Configuration is memoized, this
+ # can lead to incorrect pool sizes.
+ #
+ # To support this scenario, we always attempt to read the pool size
+ # from the model's configuration.
+ @model.connection_db_config.configuration_hash[:pool] ||
+ Database.default_pool_size
+ end
+
+ def load_balancing_enabled?
+ hosts.any? || service_discovery_enabled?
+ end
+
+ def service_discovery_enabled?
+ service_discovery[:record].present?
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/load_balancing/connection_proxy.rb b/lib/gitlab/database/load_balancing/connection_proxy.rb
index 938f4951532..1be63da8896 100644
--- a/lib/gitlab/database/load_balancing/connection_proxy.rb
+++ b/lib/gitlab/database/load_balancing/connection_proxy.rb
@@ -34,15 +34,15 @@ module Gitlab
).freeze
# hosts - The hosts to use for load balancing.
- def initialize(hosts = [])
- @load_balancer = LoadBalancer.new(hosts)
+ def initialize(load_balancer)
+ @load_balancer = load_balancer
end
def select_all(arel, name = nil, binds = [], preparable: nil)
if arel.respond_to?(:locked) && arel.locked
# SELECT ... FOR UPDATE queries should be sent to the primary.
- write_using_load_balancer(:select_all, arel, name, binds,
- sticky: true)
+ current_session.write!
+ write_using_load_balancer(:select_all, arel, name, binds)
else
read_using_load_balancer(:select_all, arel, name, binds)
end
@@ -56,7 +56,8 @@ module Gitlab
STICKY_WRITES.each do |name|
define_method(name) do |*args, **kwargs, &block|
- write_using_load_balancer(name, *args, sticky: true, **kwargs, &block)
+ current_session.write!
+ write_using_load_balancer(name, *args, **kwargs, &block)
end
end
@@ -65,13 +66,20 @@ module Gitlab
track_read_only_transaction!
read_using_load_balancer(:transaction, *args, **kwargs, &block)
else
- write_using_load_balancer(:transaction, *args, sticky: true, **kwargs, &block)
+ current_session.write!
+ write_using_load_balancer(:transaction, *args, **kwargs, &block)
end
ensure
untrack_read_only_transaction!
end
+ def respond_to_missing?(name, include_private = false)
+ @load_balancer.read_write do |connection|
+ connection.respond_to?(name, include_private)
+ end
+ end
+
# Delegates all unknown messages to a read-write connection.
def method_missing(...)
if current_session.fallback_to_replicas_for_ambiguous_queries?
@@ -102,18 +110,13 @@ module Gitlab
# name - The name of the method to call on a connection object.
# sticky - If set to true the session will stick to the master after
# the write.
- def write_using_load_balancer(name, *args, sticky: false, **kwargs, &block)
+ def write_using_load_balancer(...)
if read_only_transaction?
raise WriteInsideReadOnlyTransactionError, 'A write query is performed inside a read-only transaction'
end
@load_balancer.read_write do |connection|
- # Sticking has to be enabled before calling the method. Not doing so
- # could lead to methods called in a block still being performed on a
- # secondary instead of on a primary (when necessary).
- current_session.write! if sticky
-
- connection.send(name, *args, **kwargs, &block)
+ connection.send(...)
end
end
diff --git a/lib/gitlab/database/load_balancing/host.rb b/lib/gitlab/database/load_balancing/host.rb
index 4c5357ae8e3..acd7df0a263 100644
--- a/lib/gitlab/database/load_balancing/host.rb
+++ b/lib/gitlab/database/load_balancing/host.rb
@@ -29,11 +29,15 @@ module Gitlab
@host = host
@port = port
@load_balancer = load_balancer
- @pool = load_balancer.create_replica_connection_pool(::Gitlab::Database::LoadBalancing.pool_size, host, port)
+ @pool = load_balancer.create_replica_connection_pool(
+ load_balancer.configuration.pool_size,
+ host,
+ port
+ )
@online = true
@last_checked_at = Time.zone.now
- interval = ::Gitlab::Database::LoadBalancing.replica_check_interval
+ interval = load_balancer.configuration.replica_check_interval
@intervals = (interval..(interval * 2)).step(0.5).to_a
end
@@ -108,7 +112,7 @@ module Gitlab
def replication_lag_below_threshold?
if (lag_time = replication_lag_time)
- lag_time <= ::Gitlab::Database::LoadBalancing.max_replication_lag_time
+ lag_time <= load_balancer.configuration.max_replication_lag_time
else
false
end
@@ -125,7 +129,7 @@ module Gitlab
# only do this if we haven't replicated in a while so we only need
# to connect to the primary when truly necessary.
if (lag_size = replication_lag_size)
- lag_size <= ::Gitlab::Database::LoadBalancing.max_replication_difference
+ lag_size <= load_balancer.configuration.max_replication_difference
else
false
end
@@ -159,8 +163,6 @@ module Gitlab
def primary_write_location
load_balancer.primary_write_location
- ensure
- load_balancer.release_primary_connection
end
def database_replica_location
diff --git a/lib/gitlab/database/load_balancing/host_list.rb b/lib/gitlab/database/load_balancing/host_list.rb
index aa731521732..fb3175c7d5d 100644
--- a/lib/gitlab/database/load_balancing/host_list.rb
+++ b/lib/gitlab/database/load_balancing/host_list.rb
@@ -35,12 +35,6 @@ module Gitlab
def hosts=(hosts)
@mutex.synchronize do
- ::Gitlab::Database::LoadBalancing::Logger.info(
- event: :host_list_update,
- message: "Updating the host list for service discovery",
- host_list_length: hosts.length,
- old_host_list_length: @hosts.length
- )
@hosts = hosts
unsafe_shuffle
end
diff --git a/lib/gitlab/database/load_balancing/load_balancer.rb b/lib/gitlab/database/load_balancing/load_balancer.rb
index e3f5d0ac470..9b00b323301 100644
--- a/lib/gitlab/database/load_balancing/load_balancer.rb
+++ b/lib/gitlab/database/load_balancing/load_balancer.rb
@@ -12,12 +12,22 @@ module Gitlab
REPLICA_SUFFIX = '_replica'
- attr_reader :host_list
-
- # hosts - The hostnames/addresses of the additional databases.
- def initialize(hosts = [], model = ActiveRecord::Base)
- @model = model
- @host_list = HostList.new(hosts.map { |addr| Host.new(addr, self) })
+ attr_reader :host_list, :configuration
+
+ # configuration - An instance of `LoadBalancing::Configuration` that
+ # contains the configuration details (such as the hosts)
+ # for this load balancer.
+ # primary_only - If set, the replicas are ignored and the primary is
+ # always used.
+ def initialize(configuration, primary_only: false)
+ @configuration = configuration
+ @primary_only = primary_only
+ @host_list =
+ if primary_only
+ HostList.new([PrimaryHost.new(self)])
+ else
+ HostList.new(configuration.hosts.map { |addr| Host.new(addr, self) })
+ end
end
def disconnect!(timeout: 120)
@@ -169,7 +179,11 @@ module Gitlab
when ActiveRecord::StatementInvalid, ActionView::Template::Error
# After connecting to the DB Rails will wrap query errors using this
# class.
- connection_error?(error.cause)
+ if (cause = error.cause)
+ connection_error?(cause)
+ else
+ false
+ end
when *CONNECTION_ERRORS
true
else
@@ -213,26 +227,26 @@ module Gitlab
.establish_connection(replica_db_config)
end
- private
-
# ActiveRecord::ConnectionAdapters::ConnectionHandler handles fetching,
# and caching for connections pools for each "connection", so we
# leverage that.
def pool
ActiveRecord::Base.connection_handler.retrieve_connection_pool(
- @model.connection_specification_name,
+ @configuration.model.connection_specification_name,
role: ActiveRecord::Base.writing_role,
shard: ActiveRecord::Base.default_shard
)
end
+ private
+
def ensure_caching!
host.enable_query_cache! unless host.query_cache_enabled
end
def request_cache
- base = RequestStore[:gitlab_load_balancer] ||= {}
- base[pool] ||= {}
+ base = SafeRequestStore[:gitlab_load_balancer] ||= {}
+ base[self] ||= {}
end
end
end
diff --git a/lib/gitlab/database/load_balancing/primary_host.rb b/lib/gitlab/database/load_balancing/primary_host.rb
new file mode 100644
index 00000000000..e379652c260
--- /dev/null
+++ b/lib/gitlab/database/load_balancing/primary_host.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module LoadBalancing
+ # A host that wraps the primary database connection.
+ #
+ # This class is used to always enable load balancing as if replicas exist,
+ # without the need for extra database connections. This ensures that code
+ # using the load balancer doesn't have to handle the case where load
+ # balancing is enabled, but no replicas have been configured (= the
+ # default case).
+ class PrimaryHost
+ def initialize(load_balancer)
+ @load_balancer = load_balancer
+ end
+
+ def release_connection
+ # no-op as releasing primary connections isn't needed.
+ nil
+ end
+
+ def enable_query_cache!
+ # This could mess up the primary connection, so we make this a no-op
+ nil
+ end
+
+ def disable_query_cache!
+ # This could mess up the primary connection, so we make this a no-op
+ nil
+ end
+
+ def query_cache_enabled
+ @load_balancer.pool.query_cache_enabled
+ end
+
+ def connection
+ @load_balancer.pool.connection
+ end
+
+ def disconnect!(timeout: 120)
+ nil
+ end
+
+ def offline!
+ nil
+ end
+
+ def online?
+ true
+ end
+
+ def primary_write_location
+ @load_balancer.primary_write_location
+ end
+
+ def database_replica_location
+ row = query_and_release(<<-SQL.squish)
+ SELECT pg_last_wal_replay_lsn()::text AS location
+ SQL
+
+ row['location'] if row.any?
+ rescue *Host::CONNECTION_ERRORS
+ nil
+ end
+
+ def caught_up?(_location)
+ true
+ end
+
+ def query_and_release(sql)
+ connection.select_all(sql).first || {}
+ rescue StandardError
+ {}
+ ensure
+ release_connection
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/load_balancing/service_discovery.rb b/lib/gitlab/database/load_balancing/service_discovery.rb
index 251961c8246..dfd4892371c 100644
--- a/lib/gitlab/database/load_balancing/service_discovery.rb
+++ b/lib/gitlab/database/load_balancing/service_discovery.rb
@@ -13,11 +13,17 @@ module Gitlab
# balancer with said hosts. Requests may continue to use the old hosts
# until they complete.
class ServiceDiscovery
+ EmptyDnsResponse = Class.new(StandardError)
+
attr_reader :interval, :record, :record_type, :disconnect_timeout,
:load_balancer
MAX_SLEEP_ADJUSTMENT = 10
+ MAX_DISCOVERY_RETRIES = 3
+
+ RETRY_DELAY_RANGE = (0.1..0.2).freeze
+
RECORD_TYPES = {
'A' => Net::DNS::A,
'SRV' => Net::DNS::SRV
@@ -43,14 +49,14 @@ module Gitlab
# use_tcp - Use TCP instaed of UDP to look up resources
# load_balancer - The load balancer instance to use
def initialize(
+ load_balancer,
nameserver:,
port:,
record:,
record_type: 'A',
interval: 60,
disconnect_timeout: 120,
- use_tcp: false,
- load_balancer: LoadBalancing.proxy.load_balancer
+ use_tcp: false
)
@nameserver = nameserver
@port = port
@@ -76,15 +82,23 @@ module Gitlab
end
def perform_service_discovery
- refresh_if_necessary
- rescue StandardError => error
- # Any exceptions that might occur should be reported to
- # Sentry, instead of silently terminating this thread.
- Gitlab::ErrorTracking.track_exception(error)
-
- Gitlab::AppLogger.error(
- "Service discovery encountered an error: #{error.message}"
- )
+ MAX_DISCOVERY_RETRIES.times do
+ return refresh_if_necessary
+ rescue StandardError => error
+ # Any exceptions that might occur should be reported to
+ # Sentry, instead of silently terminating this thread.
+ Gitlab::ErrorTracking.track_exception(error)
+
+ Gitlab::Database::LoadBalancing::Logger.error(
+ event: :service_discovery_failure,
+ message: "Service discovery encountered an error: #{error.message}",
+ host_list_length: load_balancer.host_list.length
+ )
+
+ # Slightly randomize the retry delay so that, in the case of a total
+ # dns outage, all starting services do not pressure the dns server at the same time.
+ sleep(rand(RETRY_DELAY_RANGE))
+ end
interval
end
@@ -99,7 +113,22 @@ module Gitlab
current = addresses_from_load_balancer
- replace_hosts(from_dns) if from_dns != current
+ if from_dns != current
+ ::Gitlab::Database::LoadBalancing::Logger.info(
+ event: :host_list_update,
+ message: "Updating the host list for service discovery",
+ host_list_length: from_dns.length,
+ old_host_list_length: current.length
+ )
+ replace_hosts(from_dns)
+ else
+ ::Gitlab::Database::LoadBalancing::Logger.info(
+ event: :host_list_unchanged,
+ message: "Unchanged host list for service discovery",
+ host_list_length: from_dns.length,
+ old_host_list_length: current.length
+ )
+ end
interval
end
@@ -141,6 +170,8 @@ module Gitlab
addresses_from_srv_record(response)
end
+ raise EmptyDnsResponse if addresses.empty?
+
# Addresses are sorted so we can directly compare the old and new
# addresses, without having to use any additional data structures.
[new_wait_time_for(resources), addresses.sort]
diff --git a/lib/gitlab/database/load_balancing/sidekiq_client_middleware.rb b/lib/gitlab/database/load_balancing/sidekiq_client_middleware.rb
index 0e36ebbc3ee..518a812b406 100644
--- a/lib/gitlab/database/load_balancing/sidekiq_client_middleware.rb
+++ b/lib/gitlab/database/load_balancing/sidekiq_client_middleware.rb
@@ -4,13 +4,15 @@ module Gitlab
module Database
module LoadBalancing
class SidekiqClientMiddleware
+ include Gitlab::Utils::StrongMemoize
+
def call(worker_class, job, _queue, _redis_pool)
# Mailers can't be constantized
worker_class = worker_class.to_s.safe_constantize
if load_balancing_enabled?(worker_class)
job['worker_data_consistency'] = worker_class.get_data_consistency
- set_data_consistency_location!(job) unless location_already_provided?(job)
+ set_data_consistency_locations!(job) unless job['wal_locations']
else
job['worker_data_consistency'] = ::WorkerAttributes::DEFAULT_DATA_CONSISTENCY
end
@@ -27,16 +29,23 @@ module Gitlab
worker_class.get_data_consistency_feature_flag_enabled?
end
- def set_data_consistency_location!(job)
- if Session.current.use_primary?
- job['database_write_location'] = load_balancer.primary_write_location
- else
- job['database_replica_location'] = load_balancer.host.database_replica_location
- end
+ def set_data_consistency_locations!(job)
+ # Once we add support for multiple databases to our load balancer, we would use something like this:
+ # job['wal_locations'] = Gitlab::Database::DATABASES.transform_values do |connection|
+ # connection.load_balancer.primary_write_location
+ # end
+ #
+ job['wal_locations'] = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => wal_location } if wal_location
end
- def location_already_provided?(job)
- job['database_replica_location'] || job['database_write_location']
+ def wal_location
+ strong_memoize(:wal_location) do
+ if Session.current.use_primary?
+ load_balancer.primary_write_location
+ else
+ load_balancer.host.database_replica_location
+ end
+ end
end
def load_balancer
diff --git a/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb b/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb
index 0551750568a..15f8f0fb240 100644
--- a/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb
+++ b/lib/gitlab/database/load_balancing/sidekiq_server_middleware.rb
@@ -29,7 +29,7 @@ module Gitlab
private
def clear
- load_balancer.release_host
+ release_hosts
Session.clear_session
end
@@ -40,10 +40,11 @@ module Gitlab
def select_load_balancing_strategy(worker_class, job)
return :primary unless load_balancing_available?(worker_class)
- location = job['database_write_location'] || job['database_replica_location']
- return :primary_no_wal unless location
+ wal_locations = get_wal_locations(job)
+
+ return :primary_no_wal unless wal_locations
- if replica_caught_up?(location)
+ if all_databases_has_replica_caught_up?(wal_locations)
# Happy case: we can read from a replica.
retried_before?(worker_class, job) ? :replica_retried : :replica
elsif can_retry?(worker_class, job)
@@ -55,6 +56,19 @@ module Gitlab
end
end
+ def get_wal_locations(job)
+ job['dedup_wal_locations'] || job['wal_locations'] || legacy_wal_location(job)
+ end
+
+ # Already scheduled jobs could still contain legacy database write location.
+ # TODO: remove this in the next iteration
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/338213
+ def legacy_wal_location(job)
+ wal_location = job['database_write_location'] || job['database_replica_location']
+
+ { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => wal_location } if wal_location
+ end
+
def load_balancing_available?(worker_class)
worker_class.include?(::ApplicationWorker) &&
worker_class.utilizes_load_balancing_capabilities? &&
@@ -75,12 +89,26 @@ module Gitlab
job['retry_count'].nil?
end
- def load_balancer
- LoadBalancing.proxy.load_balancer
+ def all_databases_has_replica_caught_up?(wal_locations)
+ wal_locations.all? do |_config_name, location|
+ # Once we add support for multiple databases to our load balancer, we would use something like this:
+ # Gitlab::Database::DATABASES[config_name].load_balancer.select_up_to_date_host(location)
+ load_balancer.select_up_to_date_host(location)
+ end
end
- def replica_caught_up?(location)
- load_balancer.select_up_to_date_host(location)
+ def release_hosts
+ # Once we add support for multiple databases to our load balancer, we would use something like this:
+ # connection.load_balancer.primary_write_location
+ #
+ # Gitlab::Database::DATABASES.values.each do |connection|
+ # connection.load_balancer.release_host
+ # end
+ load_balancer.release_host
+ end
+
+ def load_balancer
+ LoadBalancing.proxy.load_balancer
end
end
end
diff --git a/lib/gitlab/database/migration.rb b/lib/gitlab/database/migration.rb
new file mode 100644
index 00000000000..b2248b0f4eb
--- /dev/null
+++ b/lib/gitlab/database/migration.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ class Migration
+ module LockRetriesConcern
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def enable_lock_retries!
+ @enable_lock_retries = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def enable_lock_retries?
+ @enable_lock_retries
+ end
+ end
+
+ delegate :enable_lock_retries?, to: :class
+ end
+
+ # This implements a simple versioning scheme for migration helpers.
+ #
+ # We need to be able to version helpers, so we can change their behavior without
+ # altering the behavior of already existing migrations in incompatible ways.
+ #
+ # We can continue to change the behavior of helpers without bumping the version here,
+ # *if* the change is backwards-compatible.
+ #
+ # If not, we would typically override the helper method in a new MigrationHelpers::V[0-9]+
+ # class and create a new entry with a bumped version below.
+ #
+ # We use major version bumps to indicate significant changes and minor version bumps
+ # to indicate backwards-compatible or otherwise minor changes (e.g. a Rails version bump).
+ # However, this hasn't been strictly formalized yet.
+ MIGRATION_CLASSES = {
+ 1.0 => Class.new(ActiveRecord::Migration[6.1]) do
+ include LockRetriesConcern
+ include Gitlab::Database::MigrationHelpers::V2
+ end
+ }.freeze
+
+ def self.[](version)
+ MIGRATION_CLASSES[version] || raise(ArgumentError, "Unknown migration version: #{version}")
+ end
+
+ # The current version to be used in new migrations
+ def self.current_version
+ 1.0
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 23d9b16dc09..9968096b1f6 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -73,6 +73,7 @@ module Gitlab
end
end
+ # @deprecated Use `create_table` in V2 instead
#
# Creates a new table, optionally allowing the caller to add check constraints to the table.
# Aside from that addition, this method should behave identically to Rails' `create_table` method.
@@ -380,6 +381,8 @@ module Gitlab
# The timings can be controlled via the +timing_configuration+ parameter.
# If the lock was not acquired within the retry period, a last attempt is made without using +lock_timeout+.
#
+ # Note this helper uses subtransactions when run inside an already open transaction.
+ #
# ==== Examples
# # Invoking without parameters
# with_lock_retries do
@@ -411,7 +414,8 @@ module Gitlab
raise_on_exhaustion = !!kwargs.delete(:raise_on_exhaustion)
merged_args = {
klass: self.class,
- logger: Gitlab::BackgroundMigration::Logger
+ logger: Gitlab::BackgroundMigration::Logger,
+ allow_savepoints: true
}.merge(kwargs)
Gitlab::Database::WithLockRetries.new(**merged_args)
@@ -600,17 +604,17 @@ module Gitlab
# new_column - The name of the new column.
# trigger_name - The name of the trigger to use (optional).
def install_rename_triggers(table, old, new, trigger_name: nil)
- Gitlab::Database::UnidirectionalCopyTrigger.on_table(table).create(old, new, trigger_name: trigger_name)
+ Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).create(old, new, trigger_name: trigger_name)
end
# Removes the triggers used for renaming a column concurrently.
def remove_rename_triggers(table, trigger)
- Gitlab::Database::UnidirectionalCopyTrigger.on_table(table).drop(trigger)
+ Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).drop(trigger)
end
# Returns the (base) name to use for triggers when renaming columns.
def rename_trigger_name(table, old, new)
- Gitlab::Database::UnidirectionalCopyTrigger.on_table(table).name(old, new)
+ Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).name(old, new)
end
# Changes the type of a column concurrently.
@@ -968,42 +972,7 @@ module Gitlab
# columns - The name, or array of names, of the column(s) that we want to convert to bigint.
# primary_key - The name of the primary key column (most often :id)
def initialize_conversion_of_integer_to_bigint(table, columns, primary_key: :id)
- unless table_exists?(table)
- raise "Table #{table} does not exist"
- end
-
- unless column_exists?(table, primary_key)
- raise "Column #{primary_key} does not exist on #{table}"
- end
-
- columns = Array.wrap(columns)
- columns.each do |column|
- next if column_exists?(table, column)
-
- raise ArgumentError, "Column #{column} does not exist on #{table}"
- end
-
- check_trigger_permissions!(table)
-
- conversions = columns.to_h { |column| [column, convert_to_bigint_column(column)] }
-
- with_lock_retries do
- conversions.each do |(source_column, temporary_name)|
- column = column_for(table, source_column)
-
- if (column.name.to_s == primary_key.to_s) || !column.null
- # If the column to be converted is either a PK or is defined as NOT NULL,
- # set it to `NOT NULL DEFAULT 0` and we'll copy paste the correct values bellow
- # That way, we skip the expensive validation step required to add
- # a NOT NULL constraint at the end of the process
- add_column(table, temporary_name, :bigint, default: column.default || 0, null: false)
- else
- add_column(table, temporary_name, :bigint, default: column.default)
- end
- end
-
- install_rename_triggers(table, conversions.keys, conversions.values)
- end
+ create_temporary_columns_and_triggers(table, columns, primary_key: primary_key, data_type: :bigint)
end
# Reverts `initialize_conversion_of_integer_to_bigint`
@@ -1019,6 +988,17 @@ module Gitlab
temporary_columns.each { |column| remove_column(table, column) }
end
+ alias_method :cleanup_conversion_of_integer_to_bigint, :revert_initialize_conversion_of_integer_to_bigint
+
+ # Reverts `cleanup_conversion_of_integer_to_bigint`
+ #
+ # table - The name of the database table containing the columns
+ # columns - The name, or array of names, of the column(s) that we have converted to bigint.
+ # primary_key - The name of the primary key column (most often :id)
+
+ def restore_conversion_of_integer_to_bigint(table, columns, primary_key: :id)
+ create_temporary_columns_and_triggers(table, columns, primary_key: primary_key, data_type: :int)
+ end
# Backfills the new columns used in an integer-to-bigint conversion using background migrations.
#
@@ -1400,13 +1380,11 @@ into similar problems in the future (e.g. when new tables are created).
# validate - Whether to validate the constraint in this call
#
def add_check_constraint(table, check, constraint_name, validate: true)
- validate_check_constraint_name!(constraint_name)
-
# Transactions would result in ALTER TABLE locks being held for the
# duration of the transaction, defeating the purpose of this method.
- if transaction_open?
- raise 'add_check_constraint can not be run inside a transaction'
- end
+ validate_not_in_transaction!(:add_check_constraint)
+
+ validate_check_constraint_name!(constraint_name)
if check_constraint_exists?(table, constraint_name)
warning_message = <<~MESSAGE
@@ -1451,6 +1429,10 @@ into similar problems in the future (e.g. when new tables are created).
end
def remove_check_constraint(table, constraint_name)
+ # This is technically not necessary, but aligned with add_check_constraint
+ # and allows us to continue use with_lock_retries here
+ validate_not_in_transaction!(:remove_check_constraint)
+
validate_check_constraint_name!(constraint_name)
# DROP CONSTRAINT requires an EXCLUSIVE lock
@@ -1649,6 +1631,45 @@ into similar problems in the future (e.g. when new tables are created).
private
+ def create_temporary_columns_and_triggers(table, columns, primary_key: :id, data_type: :bigint)
+ unless table_exists?(table)
+ raise "Table #{table} does not exist"
+ end
+
+ unless column_exists?(table, primary_key)
+ raise "Column #{primary_key} does not exist on #{table}"
+ end
+
+ columns = Array.wrap(columns)
+ columns.each do |column|
+ next if column_exists?(table, column)
+
+ raise ArgumentError, "Column #{column} does not exist on #{table}"
+ end
+
+ check_trigger_permissions!(table)
+
+ conversions = columns.to_h { |column| [column, convert_to_bigint_column(column)] }
+
+ with_lock_retries do
+ conversions.each do |(source_column, temporary_name)|
+ column = column_for(table, source_column)
+
+ if (column.name.to_s == primary_key.to_s) || !column.null
+ # If the column to be converted is either a PK or is defined as NOT NULL,
+ # set it to `NOT NULL DEFAULT 0` and we'll copy paste the correct values bellow
+ # That way, we skip the expensive validation step required to add
+ # a NOT NULL constraint at the end of the process
+ add_column(table, temporary_name, data_type, default: column.default || 0, null: false)
+ else
+ add_column(table, temporary_name, data_type, default: column.default)
+ end
+ end
+
+ install_rename_triggers(table, conversions.keys, conversions.values)
+ end
+ end
+
def validate_check_constraint_name!(constraint_name)
if constraint_name.to_s.length > MAX_IDENTIFIER_NAME_LENGTH
raise "The maximum allowed constraint name is #{MAX_IDENTIFIER_NAME_LENGTH} characters"
diff --git a/lib/gitlab/database/migration_helpers/cascading_namespace_settings.rb b/lib/gitlab/database/migration_helpers/cascading_namespace_settings.rb
index eecf96acb30..d9ef5ab462e 100644
--- a/lib/gitlab/database/migration_helpers/cascading_namespace_settings.rb
+++ b/lib/gitlab/database/migration_helpers/cascading_namespace_settings.rb
@@ -31,10 +31,8 @@ module Gitlab
namespace_options = options.merge(null: true, default: nil)
- with_lock_retries do
- add_column(:namespace_settings, setting_name, type, namespace_options)
- add_column(:namespace_settings, lock_column_name, :boolean, default: false, null: false)
- end
+ add_column(:namespace_settings, setting_name, type, namespace_options)
+ add_column(:namespace_settings, lock_column_name, :boolean, default: false, null: false)
add_column(:application_settings, setting_name, type, options)
add_column(:application_settings, lock_column_name, :boolean, default: false, null: false)
@@ -43,10 +41,8 @@ module Gitlab
def remove_cascading_namespace_setting(setting_name)
lock_column_name = "lock_#{setting_name}".to_sym
- with_lock_retries do
- remove_column(:namespace_settings, setting_name) if column_exists?(:namespace_settings, setting_name)
- remove_column(:namespace_settings, lock_column_name) if column_exists?(:namespace_settings, lock_column_name)
- end
+ remove_column(:namespace_settings, setting_name) if column_exists?(:namespace_settings, setting_name)
+ remove_column(:namespace_settings, lock_column_name) if column_exists?(:namespace_settings, lock_column_name)
remove_column(:application_settings, setting_name) if column_exists?(:application_settings, setting_name)
remove_column(:application_settings, lock_column_name) if column_exists?(:application_settings, lock_column_name)
diff --git a/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers.rb b/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers.rb
new file mode 100644
index 00000000000..30601bffd7a
--- /dev/null
+++ b/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module MigrationHelpers
+ module LooseForeignKeyHelpers
+ include Gitlab::Database::SchemaHelpers
+
+ DELETED_RECORDS_INSERT_FUNCTION_NAME = 'insert_into_loose_foreign_keys_deleted_records'
+
+ def track_record_deletions(table)
+ execute(<<~SQL)
+ CREATE TRIGGER #{record_deletion_trigger_name(table)}
+ AFTER DELETE ON #{table} REFERENCING OLD TABLE AS old_table
+ FOR EACH STATEMENT
+ EXECUTE FUNCTION #{DELETED_RECORDS_INSERT_FUNCTION_NAME}();
+ SQL
+ end
+
+ def untrack_record_deletions(table)
+ drop_trigger(table, record_deletion_trigger_name(table))
+ end
+
+ private
+
+ def record_deletion_trigger_name(table)
+ "#{table}_loose_fk_trigger"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/migration_helpers/v2.rb b/lib/gitlab/database/migration_helpers/v2.rb
index f20a9b30fa7..0e7f6075196 100644
--- a/lib/gitlab/database/migration_helpers/v2.rb
+++ b/lib/gitlab/database/migration_helpers/v2.rb
@@ -6,6 +6,118 @@ module Gitlab
module V2
include Gitlab::Database::MigrationHelpers
+ # Superseded by `create_table` override below
+ def create_table_with_constraints(*_)
+ raise <<~EOM
+ #create_table_with_constraints is not supported anymore - use #create_table instead, for example:
+
+ create_table :db_guides do |t|
+ t.bigint :stars, default: 0, null: false
+ t.text :title, limit: 128
+ t.text :notes, limit: 1024
+
+ t.check_constraint 'stars > 1000', name: 'so_many_stars'
+ end
+
+ See https://docs.gitlab.com/ee/development/database/strings_and_the_text_data_type.html
+ EOM
+ end
+
+ # Creates a new table, optionally allowing the caller to add text limit constraints to the table.
+ # This method only extends Rails' `create_table` method
+ #
+ # Example:
+ #
+ # create_table :db_guides do |t|
+ # t.bigint :stars, default: 0, null: false
+ # t.text :title, limit: 128
+ # t.text :notes, limit: 1024
+ #
+ # t.check_constraint 'stars > 1000', name: 'so_many_stars'
+ # end
+ #
+ # See Rails' `create_table` for more info on the available arguments.
+ #
+ # When adding foreign keys to other tables, consider wrapping the call into a with_lock_retries block
+ # to avoid traffic stalls.
+ def create_table(table_name, *args, **kwargs, &block)
+ helper_context = self
+
+ super do |t|
+ t.define_singleton_method(:text) do |column_name, **kwargs|
+ limit = kwargs.delete(:limit)
+
+ super(column_name, **kwargs)
+
+ if limit
+ # rubocop:disable GitlabSecurity/PublicSend
+ name = helper_context.send(:text_limit_name, table_name, column_name)
+ # rubocop:enable GitlabSecurity/PublicSend
+
+ column_name = helper_context.quote_column_name(column_name)
+ definition = "char_length(#{column_name}) <= #{limit}"
+
+ t.check_constraint(definition, name: name)
+ end
+ end
+
+ t.instance_eval(&block) unless block.nil?
+ end
+ end
+
+ # Executes the block with a retry mechanism that alters the +lock_timeout+ and +sleep_time+ between attempts.
+ # The timings can be controlled via the +timing_configuration+ parameter.
+ # If the lock was not acquired within the retry period, a last attempt is made without using +lock_timeout+.
+ #
+ # In order to retry the block, the method wraps the block into a transaction.
+ #
+ # When called inside an open transaction it will execute the block directly if lock retries are enabled
+ # with `enable_lock_retries!` at migration level, otherwise it will raise an error.
+ #
+ # ==== Examples
+ # # Invoking without parameters
+ # with_lock_retries do
+ # drop_table :my_table
+ # end
+ #
+ # # Invoking with custom +timing_configuration+
+ # t = [
+ # [1.second, 1.second],
+ # [2.seconds, 2.seconds]
+ # ]
+ #
+ # with_lock_retries(timing_configuration: t) do
+ # drop_table :my_table # this will be retried twice
+ # end
+ #
+ # # Disabling the retries using an environment variable
+ # > export DISABLE_LOCK_RETRIES=true
+ #
+ # with_lock_retries do
+ # drop_table :my_table # one invocation, it will not retry at all
+ # end
+ #
+ # ==== Parameters
+ # * +timing_configuration+ - [[ActiveSupport::Duration, ActiveSupport::Duration], ...] lock timeout for the block, sleep time before the next iteration, defaults to `Gitlab::Database::WithLockRetries::DEFAULT_TIMING_CONFIGURATION`
+ # * +logger+ - [Gitlab::JsonLogger]
+ # * +env+ - [Hash] custom environment hash, see the example with `DISABLE_LOCK_RETRIES`
+ def with_lock_retries(*args, **kwargs, &block)
+ if transaction_open?
+ if enable_lock_retries?
+ Gitlab::AppLogger.warn 'Lock retries already enabled, executing the block directly'
+ yield
+ else
+ raise <<~EOF
+ #{__callee__} can not be run inside an already open transaction
+
+ Use migration-level lock retries instead, see https://docs.gitlab.com/ee/development/migration_style_guide.html#retry-mechanism-when-acquiring-database-locks
+ EOF
+ end
+ else
+ super(*args, **kwargs.merge(allow_savepoints: false), &block)
+ end
+ end
+
# Renames a column without requiring downtime.
#
# Concurrent renames work by using database triggers to ensure both the
diff --git a/lib/gitlab/database/migrations/lock_retry_mixin.rb b/lib/gitlab/database/migrations/lock_retry_mixin.rb
new file mode 100644
index 00000000000..fff0f35e33c
--- /dev/null
+++ b/lib/gitlab/database/migrations/lock_retry_mixin.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ module LockRetryMixin
+ module ActiveRecordMigrationProxyLockRetries
+ def migration_class
+ migration.class
+ end
+
+ def enable_lock_retries?
+ # regular AR migrations don't have this,
+ # only ones inheriting from Gitlab::Database::Migration have
+ return false unless migration.respond_to?(:enable_lock_retries?)
+
+ migration.enable_lock_retries?
+ end
+ end
+
+ module ActiveRecordMigratorLockRetries
+ # We patch the original method to start a transaction
+ # using the WithLockRetries methodology for the whole migration.
+ def ddl_transaction(migration, &block)
+ if use_transaction?(migration) && migration.enable_lock_retries?
+ Gitlab::Database::WithLockRetries.new(
+ klass: migration.migration_class,
+ logger: Gitlab::BackgroundMigration::Logger
+ ).run(raise_on_exhaustion: false, &block)
+ else
+ super
+ end
+ end
+ end
+
+ def self.patch!
+ ActiveRecord::MigrationProxy.prepend(ActiveRecordMigrationProxyLockRetries)
+ ActiveRecord::Migrator.prepend(ActiveRecordMigratorLockRetries)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/partitioning.rb b/lib/gitlab/database/partitioning.rb
new file mode 100644
index 00000000000..bbde2063c41
--- /dev/null
+++ b/lib/gitlab/database/partitioning.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Partitioning
+ def self.register_models(models)
+ registered_models.merge(models)
+ end
+
+ def self.registered_models
+ @registered_models ||= Set.new
+ end
+
+ def self.sync_partitions(models_to_sync = registered_models)
+ MultiDatabasePartitionManager.new(models_to_sync).sync_partitions
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/partitioning/monthly_strategy.rb b/lib/gitlab/database/partitioning/monthly_strategy.rb
index 7992c2fdaa7..4cdde5bf2f1 100644
--- a/lib/gitlab/database/partitioning/monthly_strategy.rb
+++ b/lib/gitlab/database/partitioning/monthly_strategy.rb
@@ -4,17 +4,18 @@ module Gitlab
module Database
module Partitioning
class MonthlyStrategy
- attr_reader :model, :partitioning_key, :retain_for
+ attr_reader :model, :partitioning_key, :retain_for, :retain_non_empty_partitions
# We create this many partitions in the future
HEADROOM = 6.months
delegate :table_name, to: :model
- def initialize(model, partitioning_key, retain_for: nil)
+ def initialize(model, partitioning_key, retain_for: nil, retain_non_empty_partitions: false)
@model = model
@partitioning_key = partitioning_key
@retain_for = retain_for
+ @retain_non_empty_partitions = retain_non_empty_partitions
end
def current_partitions
@@ -29,7 +30,10 @@ module Gitlab
end
def extra_partitions
- current_partitions - desired_partitions
+ partitions = current_partitions - desired_partitions
+ partitions.reject!(&:holds_data?) if retain_non_empty_partitions
+
+ partitions
end
private
diff --git a/lib/gitlab/database/partitioning/multi_database_partition_manager.rb b/lib/gitlab/database/partitioning/multi_database_partition_manager.rb
new file mode 100644
index 00000000000..5a93e3fb1fb
--- /dev/null
+++ b/lib/gitlab/database/partitioning/multi_database_partition_manager.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Partitioning
+ class MultiDatabasePartitionManager
+ def initialize(models)
+ @models = models
+ end
+
+ def sync_partitions
+ Gitlab::AppLogger.info(message: "Syncing dynamic postgres partitions")
+
+ models.each do |model|
+ Gitlab::Database::SharedModel.using_connection(model.connection) do
+ Gitlab::AppLogger.debug(message: "Switched database connection",
+ connection_name: connection_name,
+ table_name: model.table_name)
+
+ PartitionManager.new(model).sync_partitions
+ end
+ end
+
+ Gitlab::AppLogger.info(message: "Finished sync of dynamic postgres partitions")
+ end
+
+ private
+
+ attr_reader :models
+
+ def connection_name
+ Gitlab::Database::SharedModel.connection.pool.db_config.name
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/partitioning/partition_manager.rb b/lib/gitlab/database/partitioning/partition_manager.rb
index 7e433ecdd39..8742c0ff166 100644
--- a/lib/gitlab/database/partitioning/partition_manager.rb
+++ b/lib/gitlab/database/partitioning/partition_manager.rb
@@ -6,60 +6,49 @@ module Gitlab
class PartitionManager
UnsafeToDetachPartitionError = Class.new(StandardError)
- def self.register(model)
- raise ArgumentError, "Only models with a #partitioning_strategy can be registered." unless model.respond_to?(:partitioning_strategy)
-
- models << model
- end
-
- def self.models
- @models ||= Set.new
- end
-
LEASE_TIMEOUT = 1.minute
MANAGEMENT_LEASE_KEY = 'database_partition_management_%s'
RETAIN_DETACHED_PARTITIONS_FOR = 1.week
- attr_reader :models
-
- def initialize(models = self.class.models)
- @models = models
+ def initialize(model)
+ @model = model
end
def sync_partitions
- Gitlab::AppLogger.info("Checking state of dynamic postgres partitions")
+ Gitlab::AppLogger.info(message: "Checking state of dynamic postgres partitions", table_name: model.table_name)
- models.each do |model|
- # Double-checking before getting the lease:
- # The prevailing situation is no missing partitions and no extra partitions
- next if missing_partitions(model).empty? && extra_partitions(model).empty?
+ # Double-checking before getting the lease:
+ # The prevailing situation is no missing partitions and no extra partitions
+ return if missing_partitions.empty? && extra_partitions.empty?
- only_with_exclusive_lease(model, lease_key: MANAGEMENT_LEASE_KEY) do
- partitions_to_create = missing_partitions(model)
- create(partitions_to_create) unless partitions_to_create.empty?
+ only_with_exclusive_lease(model, lease_key: MANAGEMENT_LEASE_KEY) do
+ partitions_to_create = missing_partitions
+ create(partitions_to_create) unless partitions_to_create.empty?
- if Feature.enabled?(:partition_pruning, default_enabled: :yaml)
- partitions_to_detach = extra_partitions(model)
- detach(partitions_to_detach) unless partitions_to_detach.empty?
- end
+ if Feature.enabled?(:partition_pruning, default_enabled: :yaml)
+ partitions_to_detach = extra_partitions
+ detach(partitions_to_detach) unless partitions_to_detach.empty?
end
- rescue StandardError => e
- Gitlab::AppLogger.error(message: "Failed to create / detach partition(s)",
- table_name: model.table_name,
- exception_class: e.class,
- exception_message: e.message)
end
+ rescue StandardError => e
+ Gitlab::AppLogger.error(message: "Failed to create / detach partition(s)",
+ table_name: model.table_name,
+ exception_class: e.class,
+ exception_message: e.message)
end
private
- def missing_partitions(model)
+ attr_reader :model
+ delegate :connection, to: :model
+
+ def missing_partitions
return [] unless connection.table_exists?(model.table_name)
model.partitioning_strategy.missing_partitions
end
- def extra_partitions(model)
+ def extra_partitions
return [] unless connection.table_exists?(model.table_name)
model.partitioning_strategy.extra_partitions
@@ -74,8 +63,9 @@ module Gitlab
end
def create(partitions)
- connection.transaction do
- with_lock_retries do
+ # with_lock_retries starts a requires_new transaction most of the time, but not on the last iteration
+ with_lock_retries do
+ connection.transaction(requires_new: false) do # so we open a transaction here if not already in progress
partitions.each do |partition|
connection.execute partition.to_sql
@@ -88,8 +78,9 @@ module Gitlab
end
def detach(partitions)
- connection.transaction do
- with_lock_retries do
+ # with_lock_retries starts a requires_new transaction most of the time, but not on the last iteration
+ with_lock_retries do
+ connection.transaction(requires_new: false) do # so we open a transaction here if not already in progress
partitions.each { |p| detach_one_partition(p) }
end
end
@@ -119,13 +110,10 @@ module Gitlab
def with_lock_retries(&block)
Gitlab::Database::WithLockRetries.new(
klass: self.class,
- logger: Gitlab::AppLogger
+ logger: Gitlab::AppLogger,
+ connection: connection
).run(&block)
end
-
- def connection
- ActiveRecord::Base.connection
- end
end
end
end
diff --git a/lib/gitlab/database/partitioning/partition_monitoring.rb b/lib/gitlab/database/partitioning/partition_monitoring.rb
index 6963ecd2cc1..e5b561fc447 100644
--- a/lib/gitlab/database/partitioning/partition_monitoring.rb
+++ b/lib/gitlab/database/partitioning/partition_monitoring.rb
@@ -6,7 +6,7 @@ module Gitlab
class PartitionMonitoring
attr_reader :models
- def initialize(models = PartitionManager.models)
+ def initialize(models = Gitlab::Database::Partitioning.registered_models)
@models = models
end
diff --git a/lib/gitlab/database/partitioning/time_partition.rb b/lib/gitlab/database/partitioning/time_partition.rb
index 1221f042530..e09ca483549 100644
--- a/lib/gitlab/database/partitioning/time_partition.rb
+++ b/lib/gitlab/database/partitioning/time_partition.rb
@@ -69,6 +69,10 @@ module Gitlab
partition_name <=> other.partition_name
end
+ def holds_data?
+ conn.execute("SELECT 1 FROM #{fully_qualified_partition} LIMIT 1").ntuples > 0
+ end
+
private
def date_or_nil(obj)
diff --git a/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers.rb b/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers.rb
index f1aa7871245..bd8ed677d77 100644
--- a/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers.rb
+++ b/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers.rb
@@ -6,6 +6,8 @@ module Gitlab
module ForeignKeyHelpers
include ::Gitlab::Database::SchemaHelpers
+ ERROR_SCOPE = 'foreign keys'
+
# Adds a foreign key with only minimal locking on the tables involved.
#
# In concept it works similarly to add_concurrent_foreign_key, but we have
@@ -32,6 +34,8 @@ module Gitlab
# name - The name of the foreign key.
#
def add_concurrent_partitioned_foreign_key(source, target, column:, on_delete: :cascade, name: nil)
+ assert_not_in_transaction_block(scope: ERROR_SCOPE)
+
partition_options = {
column: column,
on_delete: on_delete,
diff --git a/lib/gitlab/database/partitioning_migration_helpers/index_helpers.rb b/lib/gitlab/database/partitioning_migration_helpers/index_helpers.rb
index c0cc97de276..c9a3b5caf79 100644
--- a/lib/gitlab/database/partitioning_migration_helpers/index_helpers.rb
+++ b/lib/gitlab/database/partitioning_migration_helpers/index_helpers.rb
@@ -7,6 +7,8 @@ module Gitlab
include Gitlab::Database::MigrationHelpers
include Gitlab::Database::SchemaHelpers
+ ERROR_SCOPE = 'index'
+
# Concurrently creates a new index on a partitioned table. In concept this works similarly to
# `add_concurrent_index`, and won't block reads or writes on the table while the index is being built.
#
@@ -21,6 +23,8 @@ module Gitlab
#
# See Rails' `add_index` for more info on the available arguments.
def add_concurrent_partitioned_index(table_name, column_names, options = {})
+ assert_not_in_transaction_block(scope: ERROR_SCOPE)
+
raise ArgumentError, 'A name is required for indexes added to partitioned tables' unless options[:name]
partitioned_table = find_partitioned_table(table_name)
@@ -57,6 +61,8 @@ module Gitlab
#
# remove_concurrent_partitioned_index_by_name :users, 'index_name_goes_here'
def remove_concurrent_partitioned_index_by_name(table_name, index_name)
+ assert_not_in_transaction_block(scope: ERROR_SCOPE)
+
find_partitioned_table(table_name)
unless index_name_exists?(table_name, index_name)
diff --git a/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers.rb b/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers.rb
index 9ccbdc9930e..0dc9f92e4c8 100644
--- a/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers.rb
+++ b/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers.rb
@@ -431,7 +431,7 @@ module Gitlab
replace_table = Gitlab::Database::Partitioning::ReplaceTable.new(original_table_name.to_s,
replacement_table_name, replaced_table_name, primary_key_name)
- with_lock_retries do
+ transaction do
drop_sync_trigger(original_table_name)
replace_table.perform do |sql|
diff --git a/lib/gitlab/database/postgres_foreign_key.rb b/lib/gitlab/database/postgres_foreign_key.rb
index 94f74724295..72640f8785d 100644
--- a/lib/gitlab/database/postgres_foreign_key.rb
+++ b/lib/gitlab/database/postgres_foreign_key.rb
@@ -2,7 +2,7 @@
module Gitlab
module Database
- class PostgresForeignKey < ApplicationRecord
+ class PostgresForeignKey < SharedModel
self.primary_key = :oid
scope :by_referenced_table_identifier, ->(identifier) do
diff --git a/lib/gitlab/database/postgres_partition.rb b/lib/gitlab/database/postgres_partition.rb
index 7da60d8375d..eb080904f73 100644
--- a/lib/gitlab/database/postgres_partition.rb
+++ b/lib/gitlab/database/postgres_partition.rb
@@ -2,7 +2,7 @@
module Gitlab
module Database
- class PostgresPartition < ActiveRecord::Base
+ class PostgresPartition < SharedModel
self.primary_key = :identifier
belongs_to :postgres_partitioned_table, foreign_key: 'parent_identifier', primary_key: 'identifier'
diff --git a/lib/gitlab/database/postgres_partitioned_table.rb b/lib/gitlab/database/postgres_partitioned_table.rb
index 5d2eaa22ee4..3bd342f940f 100644
--- a/lib/gitlab/database/postgres_partitioned_table.rb
+++ b/lib/gitlab/database/postgres_partitioned_table.rb
@@ -2,7 +2,7 @@
module Gitlab
module Database
- class PostgresPartitionedTable < ActiveRecord::Base
+ class PostgresPartitionedTable < SharedModel
DYNAMIC_PARTITION_STRATEGIES = %w[range list].freeze
self.primary_key = :identifier
diff --git a/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb b/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb
index a2e7f4befab..59ca06b5aca 100644
--- a/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb
+++ b/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb
@@ -7,7 +7,7 @@ module Gitlab
extend ActiveSupport::Concern
def dump_schema_information # :nodoc:
- Gitlab::Database::SchemaMigrations.touch_all(self)
+ Gitlab::Database::SchemaMigrations.touch_all(self) if Gitlab.dev_or_test_env?
nil
end
diff --git a/lib/gitlab/database/rename_table_helpers.rb b/lib/gitlab/database/rename_table_helpers.rb
index 7f5af038c6d..e881c0e5455 100644
--- a/lib/gitlab/database/rename_table_helpers.rb
+++ b/lib/gitlab/database/rename_table_helpers.rb
@@ -4,27 +4,27 @@ module Gitlab
module Database
module RenameTableHelpers
def rename_table_safely(old_table_name, new_table_name)
- with_lock_retries do
+ transaction do
rename_table(old_table_name, new_table_name)
execute("CREATE VIEW #{old_table_name} AS SELECT * FROM #{new_table_name}")
end
end
def undo_rename_table_safely(old_table_name, new_table_name)
- with_lock_retries do
+ transaction do
execute("DROP VIEW IF EXISTS #{old_table_name}")
rename_table(new_table_name, old_table_name)
end
end
def finalize_table_rename(old_table_name, new_table_name)
- with_lock_retries do
+ transaction do
execute("DROP VIEW IF EXISTS #{old_table_name}")
end
end
def undo_finalize_table_rename(old_table_name, new_table_name)
- with_lock_retries do
+ transaction do
execute("CREATE VIEW #{old_table_name} AS SELECT * FROM #{new_table_name}")
end
end
diff --git a/lib/gitlab/database/schema_migrations/context.rb b/lib/gitlab/database/schema_migrations/context.rb
index 35105121bbd..a95f85c6bef 100644
--- a/lib/gitlab/database/schema_migrations/context.rb
+++ b/lib/gitlab/database/schema_migrations/context.rb
@@ -6,7 +6,7 @@ module Gitlab
class Context
attr_reader :connection
- DEFAULT_SCHEMA_MIGRATIONS_PATH = "db/schema_migrations"
+ class_attribute :default_schema_migrations_path, default: 'db/schema_migrations'
def initialize(connection)
@connection = connection
@@ -30,7 +30,7 @@ module Gitlab
end
def database_schema_migrations_path
- @connection.pool.db_config.configuration_hash[:schema_migrations_path] || DEFAULT_SCHEMA_MIGRATIONS_PATH
+ @connection.pool.db_config.configuration_hash[:schema_migrations_path] || self.class.default_schema_migrations_path
end
end
end
diff --git a/lib/gitlab/database/shared_model.rb b/lib/gitlab/database/shared_model.rb
new file mode 100644
index 00000000000..8f256758961
--- /dev/null
+++ b/lib/gitlab/database/shared_model.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ class SharedModel < ActiveRecord::Base
+ self.abstract_class = true
+
+ class << self
+ def using_connection(connection)
+ raise 'cannot nest connection overrides for shared models' unless overriding_connection.nil?
+
+ self.overriding_connection = connection
+
+ yield
+ ensure
+ self.overriding_connection = nil
+ end
+
+ def connection
+ if connection = self.overriding_connection
+ connection
+ else
+ super
+ end
+ end
+
+ private
+
+ def overriding_connection
+ Thread.current[:overriding_connection]
+ end
+
+ def overriding_connection=(connection)
+ Thread.current[:overriding_connection] = connection
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/transaction/context.rb b/lib/gitlab/database/transaction/context.rb
index a50dd30b75b..a902537f02e 100644
--- a/lib/gitlab/database/transaction/context.rb
+++ b/lib/gitlab/database/transaction/context.rb
@@ -6,9 +6,8 @@ module Gitlab
class Context
attr_reader :context
- LOG_DEPTH_THRESHOLD = 8
- LOG_SAVEPOINTS_THRESHOLD = 32
- LOG_DURATION_S_THRESHOLD = 300
+ LOG_SAVEPOINTS_THRESHOLD = 1 # 1 `SAVEPOINT` created in a transaction
+ LOG_DURATION_S_THRESHOLD = 120 # transaction that is running for 2 minutes or longer
LOG_THROTTLE_DURATION = 1
def initialize
@@ -19,6 +18,10 @@ module Gitlab
@context[:start_time] = current_timestamp
end
+ def set_depth(depth)
+ @context[:depth] = [@context[:depth].to_i, depth].max
+ end
+
def increment_savepoints
@context[:savepoints] = @context[:savepoints].to_i + 1
end
@@ -31,42 +34,33 @@ module Gitlab
@context[:releases] = @context[:releases].to_i + 1
end
- def set_depth(depth)
- @context[:depth] = [@context[:depth].to_i, depth].max
- end
-
def track_sql(sql)
(@context[:queries] ||= []).push(sql)
end
+ def track_backtrace(backtrace)
+ cleaned_backtrace = Gitlab::BacktraceCleaner.clean_backtrace(backtrace)
+ (@context[:backtraces] ||= []).push(cleaned_backtrace)
+ end
+
def duration
return unless @context[:start_time].present?
current_timestamp - @context[:start_time]
end
- def depth_threshold_exceeded?
- @context[:depth].to_i > LOG_DEPTH_THRESHOLD
- end
-
def savepoints_threshold_exceeded?
- @context[:savepoints].to_i > LOG_SAVEPOINTS_THRESHOLD
+ @context[:savepoints].to_i >= LOG_SAVEPOINTS_THRESHOLD
end
def duration_threshold_exceeded?
- duration.to_i > LOG_DURATION_S_THRESHOLD
- end
-
- def log_savepoints?
- depth_threshold_exceeded? || savepoints_threshold_exceeded?
- end
-
- def log_duration?
- duration_threshold_exceeded?
+ duration.to_i >= LOG_DURATION_S_THRESHOLD
end
def should_log?
- !logged_already? && (log_savepoints? || log_duration?)
+ return false if logged_already?
+
+ savepoints_threshold_exceeded? || duration_threshold_exceeded?
end
def commit
@@ -77,6 +71,10 @@ module Gitlab
log(:rollback)
end
+ def backtraces
+ @context[:backtraces].to_a
+ end
+
private
def queries
@@ -110,7 +108,8 @@ module Gitlab
savepoints_count: @context[:savepoints].to_i,
rollbacks_count: @context[:rollbacks].to_i,
releases_count: @context[:releases].to_i,
- sql: queries
+ sql: queries,
+ savepoint_backtraces: backtraces
}
application_info(attributes)
diff --git a/lib/gitlab/database/transaction/observer.rb b/lib/gitlab/database/transaction/observer.rb
index 7888f0916e3..ad6886a3d52 100644
--- a/lib/gitlab/database/transaction/observer.rb
+++ b/lib/gitlab/database/transaction/observer.rb
@@ -21,9 +21,10 @@ module Gitlab
context.set_start_time
context.set_depth(0)
context.track_sql(event.payload[:sql])
- elsif cmd.start_with?('SAVEPOINT ')
+ elsif cmd.start_with?('SAVEPOINT', 'EXCEPTION')
context.set_depth(manager.open_transactions)
context.increment_savepoints
+ context.track_backtrace(caller)
elsif cmd.start_with?('ROLLBACK TO SAVEPOINT')
context.increment_rollbacks
elsif cmd.start_with?('RELEASE SAVEPOINT ')
diff --git a/lib/gitlab/database/with_lock_retries.rb b/lib/gitlab/database/with_lock_retries.rb
index bbf8f133f0f..f9d467ae5cc 100644
--- a/lib/gitlab/database/with_lock_retries.rb
+++ b/lib/gitlab/database/with_lock_retries.rb
@@ -61,13 +61,15 @@ module Gitlab
[10.seconds, 10.minutes]
].freeze
- def initialize(logger: NULL_LOGGER, timing_configuration: DEFAULT_TIMING_CONFIGURATION, klass: nil, env: ENV)
+ def initialize(logger: NULL_LOGGER, allow_savepoints: true, timing_configuration: DEFAULT_TIMING_CONFIGURATION, klass: nil, env: ENV, connection: ActiveRecord::Base.connection)
@logger = logger
@klass = klass
+ @allow_savepoints = allow_savepoints
@timing_configuration = timing_configuration
@env = env
@current_iteration = 1
@log_params = { method: 'with_lock_retries', class: klass.to_s }
+ @connection = connection
end
# Executes a block of code, retrying it whenever a database lock can't be acquired in time
@@ -95,7 +97,7 @@ module Gitlab
run_block_with_lock_timeout
rescue ActiveRecord::LockWaitTimeout
if retry_with_lock_timeout?
- disable_idle_in_transaction_timeout if ActiveRecord::Base.connection.transaction_open?
+ disable_idle_in_transaction_timeout if connection.transaction_open?
wait_until_next_retry
reset_db_settings
@@ -115,14 +117,16 @@ module Gitlab
private
- attr_reader :logger, :env, :block, :current_iteration, :log_params, :timing_configuration
+ attr_reader :logger, :env, :block, :current_iteration, :log_params, :timing_configuration, :connection
def run_block
block.call
end
def run_block_with_lock_timeout
- ActiveRecord::Base.transaction(requires_new: true) do
+ raise "WithLockRetries should not run inside already open transaction" if connection.transaction_open? && @allow_savepoints.blank?
+
+ connection.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
execute("SET LOCAL lock_timeout TO '#{current_lock_timeout_in_ms}ms'")
log(message: 'Lock timeout is set', current_iteration: current_iteration, lock_timeout_in_ms: current_lock_timeout_in_ms)
@@ -149,7 +153,7 @@ module Gitlab
log(message: "Couldn't acquire lock to perform the migration", current_iteration: current_iteration)
log(message: "Executing the migration without lock timeout", current_iteration: current_iteration)
- disable_lock_timeout if ActiveRecord::Base.connection.transaction_open?
+ disable_lock_timeout if connection.transaction_open?
run_block
@@ -165,7 +169,7 @@ module Gitlab
end
def execute(statement)
- ActiveRecord::Base.connection.execute(statement)
+ connection.execute(statement)
end
def retry_count