278 lines
9.9 KiB
Ruby
278 lines
9.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Gitlab
|
|
module Database
|
|
module LoadBalancing
|
|
# Load balancing for ActiveRecord connections.
|
|
#
|
|
# Each host in the load balancer uses the same credentials as the primary
|
|
# database.
|
|
#
|
|
# This class *requires* that `ActiveRecord::Base.retrieve_connection`
|
|
# always returns a connection to the primary.
|
|
class LoadBalancer
|
|
CACHE_KEY = :gitlab_load_balancer_host
|
|
VALID_HOSTS_CACHE_KEY = :gitlab_load_balancer_valid_hosts
|
|
|
|
attr_reader :host_list
|
|
|
|
# hosts - The hostnames/addresses of the additional databases.
|
|
def initialize(hosts = [])
|
|
@host_list = HostList.new(hosts.map { |addr| Host.new(addr, self) })
|
|
@connection_db_roles = {}.compare_by_identity
|
|
@connection_db_roles_count = {}.compare_by_identity
|
|
end
|
|
|
|
# Yields a connection that can be used for reads.
|
|
#
|
|
# If no secondaries were available this method will use the primary
|
|
# instead.
|
|
def read(&block)
|
|
connection = nil
|
|
conflict_retried = 0
|
|
|
|
while host
|
|
ensure_caching!
|
|
|
|
begin
|
|
connection = host.connection
|
|
track_connection_role(connection, ROLE_REPLICA)
|
|
|
|
return yield connection
|
|
rescue StandardError => error
|
|
untrack_connection_role(connection)
|
|
|
|
if serialization_failure?(error)
|
|
# This error can occur when a query conflicts. See
|
|
# https://www.postgresql.org/docs/current/static/hot-standby.html#HOT-STANDBY-CONFLICT
|
|
# for more information.
|
|
#
|
|
# In this event we'll cycle through the secondaries at most 3
|
|
# times before using the primary instead.
|
|
will_retry = conflict_retried < @host_list.length * 3
|
|
|
|
LoadBalancing::Logger.warn(
|
|
event: :host_query_conflict,
|
|
message: 'Query conflict on host',
|
|
conflict_retried: conflict_retried,
|
|
will_retry: will_retry,
|
|
db_host: host.host,
|
|
db_port: host.port,
|
|
host_list_length: @host_list.length
|
|
)
|
|
|
|
if will_retry
|
|
conflict_retried += 1
|
|
release_host
|
|
else
|
|
break
|
|
end
|
|
elsif connection_error?(error)
|
|
host.offline!
|
|
release_host
|
|
else
|
|
raise error
|
|
end
|
|
end
|
|
end
|
|
|
|
LoadBalancing::Logger.warn(
|
|
event: :no_secondaries_available,
|
|
message: 'No secondaries were available, using primary instead',
|
|
conflict_retried: conflict_retried,
|
|
host_list_length: @host_list.length
|
|
)
|
|
|
|
read_write(&block)
|
|
ensure
|
|
untrack_connection_role(connection)
|
|
end
|
|
|
|
# Yields a connection that can be used for both reads and writes.
|
|
def read_write
|
|
connection = nil
|
|
# In the event of a failover the primary may be briefly unavailable.
|
|
# Instead of immediately grinding to a halt we'll retry the operation
|
|
# a few times.
|
|
retry_with_backoff do
|
|
connection = ActiveRecord::Base.retrieve_connection
|
|
track_connection_role(connection, ROLE_PRIMARY)
|
|
|
|
yield connection
|
|
end
|
|
ensure
|
|
untrack_connection_role(connection)
|
|
end
|
|
|
|
# Recognize the role (primary/replica) of the database this connection
|
|
# is connecting to. If the connection is not issued by this load
|
|
# balancer, return nil
|
|
def db_role_for_connection(connection)
|
|
return @connection_db_roles[connection] if @connection_db_roles[connection]
|
|
return ROLE_REPLICA if @host_list.manage_pool?(connection.pool)
|
|
return ROLE_PRIMARY if connection.pool == ActiveRecord::Base.connection_pool
|
|
end
|
|
|
|
# Returns a host to use for queries.
|
|
#
|
|
# Hosts are scoped per thread so that multiple threads don't
|
|
# accidentally re-use the same host + connection.
|
|
def host
|
|
RequestStore[CACHE_KEY] ||= current_host_list.next
|
|
end
|
|
|
|
# Releases the host and connection for the current thread.
|
|
def release_host
|
|
if host = RequestStore[CACHE_KEY]
|
|
host.disable_query_cache!
|
|
host.release_connection
|
|
end
|
|
|
|
RequestStore.delete(CACHE_KEY)
|
|
RequestStore.delete(VALID_HOSTS_CACHE_KEY)
|
|
end
|
|
|
|
def release_primary_connection
|
|
ActiveRecord::Base.connection_pool.release_connection
|
|
end
|
|
|
|
# Returns the transaction write location of the primary.
|
|
def primary_write_location
|
|
location = read_write do |connection|
|
|
::Gitlab::Database.get_write_location(connection)
|
|
end
|
|
|
|
return location if location
|
|
|
|
raise 'Failed to determine the write location of the primary database'
|
|
end
|
|
|
|
# Returns true if there was at least one host that has caught up with the given transaction.
|
|
#
|
|
# In case of a retry, this method also stores the set of hosts that have caught up.
|
|
#
|
|
# UPD: `select_caught_up_hosts` seems to have redundant logic managing host list (`:gitlab_load_balancer_valid_hosts`),
|
|
# while we only need a single host: https://gitlab.com/gitlab-org/gitlab/-/issues/326125#note_615271604
|
|
# Also, shuffling the list afterwards doesn't seem to be necessary.
|
|
# This may be improved by merging this method with `select_up_to_date_host`.
|
|
# Could be removed when `:load_balancing_refine_load_balancer_methods` FF is rolled out
|
|
def select_caught_up_hosts(location)
|
|
all_hosts = @host_list.hosts
|
|
valid_hosts = all_hosts.select { |host| host.caught_up?(location) }
|
|
|
|
return false if valid_hosts.empty?
|
|
|
|
# Hosts can come online after the time when this scan was done,
|
|
# so we need to remember the ones that can be used. If the host went
|
|
# offline, we'll just rely on the retry mechanism to use the primary.
|
|
set_consistent_hosts_for_request(HostList.new(valid_hosts))
|
|
|
|
# Since we will be using a subset from the original list, let's just
|
|
# pick a random host and mix up the original list to ensure we don't
|
|
# only end up using one replica.
|
|
RequestStore[CACHE_KEY] = valid_hosts.sample
|
|
@host_list.shuffle
|
|
|
|
true
|
|
end
|
|
|
|
# Returns true if there was at least one host that has caught up with the given transaction.
|
|
# Similar to `#select_caught_up_hosts`, picks a random host, to rotate replicas we use.
|
|
# Unlike `#select_caught_up_hosts`, does not iterate over all hosts if finds any.
|
|
#
|
|
# It is going to be merged with `select_caught_up_hosts`, because they intend to do the same.
|
|
def select_up_to_date_host(location)
|
|
all_hosts = @host_list.hosts.shuffle
|
|
host = all_hosts.find { |host| host.caught_up?(location) }
|
|
|
|
return false unless host
|
|
|
|
RequestStore[CACHE_KEY] = host
|
|
|
|
true
|
|
end
|
|
|
|
# Could be removed when `:load_balancing_refine_load_balancer_methods` FF is rolled out
|
|
def set_consistent_hosts_for_request(hosts)
|
|
RequestStore[VALID_HOSTS_CACHE_KEY] = hosts
|
|
end
|
|
|
|
# Yields a block, retrying it upon error using an exponential backoff.
|
|
def retry_with_backoff(retries = 3, time = 2)
|
|
retried = 0
|
|
last_error = nil
|
|
|
|
while retried < retries
|
|
begin
|
|
return yield
|
|
rescue StandardError => error
|
|
raise error unless connection_error?(error)
|
|
|
|
# We need to release the primary connection as otherwise Rails
|
|
# will keep raising errors when using the connection.
|
|
release_primary_connection
|
|
|
|
last_error = error
|
|
sleep(time)
|
|
retried += 1
|
|
time **= 2
|
|
end
|
|
end
|
|
|
|
raise last_error
|
|
end
|
|
|
|
def connection_error?(error)
|
|
case error
|
|
when ActiveRecord::StatementInvalid, ActionView::Template::Error
|
|
# After connecting to the DB Rails will wrap query errors using this
|
|
# class.
|
|
connection_error?(error.cause)
|
|
when *CONNECTION_ERRORS
|
|
true
|
|
else
|
|
# When PG tries to set the client encoding but fails due to a
|
|
# connection error it will raise a PG::Error instance. Catching that
|
|
# would catch all errors (even those we don't want), so instead we
|
|
# check for the message of the error.
|
|
error.message.start_with?('invalid encoding name:')
|
|
end
|
|
end
|
|
|
|
def serialization_failure?(error)
|
|
if error.cause
|
|
serialization_failure?(error.cause)
|
|
else
|
|
error.is_a?(PG::TRSerializationFailure)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def ensure_caching!
|
|
host.enable_query_cache! unless host.query_cache_enabled
|
|
end
|
|
|
|
def track_connection_role(connection, role)
|
|
@connection_db_roles[connection] = role
|
|
@connection_db_roles_count[connection] ||= 0
|
|
@connection_db_roles_count[connection] += 1
|
|
end
|
|
|
|
def untrack_connection_role(connection)
|
|
return if connection.blank? || @connection_db_roles_count[connection].blank?
|
|
|
|
@connection_db_roles_count[connection] -= 1
|
|
if @connection_db_roles_count[connection] <= 0
|
|
@connection_db_roles.delete(connection)
|
|
@connection_db_roles_count.delete(connection)
|
|
end
|
|
end
|
|
|
|
def current_host_list
|
|
RequestStore[VALID_HOSTS_CACHE_KEY] || @host_list
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|