aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib/active_record
diff options
context:
space:
mode:
authorEileen Uchitelle <eileencodes@gmail.com>2019-01-17 13:33:48 -0500
committerEileen Uchitelle <eileencodes@gmail.com>2019-01-30 13:37:25 -0500
commit0abcec416b6ec11faffa03d40e5661c0a4a8b092 (patch)
tree01a366cdae0fd4f536efbbe73360a27506cfdbc1 /activerecord/lib/active_record
parent3d22069c6355dc60be65e01958cf32917bc53142 (diff)
downloadrails-0abcec416b6ec11faffa03d40e5661c0a4a8b092.tar.gz
rails-0abcec416b6ec11faffa03d40e5661c0a4a8b092.tar.bz2
rails-0abcec416b6ec11faffa03d40e5661c0a4a8b092.zip
Adds basic automatic database switching to Rails
The following PR adds behavior to Rails to allow an application to automatically switch it's connection from the primary to the replica. A request will be sent to the replica if: * The request is a read request (`GET` or `HEAD`) * AND It's been 2 seconds since the last write to the database (because we don't want to send a user to a replica if the write hasn't made it to the replica yet) A request will be sent to the primary if: * It's not a GET/HEAD request (ie is a POST, PATCH, etc) * Has been less than 2 seconds since the last write to the database The implementation that decides when to switch reads (the 2 seconds) is "safe" to use in production but not recommended without adequate testing with your infrastructure. At GitHub in addition to the a 5 second delay we have a curcuit breaker that checks the replication delay and will send the query to a replica before the 5 seconds has passed. This is specific to our application and therefore not something Rails should be doing for you. You'll need to test and implement more robust handling of when to switch based on your infrastructure. The auto switcher in Rails is meant to be a basic implementation / API that acts as a guide for how to implement autoswitching. The impementation here is meant to be strict enough that you know how to implement your own resolver and operations classes but flexible enough that we're not telling you how to do it. The middleware is not included automatically and can be installed in your application with the classes you want to use for the resolver and operations passed in. If you don't pass any classes into the middleware the Rails default Resolver and Session classes will be used. The Resolver decides what parameters define when to switch, Operations sets timestamps for the Resolver to read from. For example you may want to use cookies instead of a session so you'd implement a Resolver::Cookies class and pass that into the middleware via configuration options. ``` config.active_record.database_selector = { delay: 2.seconds } config.active_record.database_resolver = MyResolver config.active_record.database_operations = MyResolver::MyCookies ``` Your classes can inherit from the existing classes and reimplment the methods (or implement more methods) that you need to do the switching. You only need to implement methods that you want to change. For example if you wanted to set the session token for the last read from a replica you would reimplement the `read_from_replica` method in your resolver class and implement a method that updates a new timestamp in your operations class.
Diffstat (limited to 'activerecord/lib/active_record')
-rw-r--r--activerecord/lib/active_record/core.rb1
-rw-r--r--activerecord/lib/active_record/middleware/database_selector.rb74
-rw-r--r--activerecord/lib/active_record/middleware/database_selector/resolver.rb88
-rw-r--r--activerecord/lib/active_record/middleware/database_selector/resolver/session.rb45
-rw-r--r--activerecord/lib/active_record/railtie.rb8
5 files changed, 216 insertions, 0 deletions
diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb
index 369d63e40a..519acd7605 100644
--- a/activerecord/lib/active_record/core.rb
+++ b/activerecord/lib/active_record/core.rb
@@ -101,6 +101,7 @@ module ActiveRecord
# environment where dumping schema is rarely needed.
mattr_accessor :dump_schema_after_migration, instance_writer: false, default: true
+ mattr_accessor :database_selector, instance_writer: false
##
# :singleton-method:
# Specifies which database schemas to dump when calling db:structure:dump.
diff --git a/activerecord/lib/active_record/middleware/database_selector.rb b/activerecord/lib/active_record/middleware/database_selector.rb
new file mode 100644
index 0000000000..06529bc1c9
--- /dev/null
+++ b/activerecord/lib/active_record/middleware/database_selector.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require "active_record/middleware/database_selector/resolver"
+
+module ActiveRecord
+ module Middleware
+ # The DatabaseSelector Middleware provides a framework for automatically
+ # swapping from the primary to the replica database connection. Rails
+ # provides a basic framework to determine when to swap and allows for
+ # applications to write custom strategy classes to override the default
+ # behavior.
+ #
+ # The resolver class defines when the application should switch (i.e. read
+ # from the primary if a write occurred less than 2 seconds ago) and an
+ # operations class that sets a value that helps the resolver class decide
+ # when to switch.
+ #
+ # Rails default middleware uses the request's session to set a timestamp
+ # that informs the application when to read from a primary or read from a
+ # replica.
+ #
+ # To use the DatabaseSelector in your application with default settings add
+ # the following options to your environment config:
+ #
+ # config.active_record.database_selector = { delay: 2.seconds }
+ # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
+ # config.active_record.database_operations = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
+ #
+ # New applications will include these lines commented out in the production.rb.
+ #
+ # The default behavior can be changed by setting the config options to a
+ # custom class:
+ #
+ # config.active_record.database_selector = { delay: 2.seconds }
+ # config.active_record.database_resolver = MyResolver
+ # config.active_record.database_operations = MyResolver::MySession
+ class DatabaseSelector
+ def initialize(app, resolver_klass = Resolver, operations_klass = Resolver::Session)
+ @app = app
+ @resolver_klass = resolver_klass
+ @operations_klass = operations_klass
+ end
+
+ attr_reader :resolver_klass, :operations_klass
+
+ # Middleware that determines which database connection to use in a mutliple
+ # database application.
+ def call(env)
+ request = ActionDispatch::Request.new(env)
+
+ select_database(request) do
+ @app.call(env)
+ end
+ end
+
+ private
+
+ def select_database(request, &blk)
+ operations = operations_klass.build(request)
+ database_resolver = resolver_klass.call(operations)
+
+ if reading_request?(request)
+ database_resolver.read(&blk)
+ else
+ database_resolver.write(&blk)
+ end
+ end
+
+ def reading_request?(request)
+ request.get? || request.head?
+ end
+ end
+ end
+end
diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver.rb b/activerecord/lib/active_record/middleware/database_selector/resolver.rb
new file mode 100644
index 0000000000..1c056e2156
--- /dev/null
+++ b/activerecord/lib/active_record/middleware/database_selector/resolver.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "active_record/middleware/database_selector/resolver/session"
+
+module ActiveRecord
+ module Middleware
+ class DatabaseSelector
+ # The Resolver class is used by the DatabaseSelector middleware to
+ # determine which database the request should use.
+ #
+ # To change the behavior of the Resolver class in your application,
+ # create a custom resolver class that inherts from
+ # DatabaseSelector::Resolver and implements the methods that need to
+ # be changed.
+ #
+ # By default the Resolver class will send read traffic to the replica
+ # if it's been 2 seconds since the last write.
+ class Resolver # :nodoc:
+ SEND_TO_REPLICA_DELAY = 2.seconds
+
+ def self.call(resolver)
+ new(resolver)
+ end
+
+ def initialize(resolver)
+ @resolver = resolver
+ @instrumenter = ActiveSupport::Notifications.instrumenter
+ end
+
+ attr_reader :resolver, :instrumenter
+
+ def read(&blk)
+ if read_from_primary?
+ read_from_primary(&blk)
+ else
+ read_from_replica(&blk)
+ end
+ end
+
+ def write(&blk)
+ write_to_primary(&blk)
+ end
+
+ private
+
+ def read_from_primary(&blk)
+ ActiveRecord::Base.connection.while_preventing_writes do
+ ActiveRecord::Base.connected_to(role: :writing) do
+ instrumenter.instrument("database_selector.active_record.read_from_primary") do
+ yield
+ end
+ end
+ end
+ end
+
+ def read_from_replica(&blk)
+ ActiveRecord::Base.connected_to(role: :reading) do
+ instrumenter.instrument("database_selector.active_record.read_from_replica") do
+ yield
+ end
+ end
+ end
+
+ def write_to_primary(&blk)
+ ActiveRecord::Base.connected_to(role: :writing) do
+ instrumenter.instrument("database_selector.active_record.wrote_to_primary") do
+ resolver.update_last_write_timestamp
+ yield
+ end
+ end
+ end
+
+ def read_from_primary?
+ !time_since_last_write_ok?
+ end
+
+ def send_to_replica_delay
+ (ActiveRecord::Base.database_selector && ActiveRecord::Base.database_selector[:delay]) ||
+ SEND_TO_REPLICA_DELAY
+ end
+
+ def time_since_last_write_ok?
+ Time.now - resolver.last_write_timestamp >= send_to_replica_delay
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb
new file mode 100644
index 0000000000..33e0af5ee4
--- /dev/null
+++ b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module Middleware
+ class DatabaseSelector
+ class Resolver
+ # The session class is used by the DatabaseSelector::Resolver to save
+ # timestamps of the last write in the session.
+ #
+ # The last_write is used to determine whether it's safe to read
+ # from the replica or the request needs to be sent to the primary.
+ class Session # :nodoc:
+ def self.build(request)
+ new(request.session)
+ end
+
+ # Converts time to a timestamp that represents milliseconds since
+ # epoch.
+ def self.convert_time_to_timestamp(time)
+ time.to_i * 1000 + time.usec / 1000
+ end
+
+ # 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 initialize(session)
+ @session = session
+ end
+
+ attr_reader :session
+
+ def last_write_timestamp
+ self.class.convert_timestamp_to_time(session[:last_write])
+ end
+
+ def update_last_write_timestamp
+ session[:last_write] = self.class.convert_time_to_timestamp(Time.now)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb
index 6346a95d57..a981fa97d9 100644
--- a/activerecord/lib/active_record/railtie.rb
+++ b/activerecord/lib/active_record/railtie.rb
@@ -88,6 +88,14 @@ module ActiveRecord
end
end
+ initializer "active_record.database_selector" do
+ if config.active_record.database_selector
+ resolver = config.active_record.delete(:database_resolver)
+ operations = config.active_record.delete(:database_operations)
+ config.app_middleware.use ActiveRecord::Middleware::DatabaseSelector, resolver, operations
+ end
+ end
+
initializer "Check for cache versioning support" do
config.after_initialize do |app|
ActiveSupport.on_load(:active_record) do