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 +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
|