Is it possible to apply to my own project regarding multiple DB support implemented from Rails 6.0? I will follow the implementation to judge that
https://railsguides.jp/active_record_multiple_databases.html#コネクションの自動切り替えを有効にする
The auto-switch feature allows the application to switch from primary to replica or replica to primary depending on the HTTP verb and the presence or absence of the most recent write. When an application receives a POST, PUT, DELETE, or PATCH request, it automatically writes to the primary. The application reads from primary until the specified time elapses after writing. When the application receives a GET or HEAD request, it reads from the replica if there is no recent write.
It is stated that it is performed by two types of judgment, judgment by HTTP method + judgment by time from writing. The setting method is described below
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
Let's start with the application part of middleware
activerecord-6.0.3.2/lib/active_record/railtie.rb
class Railtie < Rails::Railtie # :nodoc:
config.active_record = ActiveSupport::OrderedOptions.new
initializer "active_record.database_selector" do
if options = config.active_record.delete(:database_selector)
resolver = config.active_record.delete(:database_resolver)
operations = config.active_record.delete(:database_resolver_context)
config.app_middleware.use ActiveRecord::Middleware::DatabaseSelector, resolver, operations, options
end
end
--Executed if database_selector
is set
--database_resolver
and database_resolver_context
are now just passed
Next, we will follow the implementation of the middleware part
https://api.rubyonrails.org/classes/ActiveSupport/OrderedOptions.html
A mechanism that allows Hash to be accessed with dots Returns nil if not set with delete
Ruby2.6.5 pry(main)> x = ActiveSupport::OrderedOptions.new
=> {}
Ruby2.6.5 pry(main)> x.delete(:hoge)
=> nil
activerecord-6.0.3.2/lib/active_record/middleware/database_selector.rb
module ActiveRecord
module Middleware
class DatabaseSelector
def initialize(app, resolver_klass = nil, context_klass = nil, options = {})
@app = app
@resolver_klass = resolver_klass || Resolver
@context_klass = context_klass || Resolver::Session
@options = options
end
attr_reader :resolver_klass, :context_klass, :options
def call(env)
request = ActionDispatch::Request.new(env)
select_database(request) do
@app.call(env)
end
end
--If database_resolver
is not set, DatabaseSelector :: Resolver
is set.
--If database_resolver_context
is not set, DatabaseSelector :: Resolver :: Session
is set.
Next, let's take a look at the important #select_database
DatabaseSelector#select_database
module ActiveRecord
module Middleware
class DatabaseSelector
private
def select_database(request, &blk)
context = context_klass.call(request)
resolver = resolver_klass.call(context, options)
if reading_request?(request)
resolver.read(&blk)
else
resolver.write(&blk)
end
end
def reading_request?(request)
request.get? || request.head?
end
end
end
end
Since context
is executed first and resolver
is created based on it, let's check context
first.
Resolver::Session.call
class Resolver # :nodoc:
class Session # :nodoc:
def self.call(request)
new(request.session)
end
def initialize(session)
@session = session
end
attr_reader :session
.call
is just # new
, here only the session of ʻActionDispatch :: Requestis set So next, check Resolver's
.call`
Resolver.call
class Resolver # :nodoc:
SEND_TO_REPLICA_DELAY = 2.seconds
def self.call(context, options = {})
new(context, options)
end
def initialize(context, options = {})
@context = context
@options = options
@delay = @options && @options[:delay] ? @options[:delay] : SEND_TO_REPLICA_DELAY
@instrumenter = ActiveSupport::Notifications.instrumenter
end
attr_reader :context, :delay, :instrumenter
I'm just new here as well, so I'll read the original DatabaseSelector # select_database
a little more.
def select_database(request, &blk)
context = context_klass.call(request)
resolver = resolver_klass.call(context, options)
if reading_request?(request)
resolver.read(&blk)
else
resolver.write(&blk)
end
end
def reading_request?(request)
request.get? || request.head?
end
Here for GET or HEAD => read Otherwise => write Is executing resolver processing as (Resolver seems to be making further judgments)
Let's start with read
Resolver#read
class Resolver # :nodoc:
def read(&blk)
if read_from_primary?
read_from_primary(&blk)
else
read_from_replica(&blk)
end
end
private
def read_from_primary(&blk)
ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role, prevent_writes: true) do
instrumenter.instrument("database_selector.active_record.read_from_primary") do
yield
end
end
end
def read_from_replica(&blk)
ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role, prevent_writes: true) do
instrumenter.instrument("database_selector.active_record.read_from_replica") do
yield
end
end
end
It seems that the process is proceeding after notifying by instrument which role to use.
Then, what is judged by # read_from_primary?
?
private
def read_from_primary?
!time_since_last_write_ok?
end
def send_to_replica_delay
delay
end
def time_since_last_write_ok?
Time.now - context.last_write_timestamp >= send_to_replica_delay
end
context
The so-called session # last_write_timestamp
is
If the first config.active_record.database_selector = {delay: 2.seconds}
delay has not passed, set it to primary.
If the delay has passed, the replica will be viewed.
So check the # last_write_timestamp
of session
class Session # :nodoc:
# Converts milliseconds since epoch timestamp into a time object.
def self.convert_timestamp_to_time(timestamp)
timestamp ? Time.at(timestamp / 1000, (timestamp % 1000) * 1000) : Time.at(0)
end
def last_write_timestamp
self.class.convert_timestamp_to_time(session[:last_write])
end
Just change sesion [: last_write]
to a time object
This is the end of read, so write
Resolver#write
class Resolver # :nodoc:
def write(&blk)
write_to_primary(&blk)
end
private
def write_to_primary(&blk)
ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role, prevent_writes: false) do
instrumenter.instrument("database_selector.active_record.wrote_to_primary") do
yield
ensure
context.update_last_write_timestamp
end
end
end
This is just writing in the writing role
class Session # :nodoc:
def self.convert_time_to_timestamp(time)
time.to_i * 1000 + time.usec / 1000
end
def update_last_write_timestamp
session[:last_write] = self.class.convert_time_to_timestamp(Time.now)
end
And add the current timestamp to session [: last_write]
It's Rails, isn't it? It was made very easy to understand.
After all, it is only processed based on ActionDispatch :: Request, and if you rewrite both # read
and # write
of Resolver
It seemed that I could write whatever I wanted, not just GET and HEAD.
Judgment by header, IP address, localhost, etc. can also be used because it is the information that Request has. I thought there was a lot I could do.
Recommended Posts