Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

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.
commit 392eeecc11a291e406db927a18b75f41b2658253 1 parent 834d6da
Jon Leighton jonleighton authored
35 activerecord/CHANGELOG.md
View
@@ -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.
63 activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
View
@@ -155,10 +155,47 @@ def supports_statement_cache?
# # 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 @@ def transaction(options = {})
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 @@ def transaction_open?
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 @@ def add_transaction_record(record)
# 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
27 activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
View
@@ -13,8 +13,8 @@ def number
0
end
- def begin
- RealTransaction.new(connection, self)
+ def begin(options = {})
+ RealTransaction.new(connection, self, options)
end
def closed?
@@ -38,13 +38,13 @@ class OpenTransaction < Transaction #:nodoc:
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 @@ def number
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 @@ def open?
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 @@ def perform_commit
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
5 activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
View
@@ -167,6 +167,11 @@ def supports_explain?
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
15 activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
View
@@ -169,6 +169,14 @@ def supports_index_sort_order?
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 @@ def begin_db_transaction
# 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
5 activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
View
@@ -205,6 +205,11 @@ def begin_db_transaction
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"
4 activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
View
@@ -370,6 +370,10 @@ def supports_partial_index?
true
end
+ def supports_transaction_isolation?
+ true
+ end
+
class StatementPool < ConnectionAdapters::StatementPool
def initialize(connection, max)
super
3  activerecord/lib/active_record/errors.rb
View
@@ -195,4 +195,7 @@ def initialize(model)
class ImmutableRelation < ActiveRecordError
end
+
+ class TransactionIsolationError < ActiveRecordError
+ end
end
121 activerecord/test/cases/transaction_isolation_test.rb
View
@@ -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
Jonathan Viney
jviney added a note

This may have been intentional - but it isn't testing read uncommitted. The assertion would need to be inside the Tag2 transaction. But as mentioned in the comment, the SQL standard only specifies what must not happen, so perhaps we shouldn't try to test it at all.

Tag.transaction(isolation: :read_uncommitted) do
  assert_equal 0, Tag.count
  Tag2.transaction do
    Tag2.create
    assert_equal 1, Tag.count
  end
end

Passes on mysql, but not on postgres because it doesn't support read uncommitted.

Jon Leighton Collaborator

@jviney yeah, I am not attempting to test it at all. as you mentioned, such a test would fail on postgres. the only purpose of the test is really to make sure nothing blows up when :read_uncommitted is specified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ 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
Jonathan Viney

This may have been intentional - but it isn't testing read uncommitted. The assertion would need to be inside the Tag2 transaction. But as mentioned in the comment, the SQL standard only specifies what must not happen, so perhaps we shouldn't try to test it at all.

Tag.transaction(isolation: :read_uncommitted) do
  assert_equal 0, Tag.count
  Tag2.transaction do
    Tag2.create
    assert_equal 1, Tag.count
  end
end

Passes on mysql, but not on postgres because it doesn't support read uncommitted.

Jon Leighton

@jviney yeah, I am not attempting to test it at all. as you mentioned, such a test would fail on postgres. the only purpose of the test is really to make sure nothing blows up when :read_uncommitted is specified.

Please sign in to comment.
Something went wrong with that request. Please try again.