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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +32 -2
- data/lib/isolator/adapter_builder.rb +11 -5
- data/lib/isolator/adapters/base.rb +2 -2
- data/lib/isolator/adapters/http/sniffer.rb +6 -6
- data/lib/isolator/adapters/mailers/mail.rb +7 -7
- data/lib/isolator/callbacks.rb +24 -0
- data/lib/isolator/orm_adapters/active_record.rb +2 -2
- data/lib/isolator/orm_adapters/rom_active_support.rb +1 -1
- data/lib/isolator/plugins/concurrent_database_transactions.rb +25 -0
- data/lib/isolator/railtie.rb +23 -18
- data/lib/isolator/version.rb +1 -1
- data/lib/isolator.rb +28 -6
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc5300f95e1333baa45e8e10e1e13b1ebc03175b648791bfec5a87e160643e9e
|
|
4
|
+
data.tar.gz: e184eb7fc257b0ab5ae95878ea955769d9925b8b428dc6c469bcde5af89c69f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` >=
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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?(
|
|
31
|
-
enabled? && Isolator.enabled? && Isolator.within_transaction? && !ignored?(
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
}
|
data/lib/isolator/callbacks.rb
CHANGED
|
@@ -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 "
|
|
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 "
|
|
7
|
+
require_relative "active_support_transaction_subscriber"
|
|
8
8
|
Isolator::ActiveSupportTransactionSubscriber.subscribe!
|
|
9
9
|
else
|
|
10
10
|
Isolator::ActiveSupportSubscriber.subscribe!("sql.active_record")
|
|
@@ -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
|
data/lib/isolator/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
35
|
-
::ActiveRecord::TestFixtures.prepend(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
data/lib/isolator/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|