[RUBY] Read the implementation of Rails 6 multi-DB automatic switching (ActiveRecord :: Middleware :: DatabaseSelector)

Overview

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

Description in Rails Guide

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

Code reading

Let's start with the application part of middleware

The application part of middleware in Railtie

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

[Supplement] What are Ordered Options?

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

Database Selector implementation

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.

Judgment part of HTTP method

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]

Summary

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

Read the implementation of Rails 6 multi-DB automatic switching (ActiveRecord :: Middleware :: DatabaseSelector)
Read the Rails Guide (Overview of Action Controller) again
[Rails] Implementation of automatic address input using jpostal and jp_prefecture
Rails implementation of ajax removal
[Rails 6] Implementation of search function
[Rails] Implementation of category function
[Rails] Implementation of tutorial function
[Rails] Implementation of like function
[Rails] Implementation of coupon function (with automatic deletion function using batch processing)
[Rails] Implementation of user logic deletion
[Rails] Implementation of CSV import function
[Rails] Asynchronous implementation of like function
[Rails] Implementation of image preview function
Explanation of the order of rails routes
I read the source of ArrayList I read
[Rails] About implementation of like function
[Rails] Implementation of user withdrawal function
[Rails] Implementation of CSV export function
I read the source of Integer
Check the migration status of rails
I read the source of Long
Get the ID of automatic numbering
I read the source of Short
I read the source of Byte
I read the source of String
[Rails] I will explain the implementation procedure of the follow function using form_with.
[CircleCI] I was addicted to the automatic test of CircleCI (rails + mysql) [Memo]