isolator 0.11.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb3ecc2b4b93f35241a5f03804bc89fab1ab2a6d9d4fe33753ad827bc8a06ea5
4
- data.tar.gz: 8385b3b3654b25d1ec2ec178305cac41049523339da8d56c6ba7defb31996640
3
+ metadata.gz: dc5300f95e1333baa45e8e10e1e13b1ebc03175b648791bfec5a87e160643e9e
4
+ data.tar.gz: e184eb7fc257b0ab5ae95878ea955769d9925b8b428dc6c469bcde5af89c69f5
5
5
  SHA512:
6
- metadata.gz: a18d105a45e116af3120e4f9eefd69f1c533b14f1ce26b8bdd9c284fd99f87ddd8e3650ae3e7a73cc54592329726f549d16b374763f5bae6c71163e36499b55a
7
- data.tar.gz: cb72be0a0d3aa004dab07392b875e0d70472806517d7d89acdafd133f332a50f59232aed8c919c12ce15cd12399b29f7ef8e33f986e445046b9171765db4ae74
6
+ metadata.gz: 2668126bfa3de56db202b28dc4eeb5267a2acccf2843d2843d7928cc7fbd96d3a7f42b90b1e652b97857b9f2e3f75e009bacacc4ab9532d4c7ebfbbeea35fb56
7
+ data.tar.gz: 252e668eb55d8f4b8ddda8567833be514b595817d2064d35190bdd74719409568a7779e0bcf8564da4d32e45bc3d3fe6a5ae5afe3ec1d6018dfb0db107c1418d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.0.0 (2023-11-30)
6
+
7
+ - Add ability to track concurrent transactions to with a thread (e.g., to multiple databases). ([@palkan][])
8
+
9
+ This feature is disabled by default, opt-in via: `Isolator.config.disallow_per_thread_concurrent_transactions = true`.
10
+
11
+ - Add `Isolator.on_transaction_begin` and `Isolator.on_transaction_end` callbacks. ([@palkan][])
12
+
13
+ - Drop Ruby 2.6 and Rails 5 support. ([@palkan][])
14
+
5
15
  ## 0.11.0 (2023-09-27)
6
16
 
7
17
  - Use Rails new `transaction.active_record` event if available to better handle edge cases. ([@palkan][])
data/README.md CHANGED
@@ -101,6 +101,9 @@ Isolator.configure do |config|
101
101
  # Define a custom ignorer class (must implement .prepare)
102
102
  # uses a row number based list from the .isolator_todo.yml file
103
103
  config.ignorer = Isolator::Ignorer
104
+
105
+ # Turn on/off raising exceptions for simultaneous transactions to different databases
106
+ config.disallow_cross_database_transactions = false
104
107
  end
105
108
  ```
106
109
 
@@ -108,6 +111,34 @@ Isolator relies on [uniform_notifier][] to send custom notifications.
108
111
 
109
112
  **NOTE:** `uniform_notifier` should be installed separately (i.e., added to Gemfile).
110
113
 
114
+ ### Callbacks
115
+
116
+ Isolator different callbacks so you can inject your own logic or build custom extensions.
117
+
118
+ ```ruby
119
+ # This callback is called when Isolator enters the "danger zone"—a within-transaction context
120
+ Isolator.before_isolate do
121
+ puts "Entering a database transaction. Be careful!"
122
+ end
123
+
124
+ # This callback is called when Isolator leaves the "danger zone"
125
+ Isolator.after_isolate do
126
+ puts "Leaving a database transaction. Everything is fine. Feel free to call slow HTTP APIs"
127
+ end
128
+
129
+ # This callback is called every time a new transaction is open (root or nested)
130
+ Isolator.on_transaction_open do |event|
131
+ puts "New transaction from #{event[:connection_id]}. " \
132
+ "Current depth: #{event[:depth]}"
133
+ end
134
+
135
+ # This callback is called every time a transaction is completed
136
+ Isolator.on_transaction_close do |event|
137
+ puts "Transaction completed from #{event[:connection_id]}. " \
138
+ "Current depth: #{event[:depth]}"
139
+ end
140
+ ```
141
+
111
142
  ### Transactional tests support
112
143
 
113
144
  - Rails' baked-in [use_transactional_tests](https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html#class-ActiveRecord::FixtureSet-label-Transactional+Tests)
@@ -115,7 +146,7 @@ Isolator relies on [uniform_notifier][] to send custom notifications.
115
146
 
116
147
  ### Supported ORMs
117
148
 
118
- - `ActiveRecord` >= 5.1 (4.2 likely till works, but we do not test against it anymore)
149
+ - `ActiveRecord` >= 6.0 (see older versions of Isolator for previous versions)
119
150
  - `ROM::SQL` (only if Active Support instrumentation extension is loaded)
120
151
 
121
152
  ### Adapters
@@ -159,7 +190,6 @@ Isolator.adapters.sidekiq.ignore_if { Thread.current[:sidekiq_postpone] }
159
190
 
160
191
  You can add as many _ignores_ as you want, the offense is registered iff all of them return false.
161
192
 
162
-
163
193
  ### Using with sidekiq/testing
164
194
 
165
195
  If you require sidekiq/testing in your tests after isolator is required then it will blow away isolator's hooks, so you need to require isolator after requiring sidekiq/testing.
@@ -31,12 +31,18 @@ module Isolator
31
31
  def build_mod(method_name, adapter)
32
32
  return nil unless method_name
33
33
 
34
+ adapter_name = "__isolator_adapter_#{adapter.object_id}"
35
+
34
36
  Module.new do
35
- define_method method_name do |*args, **kwargs, &block|
36
- # check if we are even notifying before calling `caller`, which is well known to be slow
37
- adapter.notify(caller, self, *args, **kwargs) if adapter.notify?(*args, **kwargs)
38
- super(*args, **kwargs, &block)
39
- end
37
+ define_method(adapter_name) { adapter }
38
+
39
+ module_eval <<~RUBY, __FILE__, __LINE__ + 1
40
+ def #{method_name}(...)
41
+ # check if we are even notifying before calling `caller`, which is well known to be slow
42
+ #{adapter_name}.notify(caller, self, ...) if #{adapter_name}.notify?(...)
43
+ super
44
+ end
45
+ RUBY
40
46
  end
41
47
  end
42
48
  end
@@ -27,8 +27,8 @@ module Isolator
27
27
  Isolator.notify(exception: build_exception(obj, args, kwargs), backtrace: backtrace)
28
28
  end
29
29
 
30
- def notify?(*args, **kwargs)
31
- enabled? && Isolator.enabled? && Isolator.within_transaction? && !ignored?(*args, **kwargs)
30
+ def notify?(...)
31
+ enabled? && Isolator.enabled? && Isolator.within_transaction? && !ignored?(...)
32
32
  end
33
33
 
34
34
  def ignore_if(&block)
@@ -6,12 +6,12 @@ require "sniffer"
6
6
  Sniffer::Config.defaults["logger"] = nil
7
7
 
8
8
  Isolator.isolate :http, target: Sniffer.singleton_class,
9
- method_name: :store,
10
- exception_class: Isolator::HTTPError,
11
- details_message: ->(_obj, args) {
12
- req = args.first.request
13
- "#{req.method} #{req.host}:#{req.port}#{req.query}"
14
- }
9
+ method_name: :store,
10
+ exception_class: Isolator::HTTPError,
11
+ details_message: ->(_obj, args) {
12
+ req = args.first.request
13
+ "#{req.method} #{req.host}:#{req.port}#{req.query}"
14
+ }
15
15
 
16
16
  Isolator.before_isolate do
17
17
  next if Isolator.adapters.http.disabled?
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Isolator.isolate :mailer, target: Mail::Message,
4
- method_name: :deliver,
5
- exception_class: Isolator::MailerError,
6
- details_message: ->(obj) {
7
- "From: #{obj.from}\n" \
8
- "To: #{obj.to}\n" \
9
- "Subject: #{obj.subject}"
10
- }
4
+ method_name: :deliver,
5
+ exception_class: Isolator::MailerError,
6
+ details_message: ->(obj) {
7
+ "From: #{obj.from}\n" \
8
+ "To: #{obj.to}\n" \
9
+ "Subject: #{obj.subject}"
10
+ }
@@ -11,6 +11,14 @@ module Isolator
11
11
  after_isolate_callbacks << block
12
12
  end
13
13
 
14
+ def on_transaction_begin(&block)
15
+ transaction_begin_callbacks << block
16
+ end
17
+
18
+ def on_transaction_end(&block)
19
+ transaction_end_callbacks << block
20
+ end
21
+
14
22
  def start!
15
23
  return if Isolator.disabled?
16
24
  before_isolate_callbacks.each(&:call)
@@ -21,6 +29,14 @@ module Isolator
21
29
  after_isolate_callbacks.each(&:call)
22
30
  end
23
31
 
32
+ def notify!(event, payload)
33
+ if event == :begin
34
+ transaction_begin_callbacks.each { |cb| cb.call(payload) }
35
+ elsif event == :end
36
+ transaction_end_callbacks.each { |cb| cb.call(payload) }
37
+ end
38
+ end
39
+
24
40
  def before_isolate_callbacks
25
41
  @before_isolate_callbacks ||= []
26
42
  end
@@ -28,5 +44,13 @@ module Isolator
28
44
  def after_isolate_callbacks
29
45
  @after_isolate_callbacks ||= []
30
46
  end
47
+
48
+ def transaction_begin_callbacks
49
+ @transaction_begin_callbacks ||= []
50
+ end
51
+
52
+ def transaction_end_callbacks
53
+ @transaction_end_callbacks ||= []
54
+ end
31
55
  end
32
56
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./active_support_subscriber"
3
+ require_relative "active_support_subscriber"
4
4
 
5
5
  # We rely on this feature introduced in 7.1.0.beta1: https://github.com/rails/rails/pull/49192
6
6
  if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1
7
- require_relative "./active_support_transaction_subscriber"
7
+ require_relative "active_support_transaction_subscriber"
8
8
  Isolator::ActiveSupportTransactionSubscriber.subscribe!
9
9
  else
10
10
  Isolator::ActiveSupportSubscriber.subscribe!("sql.active_record")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./active_support_subscriber"
3
+ require_relative "active_support_subscriber"
4
4
 
5
5
  Isolator::ActiveSupportSubscriber.subscribe!("sql.rom")
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Isolator
4
+ class Configuration
5
+ attr_accessor :disallow_per_thread_concurrent_transactions
6
+
7
+ alias_method :disallow_per_thread_concurrent_transactions?, :disallow_per_thread_concurrent_transactions
8
+ end
9
+
10
+ class ConcurrentTransactionError < UnsafeOperationError
11
+ MESSAGE = "You are trying to open a transaction while there is an open transation to another database." \
12
+ end
13
+
14
+ Isolator.before_isolate do
15
+ next unless Isolator.config.disallow_per_thread_concurrent_transactions?
16
+
17
+ isolated_connections = Isolator.all_transactions.count do |conn_id, depth|
18
+ depth >= Isolator.connection_threshold(conn_id)
19
+ end
20
+
21
+ next unless isolated_connections > 1
22
+
23
+ Isolator.notify(exception: ConcurrentTransactionError.new, backtrace: caller)
24
+ end
25
+ end
@@ -2,6 +2,22 @@
2
2
 
3
3
  module Isolator
4
4
  class Railtie < ::Rails::Railtie # :nodoc:
5
+ module TestFixtures
6
+ def setup_fixtures(*)
7
+ super
8
+ return unless run_in_transaction?
9
+
10
+ Isolator.incr_thresholds!
11
+ end
12
+
13
+ def teardown_fixtures(*)
14
+ if run_in_transaction?
15
+ Isolator.decr_thresholds!
16
+ end
17
+ super
18
+ end
19
+ end
20
+
5
21
  initializer "isolator.backtrace_cleaner" do
6
22
  ActiveSupport.on_load(:active_record) do
7
23
  Isolator.backtrace_cleaner = lambda do |locations|
@@ -31,24 +47,13 @@ module Isolator
31
47
 
32
48
  next unless Rails.env.test?
33
49
 
34
- if defined?(::ActiveRecord::TestFixtures)
35
- ::ActiveRecord::TestFixtures.prepend(
36
- Module.new do
37
- def setup_fixtures(*)
38
- super
39
- return unless run_in_transaction?
40
-
41
- Isolator.incr_thresholds!
42
- end
43
-
44
- def teardown_fixtures(*)
45
- if run_in_transaction?
46
- Isolator.decr_thresholds!
47
- end
48
- super
49
- end
50
- end
51
- )
50
+ ActiveSupport.on_load(:active_record_fixtures) do
51
+ ::ActiveRecord::TestFixtures.prepend(TestFixtures)
52
+ end
53
+
54
+ # Rails 6 doesn't support this load hook, so we can emulate it
55
+ if ActiveRecord::VERSION::MAJOR < 7 && defined?(::ActiveRecord::TestFixtures)
56
+ ::ActiveRecord::TestFixtures.prepend(TestFixtures)
52
57
  end
53
58
  end
54
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Isolator
4
- VERSION = "0.11.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/isolator.rb CHANGED
@@ -94,6 +94,14 @@ module Isolator
94
94
  state[:transactions]&.[](connection_id) || 0
95
95
  end
96
96
 
97
+ def all_transactions
98
+ state[:transactions] || {}
99
+ end
100
+
101
+ def connection_threshold(connection_id)
102
+ state[:thresholds]&.[](connection_id) || default_threshold
103
+ end
104
+
97
105
  def set_connection_threshold(val, connection_id = default_connection_id.call)
98
106
  state[:thresholds] ||= Hash.new { |h, k| h[k] = Isolator.default_threshold }
99
107
  state[:thresholds][connection_id] = val
@@ -131,7 +139,14 @@ module Isolator
131
139
 
132
140
  debug!("Transaction opened for connection #{connection_id} (total: #{state[:transactions][connection_id]}, threshold: #{state[:thresholds]&.fetch(connection_id, default_threshold)})")
133
141
 
134
- start! if current_transactions(connection_id) == connection_threshold(connection_id)
142
+ current_depth = current_transactions(connection_id)
143
+ threshold = connection_threshold(connection_id)
144
+
145
+ start! if current_depth == threshold
146
+ if current_depth >= threshold
147
+ event = {connection_id: connection_id, depth: current_depth - threshold + 1}.freeze
148
+ notify!(:begin, event)
149
+ end
135
150
  end
136
151
 
137
152
  def decr_transactions!(connection_id = default_connection_id.call)
@@ -144,7 +159,15 @@ module Isolator
144
159
 
145
160
  state[:transactions][connection_id] -= 1
146
161
 
147
- finish! if current_transactions(connection_id) == (connection_threshold(connection_id) - 1)
162
+ current_depth = current_transactions(connection_id)
163
+ threshold = connection_threshold(connection_id)
164
+
165
+ if current_depth >= threshold - 1
166
+ event = {connection_id: connection_id, depth: current_depth - threshold + 1}.freeze
167
+ notify!(:end, event)
168
+ end
169
+
170
+ finish! if current_depth == (threshold - 1)
148
171
 
149
172
  state[:transactions].delete(connection_id) if state[:transactions][connection_id].zero?
150
173
 
@@ -192,10 +215,6 @@ module Isolator
192
215
 
193
216
  attr_accessor :state
194
217
 
195
- def connection_threshold(connection_id)
196
- state[:thresholds]&.[](connection_id) || default_threshold
197
- end
198
-
199
218
  def debug!(msg)
200
219
  return unless debug_enabled
201
220
 
@@ -241,3 +260,6 @@ require "isolator/orm_adapters"
241
260
  require "isolator/adapters"
242
261
  require "isolator/railtie" if defined?(Rails)
243
262
  require "isolator/database_cleaner_support" if defined?(DatabaseCleaner)
263
+
264
+ # Built-in extensions
265
+ require "isolator/plugins/concurrent_database_transactions"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isolator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-09-28 00:00:00.000000000 Z
11
+ date: 2023-12-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sniffer
@@ -315,6 +315,7 @@ files:
315
315
  - lib/isolator/orm_adapters/active_support_subscriber.rb
316
316
  - lib/isolator/orm_adapters/active_support_transaction_subscriber.rb
317
317
  - lib/isolator/orm_adapters/rom_active_support.rb
318
+ - lib/isolator/plugins/concurrent_database_transactions.rb
318
319
  - lib/isolator/railtie.rb
319
320
  - lib/isolator/simple_hashie.rb
320
321
  - lib/isolator/version.rb
@@ -336,14 +337,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
336
337
  requirements:
337
338
  - - ">="
338
339
  - !ruby/object:Gem::Version
339
- version: 2.6.0
340
+ version: 2.7.0
340
341
  required_rubygems_version: !ruby/object:Gem::Requirement
341
342
  requirements:
342
343
  - - ">="
343
344
  - !ruby/object:Gem::Version
344
345
  version: '0'
345
346
  requirements: []
346
- rubygems_version: 3.4.8
347
+ rubygems_version: 3.4.20
347
348
  signing_key:
348
349
  specification_version: 4
349
350
  summary: Detect non-atomic interactions within DB transactions