Commit e693ec99 authored by Quang-Minh Nguyen's avatar Quang-Minh Nguyen

Handle read-only transaction state

Issue https://gitlab.com/gitlab-org/gitlab/-/issues/322133
parent 2c48e99e
...@@ -10,6 +10,9 @@ module Gitlab ...@@ -10,6 +10,9 @@ module Gitlab
# The ConnectionProxy class redirects ActiveRecord connection requests to # The ConnectionProxy class redirects ActiveRecord connection requests to
# the right load balancer pool, depending on the type of query. # the right load balancer pool, depending on the type of query.
class ConnectionProxy class ConnectionProxy
WriteInsideReadOnlyTransactionError = Class.new(StandardError)
READ_ONLY_TRANSACTION_KEY = :load_balacing_read_only_transaction
attr_reader :load_balancer attr_reader :load_balancer
# These methods perform writes after which we need to stick to the # These methods perform writes after which we need to stick to the
...@@ -58,10 +61,14 @@ module Gitlab ...@@ -58,10 +61,14 @@ module Gitlab
def transaction(*args, &block) def transaction(*args, &block)
if ::Gitlab::Database::LoadBalancing::Session.current.use_replica? if ::Gitlab::Database::LoadBalancing::Session.current.use_replica?
track_read_only_transaction!
read_using_load_balancer(:transaction, args, &block) read_using_load_balancer(:transaction, args, &block)
else else
write_using_load_balancer(:transaction, args, sticky: true, &block) write_using_load_balancer(:transaction, args, sticky: true, &block)
end end
ensure
untrack_read_only_transaction!
end end
# Delegates all unknown messages to a read-write connection. # Delegates all unknown messages to a read-write connection.
...@@ -90,6 +97,10 @@ module Gitlab ...@@ -90,6 +97,10 @@ module Gitlab
# sticky - If set to true the session will stick to the master after # sticky - If set to true the session will stick to the master after
# the write. # the write.
def write_using_load_balancer(name, args, sticky: false, &block) def write_using_load_balancer(name, args, sticky: false, &block)
if read_only_transaction?
raise WriteInsideReadOnlyTransactionError, 'A write query is performed inside a read-only transaction'
end
result = @load_balancer.read_write do |connection| result = @load_balancer.read_write do |connection|
# Sticking has to be enabled before calling the method. Not doing so # 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 # could lead to methods called in a block still being performed on a
...@@ -101,6 +112,20 @@ module Gitlab ...@@ -101,6 +112,20 @@ module Gitlab
result result
end end
private
def track_read_only_transaction!
Thread.current[READ_ONLY_TRANSACTION_KEY] = true
end
def untrack_read_only_transaction!
Thread.current[READ_ONLY_TRANSACTION_KEY] = nil
end
def read_only_transaction?
Thread.current[READ_ONLY_TRANSACTION_KEY] == true
end
end end
end end
end end
......
...@@ -116,44 +116,69 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do ...@@ -116,44 +116,69 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
end end
context 'session prefers to use a replica' do context 'session prefers to use a replica' do
let(:replica) { double(:connection) }
before do before do
allow(session).to receive(:use_replica?).and_return(true) allow(session).to receive(:use_replica?).and_return(true)
allow(session).to receive(:use_primary?).and_return(false) allow(session).to receive(:use_primary?).and_return(false)
allow(replica).to receive(:transaction).and_yield
allow(replica).to receive(:select)
end end
it 'runs the transaction and any nested queries on the replica' do context 'with a read query' do
replica = double(:connection) it 'runs the transaction and any nested queries on the replica' do
expect(proxy.load_balancer).to receive(:read)
.twice.and_yield(replica)
expect(proxy.load_balancer).not_to receive(:read_write)
expect(session).not_to receive(:write!)
allow(replica).to receive(:transaction).and_yield proxy.transaction { proxy.select('true') }
allow(replica).to receive(:select) end
end
expect(proxy.load_balancer).to receive(:read) context 'with a write query' do
.twice.and_yield(replica) it 'raises an exception' do
expect(proxy.load_balancer).not_to receive(:read_write) allow(proxy.load_balancer).to receive(:read).and_yield(replica)
expect(session).not_to receive(:write!) allow(proxy.load_balancer).to receive(:read_write).and_yield(replica)
proxy.transaction { proxy.select('true') } expect do
proxy.transaction { proxy.insert('something') }
end.to raise_error(Gitlab::Database::LoadBalancing::ConnectionProxy::WriteInsideReadOnlyTransactionError)
end
end end
end end
context 'session does not prefer to use a replica' do context 'session does not prefer to use a replica' do
let(:primary) { double(:connection) }
before do before do
allow(session).to receive(:use_replica?).and_return(false) allow(session).to receive(:use_replica?).and_return(false)
allow(session).to receive(:use_primary?).and_return(true) allow(session).to receive(:use_primary?).and_return(true)
allow(primary).to receive(:transaction).and_yield
allow(primary).to receive(:select)
allow(primary).to receive(:insert)
end end
it 'runs the transaction and any nested queries on the primary and stick to it' do context 'with a read query' do
primary = double(:connection) it 'runs the transaction and any nested queries on the primary and stick to it' do
expect(proxy.load_balancer).to receive(:read_write)
.twice.and_yield(primary)
expect(proxy.load_balancer).not_to receive(:read)
expect(session).to receive(:write!)
allow(primary).to receive(:transaction).and_yield proxy.transaction { proxy.select('true') }
allow(primary).to receive(:select) end
end
expect(proxy.load_balancer).to receive(:read_write) context 'with a write query' do
.twice.and_yield(primary) it 'runs the transaction and any nested queries on the primary and stick to it' do
expect(proxy.load_balancer).not_to receive(:read) expect(proxy.load_balancer).to receive(:read_write)
expect(session).to receive(:write!) .twice.and_yield(primary)
expect(proxy.load_balancer).not_to receive(:read)
expect(session).to receive(:write!).twice
proxy.transaction { proxy.select('true') } proxy.transaction { proxy.insert('something') }
end
end end
end end
end end
......
...@@ -415,6 +415,43 @@ RSpec.describe Gitlab::Database::LoadBalancing do ...@@ -415,6 +415,43 @@ RSpec.describe Gitlab::Database::LoadBalancing do
# instrumentaiton) while triggering real queries from the defined model. # instrumentaiton) while triggering real queries from the defined model.
# - We assert the desinations (replica/primary) of the queries in order. # - We assert the desinations (replica/primary) of the queries in order.
describe 'LoadBalancing integration tests', :delete do describe 'LoadBalancing integration tests', :delete do
shared_context 'LoadBalancing setup' do
let!(:license) { create(:license, plan: ::License::PREMIUM_PLAN) }
let(:hosts) { [ActiveRecord::Base.configurations["development"]['host']] }
let(:model) do
Class.new(ApplicationRecord) do
self.table_name = "load_balancing_test"
end
end
before do
ActiveRecord::Schema.define do
create_table :load_balancing_test, force: true do |t|
t.string :name, null: true
end
end
# Preloading testing class
model.singleton_class.prepend ::Gitlab::Database::LoadBalancing::ActiveRecordProxy
# Setup load balancing
subject.clear_configuration
allow(ActiveRecord::Base.singleton_class).to receive(:prepend)
subject.configure_proxy(::Gitlab::Database::LoadBalancing::ConnectionProxy.new(hosts))
allow(ActiveRecord::Base.configurations[Rails.env])
.to receive(:[])
.with('load_balancing')
.and_return('hosts' => hosts)
::Gitlab::Database::LoadBalancing::Session.clear_session
end
after do
subject.clear_configuration
ActiveRecord::Schema.define do
drop_table :load_balancing_test, force: true
end
end
end
where(:queries, :include_transaction, :expected_results) do where(:queries, :include_transaction, :expected_results) do
[ [
# Read methods # Read methods
...@@ -573,7 +610,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do ...@@ -573,7 +610,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do
false, [:primary] false, [:primary]
], ],
# use_replica_if_possible inside use_primary! # use_replica_if_possible inside use_primary
[ [
-> { -> {
::Gitlab::Database::LoadBalancing::Session.current.use_primary do ::Gitlab::Database::LoadBalancing::Session.current.use_primary do
...@@ -583,45 +620,36 @@ RSpec.describe Gitlab::Database::LoadBalancing do ...@@ -583,45 +620,36 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end end
}, },
false, [:primary] false, [:primary]
],
# use_primary inside use_replica_if_possible
[
-> {
::Gitlab::Database::LoadBalancing::Session.current.use_replica_if_possible do
::Gitlab::Database::LoadBalancing::Session.current.use_primary do
model.first
end
end
},
false, [:primary]
],
# A write query inside use_replica_if_possible
[
-> {
::Gitlab::Database::LoadBalancing::Session.current.use_replica_if_possible do
model.first
model.delete_all
model.where(name: 'test1').to_a
end
},
false, [:replica, :primary, :primary]
] ]
] ]
end end
with_them do with_them do
let!(:license) { create(:license, plan: ::License::PREMIUM_PLAN) } include_context 'LoadBalancing setup'
let(:hosts) { [ActiveRecord::Base.configurations["development"]['host']] }
let(:model) do
Class.new(ApplicationRecord) do
self.table_name = "load_balancing_test"
end
end
before do
ActiveRecord::Schema.define do
create_table :load_balancing_test, force: true do |t|
t.string :name, null: true
end
end
# Preloading testing class
model.singleton_class.prepend ::Gitlab::Database::LoadBalancing::ActiveRecordProxy
# Setup load balancing
subject.clear_configuration
allow(ActiveRecord::Base.singleton_class).to receive(:prepend)
subject.configure_proxy(::Gitlab::Database::LoadBalancing::ConnectionProxy.new(hosts))
allow(ActiveRecord::Base.configurations[Rails.env])
.to receive(:[])
.with('load_balancing')
.and_return('hosts' => hosts)
::Gitlab::Database::LoadBalancing::Session.clear_session
end
after do
subject.clear_configuration
ActiveRecord::Schema.define do
drop_table :load_balancing_test, force: true
end
end
it 'redirects queries to the right roles' do it 'redirects queries to the right roles' do
roles = [] roles = []
...@@ -653,5 +681,20 @@ RSpec.describe Gitlab::Database::LoadBalancing do ...@@ -653,5 +681,20 @@ RSpec.describe Gitlab::Database::LoadBalancing do
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end end
end end
context 'a write inside a transaction inside use_replica_if_possible block' do
include_context 'LoadBalancing setup'
it 'raises an exception' do
expect do
::Gitlab::Database::LoadBalancing::Session.current.use_replica_if_possible do
model.transaction do
model.first
model.create!(name: 'hello')
end
end
end.to raise_error(Gitlab::Database::LoadBalancing::ConnectionProxy::WriteInsideReadOnlyTransactionError)
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment