isolator 0.11.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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