From 392eeecc11a291e406db927a18b75f41b2658253 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 21 Sep 2012 16:14:42 +0100 Subject: [PATCH] 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. --- activerecord/CHANGELOG.md | 35 +++++ .../abstract/database_statements.rb | 63 ++++++++- .../abstract/transaction.rb | 27 ++-- .../connection_adapters/abstract_adapter.rb | 5 + .../abstract_mysql_adapter.rb | 15 +++ .../postgresql/database_statements.rb | 5 + .../connection_adapters/postgresql_adapter.rb | 4 + activerecord/lib/active_record/errors.rb | 3 + .../test/cases/transaction_isolation_test.rb | 121 ++++++++++++++++++ 9 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 activerecord/test/cases/transaction_isolation_test.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index ae304352f6..aee8f8d1f7 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -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. diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index 32e3c7f5d8..793f58d4d3 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -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: + # + # * :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. 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 diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index 2117eae5cb..4cca94e40b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 3a8fbcf93f..0cb219767b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 1126fe7fce..1783b036a2 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb index c8437c18cc..553985bd1e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -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" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 761052a788..5e35f472c7 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -370,6 +370,10 @@ module ActiveRecord true end + def supports_transaction_isolation? + true + end + class StatementPool < ConnectionAdapters::StatementPool def initialize(connection, max) super diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 5f157fde6d..0637dd58b6 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -195,4 +195,7 @@ module ActiveRecord class ImmutableRelation < ActiveRecordError end + + class TransactionIsolationError < ActiveRecordError + end end diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb new file mode 100644 index 0000000000..77f6b03d69 --- /dev/null +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -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