Skip to content
This repository
Browse code

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 authored September 21, 2012
35  activerecord/CHANGELOG.md
Source Rendered
... ...
@@ -1,5 +1,40 @@
1 1
 ## Rails 4.0.0 (unreleased) ##
2 2
 
  3
+*   Support for specifying transaction isolation level
  4
+
  5
+    If your database supports setting the isolation level for a transaction, you can set
  6
+    it like so:
  7
+
  8
+        Post.transaction(isolation: :serializable) do
  9
+          # ...
  10
+        end
  11
+
  12
+    Valid isolation levels are:
  13
+
  14
+    * `:read_uncommitted`
  15
+    * `:read_committed`
  16
+    * `:repeatable_read`
  17
+    * `:serializable`
  18
+
  19
+    You should consult the documentation for your database to understand the
  20
+    semantics of these different levels:
  21
+
  22
+    * http://www.postgresql.org/docs/9.1/static/transaction-iso.html
  23
+    * https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html
  24
+
  25
+    An `ActiveRecord::TransactionIsolationError` will be raised if:
  26
+
  27
+    * The adapter does not support setting the isolation level
  28
+    * You are joining an existing open transaction
  29
+    * You are creating a nested (savepoint) transaction
  30
+
  31
+    The mysql, mysql2 and postgresql adapters support setting the transaction
  32
+    isolation level. However, support is disabled for mysql versions below 5,
  33
+    because they are affected by a bug (http://bugs.mysql.com/bug.php?id=39170)
  34
+    which means the isolation level gets persisted outside the transaction.
  35
+
  36
+    *Jon Leighton*
  37
+
3 38
 *   `ActiveModel::ForbiddenAttributesProtection` is included by default
4 39
     in Active Record models. Check the docs of `ActiveModel::ForbiddenAttributesProtection`
5 40
     for more details.
63  activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
@@ -155,10 +155,47 @@ def supports_statement_cache?
155 155
       #       # active_record_1 now automatically released
156 156
       #     end  # RELEASE SAVEPOINT active_record_1  <--- BOOM! database error!
157 157
       #   end
  158
+      #
  159
+      # == Transaction isolation
  160
+      #
  161
+      # If your database supports setting the isolation level for a transaction, you can set
  162
+      # it like so:
  163
+      #
  164
+      #   Post.transaction(isolation: :serializable) do
  165
+      #     # ...
  166
+      #   end
  167
+      #
  168
+      # Valid isolation levels are:
  169
+      #
  170
+      # * <tt>:read_uncommitted</tt>
  171
+      # * <tt>:read_committed</tt>
  172
+      # * <tt>:repeatable_read</tt>
  173
+      # * <tt>:serializable</tt>
  174
+      #
  175
+      # You should consult the documentation for your database to understand the
  176
+      # semantics of these different levels:
  177
+      #
  178
+      # * http://www.postgresql.org/docs/9.1/static/transaction-iso.html
  179
+      # * https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html
  180
+      #
  181
+      # An <tt>ActiveRecord::TransactionIsolationError</tt> will be raised if:
  182
+      #
  183
+      # * The adapter does not support setting the isolation level
  184
+      # * You are joining an existing open transaction
  185
+      # * You are creating a nested (savepoint) transaction
  186
+      #
  187
+      # The mysql, mysql2 and postgresql adapters support setting the transaction
  188
+      # isolation level. However, support is disabled for mysql versions below 5,
  189
+      # because they are affected by a bug[http://bugs.mysql.com/bug.php?id=39170]
  190
+      # which means the isolation level gets persisted outside the transaction.
158 191
       def transaction(options = {})
159  
-        options.assert_valid_keys :requires_new, :joinable
  192
+        options.assert_valid_keys :requires_new, :joinable, :isolation
160 193
 
161 194
         if !options[:requires_new] && current_transaction.joinable?
  195
+          if options[:isolation]
  196
+            raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
  197
+          end
  198
+
162 199
           yield
163 200
         else
164 201
           within_new_transaction(options) { yield }
@@ -168,10 +205,10 @@ def transaction(options = {})
168 205
       end
169 206
 
170 207
       def within_new_transaction(options = {}) #:nodoc:
171  
-        begin_transaction(options)
  208
+        transaction = begin_transaction(options)
172 209
         yield
173 210
       rescue Exception => error
174  
-        rollback_transaction
  211
+        rollback_transaction if transaction
175 212
         raise
176 213
       ensure
177 214
         begin
@@ -191,9 +228,7 @@ def transaction_open?
191 228
       end
192 229
 
193 230
       def begin_transaction(options = {}) #:nodoc:
194  
-        @transaction = @transaction.begin
195  
-        @transaction.joinable = options.fetch(:joinable, true)
196  
-        @transaction
  231
+        @transaction = @transaction.begin(options)
197 232
       end
198 233
 
199 234
       def commit_transaction #:nodoc:
@@ -217,6 +252,22 @@ def add_transaction_record(record)
217 252
       # Begins the transaction (and turns off auto-committing).
218 253
       def begin_db_transaction()    end
219 254
 
  255
+      def transaction_isolation_levels
  256
+        {
  257
+          read_uncommitted: "READ UNCOMMITTED",
  258
+          read_committed:   "READ COMMITTED",
  259
+          repeatable_read:  "REPEATABLE READ",
  260
+          serializable:     "SERIALIZABLE"
  261
+        }
  262
+      end
  263
+
  264
+      # Begins the transaction with the isolation level set. Raises an error by
  265
+      # default; adapters that support setting the isolation level should implement
  266
+      # this method.
  267
+      def begin_isolated_db_transaction(isolation)
  268
+        raise ActiveRecord::TransactionIsolationError, "adapter does not support setting transaction isolation"
  269
+      end
  270
+
220 271
       # Commits the transaction (and turns on auto-committing).
221 272
       def commit_db_transaction()   end
222 273
 
27  activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
@@ -13,8 +13,8 @@ def number
13 13
         0
14 14
       end
15 15
 
16  
-      def begin
17  
-        RealTransaction.new(connection, self)
  16
+      def begin(options = {})
  17
+        RealTransaction.new(connection, self, options)
18 18
       end
19 19
 
20 20
       def closed?
@@ -38,13 +38,13 @@ class OpenTransaction < Transaction #:nodoc:
38 38
       attr_reader :parent, :records
39 39
       attr_writer :joinable
40 40
 
41  
-      def initialize(connection, parent)
  41
+      def initialize(connection, parent, options = {})
42 42
         super connection
43 43
 
44 44
         @parent    = parent
45 45
         @records   = []
46 46
         @finishing = false
47  
-        @joinable  = true
  47
+        @joinable  = options.fetch(:joinable, true)
48 48
       end
49 49
 
50 50
       # This state is necesarry so that we correctly handle stuff that might
@@ -66,11 +66,11 @@ def number
66 66
         end
67 67
       end
68 68
 
69  
-      def begin
  69
+      def begin(options = {})
70 70
         if finishing?
71 71
           parent.begin
72 72
         else
73  
-          SavepointTransaction.new(connection, self)
  73
+          SavepointTransaction.new(connection, self, options)
74 74
         end
75 75
       end
76 76
 
@@ -120,9 +120,14 @@ def open?
120 120
     end
121 121
 
122 122
     class RealTransaction < OpenTransaction #:nodoc:
123  
-      def initialize(connection, parent)
  123
+      def initialize(connection, parent, options = {})
124 124
         super
125  
-        connection.begin_db_transaction
  125
+
  126
+        if options[:isolation]
  127
+          connection.begin_isolated_db_transaction(options[:isolation])
  128
+        else
  129
+          connection.begin_db_transaction
  130
+        end
126 131
       end
127 132
 
128 133
       def perform_rollback
@@ -137,7 +142,11 @@ def perform_commit
137 142
     end
138 143
 
139 144
     class SavepointTransaction < OpenTransaction #:nodoc:
140  
-      def initialize(connection, parent)
  145
+      def initialize(connection, parent, options = {})
  146
+        if options[:isolation]
  147
+          raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
  148
+        end
  149
+
141 150
         super
142 151
         connection.create_savepoint
143 152
       end
5  activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -167,6 +167,11 @@ def supports_explain?
167 167
         false
168 168
       end
169 169
 
  170
+      # Does this adapter support setting the isolation level for a transaction?
  171
+      def supports_transaction_isolation?
  172
+        false
  173
+      end
  174
+
170 175
       # QUOTING ==================================================
171 176
 
172 177
       # Returns a bind substitution value given a +column+ and list of current
15  activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
@@ -169,6 +169,14 @@ def supports_index_sort_order?
169 169
         true
170 170
       end
171 171
 
  172
+      # MySQL 4 technically support transaction isolation, but it is affected by a bug
  173
+      # where the transaction level gets persisted for the whole session:
  174
+      #
  175
+      # http://bugs.mysql.com/bug.php?id=39170
  176
+      def supports_transaction_isolation?
  177
+        version[0] >= 5
  178
+      end
  179
+
172 180
       def native_database_types
173 181
         NATIVE_DATABASE_TYPES
174 182
       end
@@ -269,6 +277,13 @@ def begin_db_transaction
269 277
         # Transactions aren't supported
270 278
       end
271 279
 
  280
+      def begin_isolated_db_transaction(isolation)
  281
+        execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}"
  282
+        begin_db_transaction
  283
+      rescue
  284
+        # Transactions aren't supported
  285
+      end
  286
+
272 287
       def commit_db_transaction #:nodoc:
273 288
         execute "COMMIT"
274 289
       rescue
5  activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
@@ -205,6 +205,11 @@ def begin_db_transaction
205 205
           execute "BEGIN"
206 206
         end
207 207
 
  208
+        def begin_isolated_db_transaction(isolation)
  209
+          begin_db_transaction
  210
+          execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}"
  211
+        end
  212
+
208 213
         # Commits a transaction.
209 214
         def commit_db_transaction
210 215
           execute "COMMIT"
4  activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -370,6 +370,10 @@ def supports_partial_index?
370 370
         true
371 371
       end
372 372
 
  373
+      def supports_transaction_isolation?
  374
+        true
  375
+      end
  376
+
373 377
       class StatementPool < ConnectionAdapters::StatementPool
374 378
         def initialize(connection, max)
375 379
           super
3  activerecord/lib/active_record/errors.rb
@@ -195,4 +195,7 @@ def initialize(model)
195 195
 
196 196
   class ImmutableRelation < ActiveRecordError
197 197
   end
  198
+
  199
+  class TransactionIsolationError < ActiveRecordError
  200
+  end
198 201
 end
121  activerecord/test/cases/transaction_isolation_test.rb
... ...
@@ -0,0 +1,121 @@
  1
+require 'cases/helper'
  2
+
  3
+class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase
  4
+  self.use_transactional_fixtures = false
  5
+
  6
+  class Tag < ActiveRecord::Base
  7
+  end
  8
+
  9
+  setup do
  10
+    if ActiveRecord::Base.connection.supports_transaction_isolation?
  11
+      skip "database supports transaction isolation; test is irrelevant"
  12
+    end
  13
+  end
  14
+
  15
+  test "setting the isolation level raises an error" do
  16
+    assert_raises(ActiveRecord::TransactionIsolationError) do
  17
+      Tag.transaction(isolation: :serializable) { }
  18
+    end
  19
+  end
  20
+end
  21
+
  22
+class TransactionIsolationTest < ActiveRecord::TestCase
  23
+  self.use_transactional_fixtures = false
  24
+
  25
+  class Tag < ActiveRecord::Base
  26
+    self.table_name = 'tags'
  27
+  end
  28
+
  29
+  class Tag2 < ActiveRecord::Base
  30
+    self.table_name = 'tags'
  31
+  end
  32
+
  33
+  setup do
  34
+    unless ActiveRecord::Base.connection.supports_transaction_isolation?
  35
+      skip "database does not support setting transaction isolation"
  36
+    end
  37
+
  38
+    Tag.establish_connection 'arunit'
  39
+    Tag2.establish_connection 'arunit'
  40
+    Tag.destroy_all
  41
+  end
  42
+
  43
+  # It is impossible to properly test read uncommitted. The SQL standard only
  44
+  # specifies what must not happen at a certain level, not what must happen. At
  45
+  # the read uncommitted level, there is nothing that must not happen.
  46
+  test "read uncommitted" do
  47
+    Tag.transaction(isolation: :read_uncommitted) do
  48
+      assert_equal 0, Tag.count
  49
+      Tag2.create
  50
+      assert_equal 1, Tag.count
  51
+    end
  52
+  end
  53
+
  54
+  # We are testing that a dirty read does not happen
  55
+  test "read committed" do
  56
+    Tag.transaction(isolation: :read_committed) do
  57
+      assert_equal 0, Tag.count
  58
+
  59
+      Tag2.transaction do
  60
+        Tag2.create
  61
+        assert_equal 0, Tag.count
  62
+      end
  63
+    end
  64
+
  65
+    assert_equal 1, Tag.count
  66
+  end
  67
+
  68
+  # We are testing that a nonrepeatable read does not happen
  69
+  test "repeatable read" do
  70
+    tag = Tag.create(name: 'jon')
  71
+
  72
+    Tag.transaction(isolation: :repeatable_read) do
  73
+      tag.reload
  74
+      Tag2.find(tag.id).update_attributes(name: 'emily')
  75
+
  76
+      tag.reload
  77
+      assert_equal 'jon', tag.name
  78
+    end
  79
+
  80
+    tag.reload
  81
+    assert_equal 'emily', tag.name
  82
+  end
  83
+
  84
+  # We are testing that a non-serializable sequence of statements will raise
  85
+  # an error.
  86
+  test "serializable" do
  87
+    if Tag2.connection.adapter_name =~ /mysql/i
  88
+      # Unfortunately it cannot be set to 0
  89
+      Tag2.connection.execute "SET innodb_lock_wait_timeout = 1"
  90
+    end
  91
+
  92
+    assert_raises ActiveRecord::StatementInvalid do
  93
+      Tag.transaction(isolation: :serializable) do
  94
+        Tag.create
  95
+
  96
+        Tag2.transaction(isolation: :serializable) do
  97
+          Tag2.create
  98
+          Tag2.count
  99
+        end
  100
+
  101
+        Tag.count
  102
+      end
  103
+    end
  104
+  end
  105
+
  106
+  test "setting isolation when joining a transaction raises an error" do
  107
+    Tag.transaction do
  108
+      assert_raises(ActiveRecord::TransactionIsolationError) do
  109
+        Tag.transaction(isolation: :serializable) { }
  110
+      end
  111
+    end
  112
+  end
  113
+
  114
+  test "setting isolation when starting a nested transaction raises error" do
  115
+    Tag.transaction do
  116
+      assert_raises(ActiveRecord::TransactionIsolationError) do
  117
+        Tag.transaction(requires_new: true, isolation: :serializable) { }
  118
+      end
  119
+    end
  120
+  end
  121
+end

0 notes on commit 392eeec

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.