Skip to content

Commit

Permalink
Add ability to ignore active database transactions when performing sl…
Browse files Browse the repository at this point in the history
…ave reads
  • Loading branch information
reidmorrison committed Jul 23, 2014
1 parent 8b38490 commit a402a12
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 39 deletions.
1 change: 0 additions & 1 deletion .ruby-version

This file was deleted.

2 changes: 1 addition & 1 deletion LICENSE.txt
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2012 Clarity Services, Inc.
Copyright 2012, 2013, 2014 Reid Morrison

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Expand Up @@ -91,6 +91,39 @@ D, [2012-11-06T19:43:26.891667 #89002] DEBUG -- : SQL (0.4ms) DELETE FROM "us
D, [2012-11-06T19:43:26.892697 #89002] DEBUG -- : (0.9ms) commit transaction
```

## Transactions

By default ActiveRecordSlave detects when a call is inside a transaction and will
send all reads to the _master_ when a transaction is active.

With the latest Rails releases, Rails automatically wraps all Controller Action
calls with a transaction, effectively sending all reads to the master database.

It is now possible to send reads to database slaves and ignore whether currently
inside a transaction:

In file config/application.rb:

```ruby
# Read from slave even when in an active transaction
config.active_record_slave.ignore_transactions = true
```

It is important to identify any code in the application that depends on being
able to read any changes already part of the transaction, but not yet committed
and wrap those reads with `ActiveRecordSlave.read_from_master`

```ruby
# Create a new inquiry
Inquiry.create

# Then make sure that the new inquiry that is not yet committed is visible during
# the read below:
ActiveRecordSlave.read_from_master do
count = Inquiry.count
end
```

## Dependencies

* Tested on Rails 3 and Rails 4
Expand Down
82 changes: 57 additions & 25 deletions lib/active_record_slave/active_record_slave.rb
Expand Up @@ -25,9 +25,11 @@ def self.install!(adapter_class = nil, environment = nil)
# Inject a new #select method into the ActiveRecord Database adapter
base = adapter_class || ActiveRecord::Base.connection.class
base.send(:include, InstanceMethods)
base.alias_method_chain(:select, :slave_reader)
SELECT_METHODS.each do |select_method|
base.alias_method_chain(select_method, :slave_reader)
end
else
ActiveRecord::Base.logger.info "ActiveRecordSlave no slave database defined"
ActiveRecord::Base.logger.info "ActiveRecordSlave not installed since no slave database defined"
end
end

Expand All @@ -45,37 +47,67 @@ def self.read_from_master
end
end

if RUBY_VERSION.to_i >= 2
# Fibers have their own thread local variables so use thread_variable_get
# Whether this thread is currently forcing all reads to go against the master database
def self.read_from_master?
thread_variable_get(:active_record_slave) == :master
end

# Whether this thread is currently forcing all reads to go against the master database
def self.read_from_master?
Thread.current.thread_variable_get(:active_record_slave) == :master
end
# Force all subsequent reads on this thread and any fibers called by this thread to go the master
def self.read_from_master!
thread_variable_set(:active_record_slave, :master)
end

# Force all subsequent reads on this thread and any fibers called by this thread to go the master
def self.read_from_master!
Thread.current.thread_variable_set(:active_record_slave, :master)
end
# Subsequent reads on this thread and any fibers called by this thread can go to a slave
def self.read_from_slave!
thread_variable_set(:active_record_slave, nil)
end

# Returns whether slave reads are ignoring transactions
def self.ignore_transactions?
@ignore_transactions
end

# Set whether slave reads should ignore transactions
def self.ignore_transactions=(ignore_transactions)
@ignore_transactions = ignore_transactions
end

# Subsequent reads on this thread and any fibers called by this thread can go to a slave
def self.read_from_slave!
Thread.current.thread_variable_set(:active_record_slave, nil)
##############################################################################
private

@ignore_transactions = false

# Returns the value of the local thread variable
#
# Parameters
# variable [Symbol]
# Name of variable to get
if RUBY_VERSION.to_i >= 2
# Fibers have their own thread local variables so use thread_variable_get
def self.thread_variable_get(variable)
Thread.current.thread_variable_get(variable)
end
else
# Whether this thread is currently forcing all reads to go against the master database
def self.read_from_master?
Thread.current[:active_record_slave] == :master
def self.thread_variable_get(variable)
Thread.current[variable]
end
end

# Force all subsequent reads on this thread and any fibers called by this thread to go the master
def self.read_from_master!
Thread.current[:active_record_slave] = :master
# Sets the value of the local thread variable
#
# Parameters
# variable [Symbol]
# Name of variable to set
# value [Object]
# Value to set the thread variable to
if RUBY_VERSION.to_i >= 2
# Fibers have their own thread local variables so use thread_variable_set
def self.thread_variable_set(variable, value)
Thread.current.thread_variable_set(variable, value)
end

# Subsequent reads on this thread and any fibers called by this thread can go to a slave
def self.read_from_slave!
Thread.current[:active_record_slave] = nil
else
def self.thread_variable_set(variable, value)
Thread.current[variable] = value
end
end

Expand Down
38 changes: 28 additions & 10 deletions lib/active_record_slave/instance_methods.rb
@@ -1,17 +1,35 @@
module ActiveRecordSlave
module InstanceMethods
# Select Methods
SELECT_METHODS = [:select, :select_all, :select_one, :select_rows, :select_value, :select_values]

# In case in the future we are forced to intercept connection#execute if the
# above select methods are not sufficient
# SQL_READS = /\A\s*(SELECT|WITH|SHOW|CALL|EXPLAIN|DESCRIBE)/i

# Database Adapter method #select is called for every select call
# Replace #select with one that calls the slave connection instead
def select_with_slave_reader(sql, name = nil, *args)
# Only read from slave when not in a transaction and when this is not already the slave connection
if (open_transactions == 0) && !ActiveRecordSlave.read_from_master?
ActiveRecordSlave.read_from_master do
Slave.connection.select(sql, "Slave: #{name || 'SQL'}", *args)
module InstanceMethods
SELECT_METHODS.each do |select_method|
# Database Adapter method #exec_query is called for every select call
# Replace #exec_query with one that calls the slave connection instead
eval <<-METHOD
def #{select_method}_with_slave_reader(sql, name = nil, *args)
if active_record_slave_read_from_master?
#{select_method}_without_slave_reader(sql, name, *args)
else
# Calls are going against the Slave now, prevent an infinite loop
ActiveRecordSlave.read_from_master do
Slave.connection.#{select_method}(sql, "Slave: \#{name || 'SQL'}", *args)
end
end
else
select_without_slave_reader(sql, name, *args)
end
METHOD
end

# Returns whether to read from the master database
def active_record_slave_read_from_master?
# Read from master when forced by thread variable, or
# in a transaction and not ignoring transactions
ActiveRecordSlave.read_from_master? ||
(open_transactions > 0) && !ActiveRecordSlave.ignore_transactions?
end

end
Expand Down
17 changes: 16 additions & 1 deletion lib/active_record_slave/railtie.rb
@@ -1,8 +1,23 @@
module ActiveRecordSlave #:nodoc:
class Railtie < Rails::Railtie #:nodoc:

# Make the ActiveRecordSlave configuration available in the Rails application config
#
# Example: For this application ignore the current transactions since the application
# has been coded to use ActiveRecordSlave.read_from_master whenever
# the current transaction must be visible to reads.
# In file config/application.rb
#
# Rails::Application.configure do
# # Read from slave even when in an active transaction
# # The application will use ActiveRecordSlave.read_from_master to make
# # changes in the current transaction visible to reads
# config.active_record_slave.ignore_transactions = true
# end
config.active_record_slave = ::ActiveRecordSlave

# Initialize ActiveRecordSlave
initializer "load active_record_slave" , :after=>"active_record.initialize_database" do
initializer "load active_record_slave" , :after => "active_record.initialize_database" do
ActiveRecordSlave.install!
end

Expand Down
2 changes: 1 addition & 1 deletion lib/active_record_slave/version.rb
@@ -1,3 +1,3 @@
module ActiveRecordSlave #:nodoc
VERSION = "1.1.0"
VERSION = "1.2.0"
end
32 changes: 32 additions & 0 deletions test/active_record_slave_test.rb
Expand Up @@ -52,6 +52,8 @@ class ActiveRecordSlaveTest < Test::Unit::TestCase
context 'the active_record_slave gem' do

setup do
ActiveRecordSlave.ignore_transactions = false

User.delete_all

@name = "Joe Bloggs"
Expand Down Expand Up @@ -89,7 +91,12 @@ class ActiveRecordSlaveTest < Test::Unit::TestCase
end

should "save to master, read from master when in a transaction" do
assert_equal false, ActiveRecordSlave.ignore_transactions?

User.transaction do
# The delete_all in setup should have cleared the table
assert_equal 0, User.count

# Read from Master
assert_equal 0, User.where(:name => @name, :address => @address).count

Expand All @@ -99,6 +106,31 @@ class ActiveRecordSlaveTest < Test::Unit::TestCase
# Read from Master
assert_equal 1, User.where(:name => @name, :address => @address).count
end

# Read from Non-replicated slave
assert_equal 0, User.where(:name => @name, :address => @address).count
end

should "save to master, read from slave when ignoring transactions" do
ActiveRecordSlave.ignore_transactions = true
assert_equal true, ActiveRecordSlave.ignore_transactions?

User.transaction do
# The delete_all in setup should have cleared the table
assert_equal 0, User.count

# Read from Master
assert_equal 0, User.where(:name => @name, :address => @address).count

# Write to master
assert_equal true, @user.save!

# Read from Non-replicated slave
assert_equal 0, User.where(:name => @name, :address => @address).count
end

# Read from Non-replicated slave
assert_equal 0, User.where(:name => @name, :address => @address).count
end

should "save to master, force a read from master even when _not_ in a transaction" do
Expand Down

0 comments on commit a402a12

Please sign in to comment.