1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Support for specifying transaction isolation level

If your database supports setting the isolation level for a transaction,
you can set it like so:

  Post.transaction(isolation: :serializable) do
    # ...
  end

Valid isolation levels are:

* `:read_uncommitted`
* `:read_committed`
* `:repeatable_read`
* `:serializable`

You should consult the documentation for your database to understand the
semantics of these different levels:

* http://www.postgresql.org/docs/9.1/static/transaction-iso.html
* https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html

An `ActiveRecord::TransactionIsolationError` will be raised if:

* The adapter does not support setting the isolation level
* You are joining an existing open transaction
* You are creating a nested (savepoint) transaction

The mysql, mysql2 and postgresql adapters support setting the
transaction isolation level. However, support is disabled for mysql
versions below 5, because they are affected by a bug
(http://bugs.mysql.com/bug.php?id=39170) which means the isolation level
gets persisted outside the transaction.
This commit is contained in:
Jon Leighton 2012-09-21 16:14:42 +01:00
parent 834d6da54e
commit 392eeecc11
9 changed files with 263 additions and 15 deletions

View file

@ -1,5 +1,40 @@
## Rails 4.0.0 (unreleased) ##
* Support for specifying transaction isolation level
If your database supports setting the isolation level for a transaction, you can set
it like so:
Post.transaction(isolation: :serializable) do
# ...
end
Valid isolation levels are:
* `:read_uncommitted`
* `:read_committed`
* `:repeatable_read`
* `:serializable`
You should consult the documentation for your database to understand the
semantics of these different levels:
* http://www.postgresql.org/docs/9.1/static/transaction-iso.html
* https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html
An `ActiveRecord::TransactionIsolationError` will be raised if:
* The adapter does not support setting the isolation level
* You are joining an existing open transaction
* You are creating a nested (savepoint) transaction
The mysql, mysql2 and postgresql adapters support setting the transaction
isolation level. However, support is disabled for mysql versions below 5,
because they are affected by a bug (http://bugs.mysql.com/bug.php?id=39170)
which means the isolation level gets persisted outside the transaction.
*Jon Leighton*
* `ActiveModel::ForbiddenAttributesProtection` is included by default
in Active Record models. Check the docs of `ActiveModel::ForbiddenAttributesProtection`
for more details.

View file

@ -155,10 +155,47 @@ module ActiveRecord
# # active_record_1 now automatically released
# end # RELEASE SAVEPOINT active_record_1 <--- BOOM! database error!
# end
#
# == Transaction isolation
#
# If your database supports setting the isolation level for a transaction, you can set
# it like so:
#
# Post.transaction(isolation: :serializable) do
# # ...
# end
#
# Valid isolation levels are:
#
# * <tt>:read_uncommitted</tt>
# * <tt>:read_committed</tt>
# * <tt>:repeatable_read</tt>
# * <tt>:serializable</tt>
#
# You should consult the documentation for your database to understand the
# semantics of these different levels:
#
# * http://www.postgresql.org/docs/9.1/static/transaction-iso.html
# * https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html
#
# An <tt>ActiveRecord::TransactionIsolationError</tt> will be raised if:
#
# * The adapter does not support setting the isolation level
# * You are joining an existing open transaction
# * You are creating a nested (savepoint) transaction
#
# The mysql, mysql2 and postgresql adapters support setting the transaction
# isolation level. However, support is disabled for mysql versions below 5,
# because they are affected by a bug[http://bugs.mysql.com/bug.php?id=39170]
# which means the isolation level gets persisted outside the transaction.
def transaction(options = {})
options.assert_valid_keys :requires_new, :joinable
options.assert_valid_keys :requires_new, :joinable, :isolation
if !options[:requires_new] && current_transaction.joinable?
if options[:isolation]
raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
end
yield
else
within_new_transaction(options) { yield }
@ -168,10 +205,10 @@ module ActiveRecord
end
def within_new_transaction(options = {}) #:nodoc:
begin_transaction(options)
transaction = begin_transaction(options)
yield
rescue Exception => error
rollback_transaction
rollback_transaction if transaction
raise
ensure
begin
@ -191,9 +228,7 @@ module ActiveRecord
end
def begin_transaction(options = {}) #:nodoc:
@transaction = @transaction.begin
@transaction.joinable = options.fetch(:joinable, true)
@transaction
@transaction = @transaction.begin(options)
end
def commit_transaction #:nodoc:
@ -217,6 +252,22 @@ module ActiveRecord
# Begins the transaction (and turns off auto-committing).
def begin_db_transaction() end
def transaction_isolation_levels
{
read_uncommitted: "READ UNCOMMITTED",
read_committed: "READ COMMITTED",
repeatable_read: "REPEATABLE READ",
serializable: "SERIALIZABLE"
}
end
# Begins the transaction with the isolation level set. Raises an error by
# default; adapters that support setting the isolation level should implement
# this method.
def begin_isolated_db_transaction(isolation)
raise ActiveRecord::TransactionIsolationError, "adapter does not support setting transaction isolation"
end
# Commits the transaction (and turns on auto-committing).
def commit_db_transaction() end

View file

@ -13,8 +13,8 @@ module ActiveRecord
0
end
def begin
RealTransaction.new(connection, self)
def begin(options = {})
RealTransaction.new(connection, self, options)
end
def closed?
@ -38,13 +38,13 @@ module ActiveRecord
attr_reader :parent, :records
attr_writer :joinable
def initialize(connection, parent)
def initialize(connection, parent, options = {})
super connection
@parent = parent
@records = []
@finishing = false
@joinable = true
@joinable = options.fetch(:joinable, true)
end
# This state is necesarry so that we correctly handle stuff that might
@ -66,11 +66,11 @@ module ActiveRecord
end
end
def begin
def begin(options = {})
if finishing?
parent.begin
else
SavepointTransaction.new(connection, self)
SavepointTransaction.new(connection, self, options)
end
end
@ -120,9 +120,14 @@ module ActiveRecord
end
class RealTransaction < OpenTransaction #:nodoc:
def initialize(connection, parent)
def initialize(connection, parent, options = {})
super
connection.begin_db_transaction
if options[:isolation]
connection.begin_isolated_db_transaction(options[:isolation])
else
connection.begin_db_transaction
end
end
def perform_rollback
@ -137,7 +142,11 @@ module ActiveRecord
end
class SavepointTransaction < OpenTransaction #:nodoc:
def initialize(connection, parent)
def initialize(connection, parent, options = {})
if options[:isolation]
raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
end
super
connection.create_savepoint
end

View file

@ -167,6 +167,11 @@ module ActiveRecord
false
end
# Does this adapter support setting the isolation level for a transaction?
def supports_transaction_isolation?
false
end
# QUOTING ==================================================
# Returns a bind substitution value given a +column+ and list of current

View file

@ -169,6 +169,14 @@ module ActiveRecord
true
end
# MySQL 4 technically support transaction isolation, but it is affected by a bug
# where the transaction level gets persisted for the whole session:
#
# http://bugs.mysql.com/bug.php?id=39170
def supports_transaction_isolation?
version[0] >= 5
end
def native_database_types
NATIVE_DATABASE_TYPES
end
@ -269,6 +277,13 @@ module ActiveRecord
# Transactions aren't supported
end
def begin_isolated_db_transaction(isolation)
execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}"
begin_db_transaction
rescue
# Transactions aren't supported
end
def commit_db_transaction #:nodoc:
execute "COMMIT"
rescue

View file

@ -205,6 +205,11 @@ module ActiveRecord
execute "BEGIN"
end
def begin_isolated_db_transaction(isolation)
begin_db_transaction
execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}"
end
# Commits a transaction.
def commit_db_transaction
execute "COMMIT"

View file

@ -370,6 +370,10 @@ module ActiveRecord
true
end
def supports_transaction_isolation?
true
end
class StatementPool < ConnectionAdapters::StatementPool
def initialize(connection, max)
super

View file

@ -195,4 +195,7 @@ module ActiveRecord
class ImmutableRelation < ActiveRecordError
end
class TransactionIsolationError < ActiveRecordError
end
end

View file

@ -0,0 +1,121 @@
require 'cases/helper'
class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase
self.use_transactional_fixtures = false
class Tag < ActiveRecord::Base
end
setup do
if ActiveRecord::Base.connection.supports_transaction_isolation?
skip "database supports transaction isolation; test is irrelevant"
end
end
test "setting the isolation level raises an error" do
assert_raises(ActiveRecord::TransactionIsolationError) do
Tag.transaction(isolation: :serializable) { }
end
end
end
class TransactionIsolationTest < ActiveRecord::TestCase
self.use_transactional_fixtures = false
class Tag < ActiveRecord::Base
self.table_name = 'tags'
end
class Tag2 < ActiveRecord::Base
self.table_name = 'tags'
end
setup do
unless ActiveRecord::Base.connection.supports_transaction_isolation?
skip "database does not support setting transaction isolation"
end
Tag.establish_connection 'arunit'
Tag2.establish_connection 'arunit'
Tag.destroy_all
end
# It is impossible to properly test read uncommitted. The SQL standard only
# specifies what must not happen at a certain level, not what must happen. At
# the read uncommitted level, there is nothing that must not happen.
test "read uncommitted" do
Tag.transaction(isolation: :read_uncommitted) do
assert_equal 0, Tag.count
Tag2.create
assert_equal 1, Tag.count
end
end
# We are testing that a dirty read does not happen
test "read committed" do
Tag.transaction(isolation: :read_committed) do
assert_equal 0, Tag.count
Tag2.transaction do
Tag2.create
assert_equal 0, Tag.count
end
end
assert_equal 1, Tag.count
end
# We are testing that a nonrepeatable read does not happen
test "repeatable read" do
tag = Tag.create(name: 'jon')
Tag.transaction(isolation: :repeatable_read) do
tag.reload
Tag2.find(tag.id).update_attributes(name: 'emily')
tag.reload
assert_equal 'jon', tag.name
end
tag.reload
assert_equal 'emily', tag.name
end
# We are testing that a non-serializable sequence of statements will raise
# an error.
test "serializable" do
if Tag2.connection.adapter_name =~ /mysql/i
# Unfortunately it cannot be set to 0
Tag2.connection.execute "SET innodb_lock_wait_timeout = 1"
end
assert_raises ActiveRecord::StatementInvalid do
Tag.transaction(isolation: :serializable) do
Tag.create
Tag2.transaction(isolation: :serializable) do
Tag2.create
Tag2.count
end
Tag.count
end
end
end
test "setting isolation when joining a transaction raises an error" do
Tag.transaction do
assert_raises(ActiveRecord::TransactionIsolationError) do
Tag.transaction(isolation: :serializable) { }
end
end
end
test "setting isolation when starting a nested transaction raises error" do
Tag.transaction do
assert_raises(ActiveRecord::TransactionIsolationError) do
Tag.transaction(requires_new: true, isolation: :serializable) { }
end
end
end
end