isolator 0.0.1 → 0.1.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/.rubocop.yml +6 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +6 -4
- data/README.md +117 -1
- data/gemfiles/activerecord42.gemfile +4 -4
- data/gemfiles/jruby.gemfile +5 -5
- data/gemfiles/railsmaster.gemfile +5 -5
- data/isolator.gemspec +7 -0
- data/lib/isolator/adapter_builder.rb +31 -0
- data/lib/isolator/adapters/background_jobs/active_job.rb +4 -0
- data/lib/isolator/adapters/background_jobs/sidekiq.rb +4 -0
- data/lib/isolator/adapters/background_jobs.rb +7 -0
- data/lib/isolator/adapters/base.rb +35 -0
- data/lib/isolator/adapters/http.rb +20 -0
- data/lib/isolator/adapters.rb +4 -0
- data/lib/isolator/callbacks.rb +30 -0
- data/lib/isolator/configuration.rb +30 -0
- data/lib/isolator/errors.rb +21 -0
- data/lib/isolator/ext/thread_fetch.rb +13 -0
- data/lib/isolator/isolate.rb +12 -0
- data/lib/isolator/notifier.rb +64 -0
- data/lib/isolator/orm_adapters/active_record.rb +5 -0
- data/lib/isolator/orm_adapters/active_support_subscriber.rb +17 -0
- data/lib/isolator/orm_adapters/rom_active_support.rb +5 -0
- data/lib/isolator/orm_adapters.rb +10 -0
- data/lib/isolator/railtie.rb +34 -0
- data/lib/isolator/simple_hashie.rb +25 -0
- data/lib/isolator/version.rb +1 -1
- data/lib/isolator.rb +88 -1
- metadata +94 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 49bd3e78bcb76db6246085d139aba6aecda0ed438eb4a8241765034d92e307c0
|
4
|
+
data.tar.gz: 525b2c3d728493a7c4b57f0552dbc0434b9012db07f9b3e3b79d6b585349e5b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8e272766ea2a5387632c203388fb0781d39897ff2a426a8fb8221b6e483bfc785214e26bff3f01eedefa1e40fa73a33816e7d617033a46d883bc57b9bdb26835
|
7
|
+
data.tar.gz: 8ca5b2671dca29a767ac8a33d8e215387850b817dda34f47a7ee455f349fc2c6e42d5fac1e07a83f8bbb2347cbf5a5d6279e4236346db0d3241e08f345253326
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
@@ -22,6 +22,9 @@ AllCops:
|
|
22
22
|
Rails:
|
23
23
|
Enabled: false
|
24
24
|
|
25
|
+
Bundler/OrderedGems:
|
26
|
+
Enabled: false
|
27
|
+
|
25
28
|
Style/SymbolArray:
|
26
29
|
Enabled: false
|
27
30
|
|
@@ -39,6 +42,9 @@ Style/BlockDelimiters:
|
|
39
42
|
Exclude:
|
40
43
|
- 'spec/**/*.rb'
|
41
44
|
|
45
|
+
Style/NumericPredicate:
|
46
|
+
Enabled: false
|
47
|
+
|
42
48
|
Layout/SpaceInsideStringInterpolation:
|
43
49
|
EnforcedStyle: no_space
|
44
50
|
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
@@ -5,12 +5,14 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
5
5
|
# Specify your gem's dependencies in isolator.gemspec
|
6
6
|
gemspec
|
7
7
|
|
8
|
-
gem
|
9
|
-
gem 'sqlite3'
|
10
|
-
gem 'activerecord', '>= 5.0'
|
8
|
+
gem "pry-byebug"
|
11
9
|
|
12
|
-
|
10
|
+
gem "sqlite3"
|
11
|
+
|
12
|
+
local_gemfile = File.join(__dir__, "Gemfile.local")
|
13
13
|
|
14
14
|
if File.exist?(local_gemfile)
|
15
15
|
eval(File.read(local_gemfile)) # rubocop:disable Security/Eval
|
16
|
+
else
|
17
|
+
gem "rails", "~> 5.0"
|
16
18
|
end
|
data/README.md
CHANGED
@@ -27,19 +27,132 @@ end
|
|
27
27
|
#=> raises Isolator::BackgroundJobError
|
28
28
|
```
|
29
29
|
|
30
|
+
Of course, Isolator can detect _implicit_ transactions too. Consider this pretty common bad practice–enqueueing background job from `after_create` callback:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
class Comment < ApplicationRecord
|
34
|
+
# the good way is to use after_create_commit
|
35
|
+
# (or not use callbacks at all)
|
36
|
+
after_create :notify_author
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def notify_author
|
41
|
+
CommentMailer.comment_created(self).deliver_later
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
Comment.create(text: "Mars is watching you!")
|
46
|
+
#=> raises Isolator::BackgroundJobError
|
47
|
+
```
|
48
|
+
|
49
|
+
Isolator is supposed to be used in tests and on staging.
|
50
|
+
|
30
51
|
## Installation
|
31
52
|
|
32
53
|
Add this line to your application's Gemfile:
|
33
54
|
|
34
55
|
```ruby
|
56
|
+
# We suppose that Isolator is used in development and test
|
57
|
+
# environments.
|
35
58
|
group :development, :test do
|
36
59
|
gem "isolator"
|
37
60
|
end
|
61
|
+
|
62
|
+
# Or you can add it to Gemfile with `require: false`
|
63
|
+
# and require it manually in your code.
|
64
|
+
#
|
65
|
+
# This approach is useful when you want to use it in staging env too.
|
66
|
+
gem "isolator", require: false
|
38
67
|
```
|
39
68
|
|
40
69
|
## Usage
|
41
70
|
|
42
|
-
|
71
|
+
Isolator is a plug-n-play tool, so, it begins to work right after required.
|
72
|
+
|
73
|
+
However, there are some potential caveats:
|
74
|
+
|
75
|
+
1) Isolator tries to detect the environment automatically and includes only necessary adapters. Thus the order of loading gems matters: make sure that `isolator` is required in the end (NOTE: in Rails, all adapters loaded after application initialization).
|
76
|
+
|
77
|
+
2) Isolator does not distinguish framework-level adapters. For example, `:active_job` spy doesn't take into account which AJ adapter you use; if you are using a safe one (e.g. `Que`) just disable the `:active_job` adapter to avoid false negatives (i.e. `Isolator.adapters.active_job.disable!`).
|
78
|
+
|
79
|
+
3) Isolator tries to detect the `test` environment and slightly change its behavior: first, it respect _transactional tests_; secondly, error raising is turned on by default (see [below](#configuration)).
|
80
|
+
|
81
|
+
### Configuration
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
Isolator.configure do |config|
|
85
|
+
# Specify a custom logger to log offenses
|
86
|
+
config.logger = nil
|
87
|
+
|
88
|
+
# Raise exception on offense
|
89
|
+
config.raise_exceptions = false # true in test env
|
90
|
+
|
91
|
+
# Send notifications to uniform_notifier
|
92
|
+
config.send_notifications = false
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
Isolator relys on [uniform_notifier][] to send custom notifications.
|
97
|
+
|
98
|
+
**NOTE:** `uniform_notifier` should be installed separately (i.e., added to Gemfile).
|
99
|
+
|
100
|
+
### Supported ORMs
|
101
|
+
|
102
|
+
- `ActiveRecord` >= 4.1
|
103
|
+
- `ROM::SQL` (only if Active Support instrumentation extenstion is loaded)
|
104
|
+
|
105
|
+
### Adapters
|
106
|
+
|
107
|
+
Isolator has a bunch of built-in adapters:
|
108
|
+
- `:http` – built on top of [Sniffer][]
|
109
|
+
- `:active_job`
|
110
|
+
- `:sidekiq`
|
111
|
+
|
112
|
+
You can dynamically enable/disable adapters, e.g.:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
# Disable HTTP adapter == do not spy on HTTP requests
|
116
|
+
Isolator.adapters.http.disable!
|
117
|
+
|
118
|
+
# Enable back
|
119
|
+
|
120
|
+
Isolator.adapters.http.enable!
|
121
|
+
```
|
122
|
+
|
123
|
+
## Custom Adapters
|
124
|
+
|
125
|
+
An adapter is just a combination of a _method wrapper_ and lifecycle hooks.
|
126
|
+
|
127
|
+
Suppose that you have a class `Danger` with a method `#explode`, which is not safe to be run within a DB transaction. Then you can _isolate_ it (i.e., register with Isolator):
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
# The first argument is a unique adapter id,
|
131
|
+
# you can use it later to enable/disable the adapter
|
132
|
+
#
|
133
|
+
# The second argument is the method owner and
|
134
|
+
# the third one is a method name.
|
135
|
+
Isolotar.isolate :danger, Danger, :explode, **options
|
136
|
+
|
137
|
+
# NOTE: if you want to isolate a class method, use signleton_class instead
|
138
|
+
Isolator.isolate :danger, Danger.singleton_class, :explode, **options
|
139
|
+
```
|
140
|
+
|
141
|
+
Possible `options` are:
|
142
|
+
- `exception_class` – an exception class to raise in case of offense
|
143
|
+
- `exception_message` – custom exception message (could be specified without a class)
|
144
|
+
|
145
|
+
You can also add some callbacks to be run before and after the transaction:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
Isolator.before_isolate do
|
149
|
+
# right after we enter the transaction
|
150
|
+
end
|
151
|
+
|
152
|
+
Isolator.after_isolate do
|
153
|
+
# right after the transaction has been committed/rollbacked
|
154
|
+
end
|
155
|
+
```
|
43
156
|
|
44
157
|
## Contributing
|
45
158
|
|
@@ -48,3 +161,6 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/palkan
|
|
48
161
|
## License
|
49
162
|
|
50
163
|
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
164
|
+
|
165
|
+
[Sniffer]: https://github.com/aderyabin/sniffer
|
166
|
+
[uniform_notifier]: https://github.com/flyerhzm/uniform_notifier
|
data/gemfiles/jruby.gemfile
CHANGED
@@ -1,7 +1,7 @@
|
|
1
|
-
source
|
1
|
+
source "https://rubygems.org"
|
2
2
|
|
3
|
-
gem
|
4
|
-
gem
|
5
|
-
gem
|
3
|
+
gem "activerecord-jdbcsqlite3-adapter", "~> 50.0"
|
4
|
+
gem "jdbc-sqlite3"
|
5
|
+
gem "rails", "~> 5.0"
|
6
6
|
|
7
|
-
gemspec path:
|
7
|
+
gemspec path: ".."
|
@@ -1,7 +1,7 @@
|
|
1
|
-
source
|
1
|
+
source "https://rubygems.org"
|
2
2
|
|
3
|
-
gem
|
4
|
-
gem
|
5
|
-
gem
|
3
|
+
gem "arel", github: "rails/arel"
|
4
|
+
gem "rails", github: "rails/rails"
|
5
|
+
gem "sqlite3"
|
6
6
|
|
7
|
-
gemspec path:
|
7
|
+
gemspec path: ".."
|
data/isolator.gemspec
CHANGED
@@ -20,9 +20,16 @@ Gem::Specification.new do |spec|
|
|
20
20
|
end
|
21
21
|
spec.require_paths = ["lib"]
|
22
22
|
|
23
|
+
spec.add_runtime_dependency "sniffer", "~> 0.3.0"
|
24
|
+
|
23
25
|
spec.add_development_dependency "bundler", "~> 1.14"
|
24
26
|
spec.add_development_dependency "rake", "~> 10.0"
|
25
27
|
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency "rspec-rails", "~> 3.0"
|
29
|
+
spec.add_development_dependency "minitest", "~> 5.10.0"
|
26
30
|
spec.add_development_dependency "rubocop", "~> 0.51"
|
27
31
|
spec.add_development_dependency "rubocop-md", "~> 0.2"
|
32
|
+
|
33
|
+
spec.add_development_dependency "uniform_notifier", "~> 1.11"
|
34
|
+
spec.add_development_dependency "sidekiq", "~> 5.0"
|
28
35
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "isolator/adapters/base"
|
4
|
+
|
5
|
+
module Isolator
|
6
|
+
# Builds adapter from provided params
|
7
|
+
module AdapterBuilder
|
8
|
+
def self.call(target, method_name, **options)
|
9
|
+
adapter = Module.new do
|
10
|
+
extend Isolator::Adapters::Base
|
11
|
+
|
12
|
+
self.exception_class = options[:exception_class] if options.key?(:exception_class)
|
13
|
+
self.exception_message = options[:exception_message] if options.key?(:exception_message)
|
14
|
+
end
|
15
|
+
|
16
|
+
add_patch_method adapter, target, method_name
|
17
|
+
adapter
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.add_patch_method(adapter, base, method_name)
|
21
|
+
mod = Module.new do
|
22
|
+
define_method method_name do |*args, &block|
|
23
|
+
adapter.notify(caller) if adapter.notify_isolator?
|
24
|
+
super(*args, &block)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
base.prepend mod
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
module Adapters
|
5
|
+
# Used as a "template" for adapters
|
6
|
+
module Base
|
7
|
+
attr_accessor :exception_class, :exception_message
|
8
|
+
|
9
|
+
def disable!
|
10
|
+
@disabled = true
|
11
|
+
end
|
12
|
+
|
13
|
+
def enable!
|
14
|
+
@disabled = false
|
15
|
+
end
|
16
|
+
|
17
|
+
def enabled?
|
18
|
+
@disabled != true
|
19
|
+
end
|
20
|
+
|
21
|
+
def notify(backtrace)
|
22
|
+
Isolator.notify(exception: build_exception, backtrace: backtrace)
|
23
|
+
end
|
24
|
+
|
25
|
+
def notify_isolator?
|
26
|
+
enabled? && Isolator.within_transaction?
|
27
|
+
end
|
28
|
+
|
29
|
+
def build_exception
|
30
|
+
klass = exception_class || Isolator::UnsafeOperationError
|
31
|
+
klass.new(exception_message)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sniffer"
|
4
|
+
|
5
|
+
Sniffer.config do |c|
|
6
|
+
# Disable Sniffer logger
|
7
|
+
c.logger = Logger.new(IO::NULL)
|
8
|
+
end
|
9
|
+
|
10
|
+
Isolator.isolate :http, Sniffer.singleton_class,
|
11
|
+
:store, exception_class: Isolator::HTTPError
|
12
|
+
|
13
|
+
Isolator.before_isolate do
|
14
|
+
Sniffer.enable!
|
15
|
+
end
|
16
|
+
|
17
|
+
Isolator.after_isolate do
|
18
|
+
Sniffer.clear!
|
19
|
+
Sniffer.disable!
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# Add before_isolate and after_isolate callbacks
|
5
|
+
module Callbacks
|
6
|
+
def before_isolate(&block)
|
7
|
+
before_isolate_callbacks << block
|
8
|
+
end
|
9
|
+
|
10
|
+
def after_isolate(&block)
|
11
|
+
after_isolate_callbacks << block
|
12
|
+
end
|
13
|
+
|
14
|
+
def start!
|
15
|
+
before_isolate_callbacks.each(&:call)
|
16
|
+
end
|
17
|
+
|
18
|
+
def finish!
|
19
|
+
after_isolate_callbacks.each(&:call)
|
20
|
+
end
|
21
|
+
|
22
|
+
def before_isolate_callbacks
|
23
|
+
@before_isolate_callbacks ||= []
|
24
|
+
end
|
25
|
+
|
26
|
+
def after_isolate_callbacks
|
27
|
+
@after_isolate_callbacks ||= []
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# Isolator configuration:
|
5
|
+
#
|
6
|
+
# - `raise_exceptions` - whether to raise an exception in case of offense;
|
7
|
+
# defaults to true in test env and false otherwise.
|
8
|
+
# NOTE: env is infered from RACK_ENV and RAILS_ENV.
|
9
|
+
#
|
10
|
+
# - `logger` - logger instance (nil by default)
|
11
|
+
#
|
12
|
+
# - `send_notifications` - whether to send notifications (through uniform_notifier);
|
13
|
+
# defauls to false
|
14
|
+
class Configuration
|
15
|
+
attr_accessor :raise_exceptions, :logger, :send_notifications
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@logger = nil
|
19
|
+
@raise_exceptions = test_env?
|
20
|
+
@send_notifications = false
|
21
|
+
end
|
22
|
+
|
23
|
+
alias raise_exceptions? raise_exceptions
|
24
|
+
alias send_notifications? send_notifications
|
25
|
+
|
26
|
+
def test_env?
|
27
|
+
ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator # :nodoc: all
|
4
|
+
class UnsafeOperationError < StandardError
|
5
|
+
MESSAGE = "You are trying to do unsafe operation inside db transaction"
|
6
|
+
|
7
|
+
def initialize(msg = nil)
|
8
|
+
msg ||= self.class::MESSAGE
|
9
|
+
super
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class HTTPError < UnsafeOperationError
|
14
|
+
MESSAGE = "You are trying to make an outgoing network request inside db transaction. "
|
15
|
+
end
|
16
|
+
|
17
|
+
class BackgroundJobError < UnsafeOperationError
|
18
|
+
MESSAGE = "You are trying to enqueue background job inside db transaction. " \
|
19
|
+
"In case of transaction failure, this may lead to data inconsistency and unexpected bugs"
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
module ThreadFetch # :nodoc:
|
5
|
+
refine Thread do
|
6
|
+
def fetch(key, fallback = :__undef__)
|
7
|
+
raise KeyError, "key not found: #{key}" if !key?(key) && fallback == :__undef__
|
8
|
+
|
9
|
+
self[key] || fallback
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# Add .isolate function to build and register adapters
|
5
|
+
module Isolate
|
6
|
+
def isolate(id, target_module, method_name, **options)
|
7
|
+
raise "Adapter already registered: #{id}" if Isolator.adapters.key?(id.to_s)
|
8
|
+
adapter = AdapterBuilder.call(target_module, method_name, **options)
|
9
|
+
Isolator.adapters[id.to_s] = adapter
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# Wrapper over different notifications methods (exceptions, logging, uniform notifier)
|
5
|
+
class Notifier
|
6
|
+
attr_reader :exception, :backtrace
|
7
|
+
|
8
|
+
def initialize(exception, backtrace = caller)
|
9
|
+
@exception = exception
|
10
|
+
@backtrace = backtrace
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
log_exception
|
15
|
+
send_notifications if send_notifications?
|
16
|
+
raise(exception.class, exception.message, filtered_backtrace) if raise_exceptions?
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def raise_exceptions?
|
22
|
+
Isolator.config.raise_exceptions?
|
23
|
+
end
|
24
|
+
|
25
|
+
def send_notifications?
|
26
|
+
Isolator.config.send_notifications?
|
27
|
+
end
|
28
|
+
|
29
|
+
def log_exception
|
30
|
+
return unless Isolator.config.logger
|
31
|
+
Isolator.config.logger.warn(
|
32
|
+
"[ISOLATOR EXCEPTION]\n" \
|
33
|
+
"#{exception.message}\n" \
|
34
|
+
" ↳ #{filtered_backtrace.first}"
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
def send_notifications
|
39
|
+
return unless uniform_notifier_loaded?
|
40
|
+
|
41
|
+
::UniformNotifier.active_notifiers.each do |notifier|
|
42
|
+
notifier.out_of_channel_notify exception.message
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def filtered_backtrace
|
47
|
+
backtrace.reject { |line| line =~ /gems/ }.take_while { |line| line !~ /ruby/ }
|
48
|
+
end
|
49
|
+
|
50
|
+
def uniform_notifier_loaded?
|
51
|
+
return true if defined?(::UniformNotifier)
|
52
|
+
|
53
|
+
begin
|
54
|
+
require "uniform_notifier"
|
55
|
+
rescue LoadError
|
56
|
+
warn(
|
57
|
+
"Please, install and configure 'uniform_notifier' to send notifications:\n" \
|
58
|
+
"# Gemfile\n" \
|
59
|
+
"gem 'uniform_notifer', '~> 1.11', require: false"
|
60
|
+
)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# ActiveSupport notifications listener
|
5
|
+
# Used for ActiveRecord and ROM::SQL (when instrumentation is available)
|
6
|
+
module ActiveSupportSubscriber
|
7
|
+
START_PATTERN = %r{(\ABEGIN|\ASAVEPOINT)}xi
|
8
|
+
FINISH_PATTERN = %r{(\ACOMMIT|\AROLLBACK|\ARELEASE|\AEND TRANSACTION)}xi
|
9
|
+
|
10
|
+
def self.subscribe!(event)
|
11
|
+
::ActiveSupport::Notifications.subscribe(event) do |_name, _start, _finish, _id, query|
|
12
|
+
Isolator.incr_transactions! if query[:sql] =~ START_PATTERN
|
13
|
+
Isolator.decr_transactions! if query[:sql] =~ FINISH_PATTERN
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
if defined?(ActiveSupport)
|
4
|
+
ActiveSupport.on_load(:active_record) do
|
5
|
+
require "isolator/orm_adapters/active_record"
|
6
|
+
end
|
7
|
+
|
8
|
+
require "isolator/orm_adapter/rom_active_support" if
|
9
|
+
defined?(::ROM::SQL::ActiveSupportInstrumentation)
|
10
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
class Railtie < ::Rails::Railtie # :nodoc:
|
5
|
+
config.after_initialize do
|
6
|
+
# Load adapters
|
7
|
+
require "isolator/adapters"
|
8
|
+
|
9
|
+
next unless Rails.env.test?
|
10
|
+
|
11
|
+
if defined?(::ActiveRecord::TestFixtures)
|
12
|
+
::ActiveRecord::TestFixtures.prepend(
|
13
|
+
Module.new do
|
14
|
+
def setup_fixtures(*)
|
15
|
+
super
|
16
|
+
return unless run_in_transaction?
|
17
|
+
|
18
|
+
open_count = ActiveRecord::Base.connection.open_transactions
|
19
|
+
Isolator.transactions_threshold += open_count
|
20
|
+
end
|
21
|
+
|
22
|
+
def teardown_fixtures(*)
|
23
|
+
if run_in_transaction?
|
24
|
+
open_count = ActiveRecord::Base.connection.open_transactions
|
25
|
+
Isolator.transactions_threshold -= open_count
|
26
|
+
end
|
27
|
+
super
|
28
|
+
end
|
29
|
+
end
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Isolator
|
4
|
+
# Hash with key accessors
|
5
|
+
class SimpleHashie < Hash
|
6
|
+
def method_missing(key, *args, &block)
|
7
|
+
key_str = key.to_s
|
8
|
+
|
9
|
+
if key_str.end_with?("=")
|
10
|
+
self[key_str.tr("=")] = args.first
|
11
|
+
else
|
12
|
+
fetch(key_str) { super }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def respond_to_missing?(key)
|
17
|
+
key_str = key.to_s
|
18
|
+
if key_str.end_with?("=")
|
19
|
+
key?(key_str.tr("=")) || super
|
20
|
+
else
|
21
|
+
key?(key_str) || super
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/isolator/version.rb
CHANGED
data/lib/isolator.rb
CHANGED
@@ -1,6 +1,93 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "isolator/version"
|
4
|
+
require "isolator/configuration"
|
5
|
+
require "isolator/adapter_builder"
|
6
|
+
require "isolator/notifier"
|
7
|
+
require "isolator/errors"
|
8
|
+
require "isolator/simple_hashie"
|
4
9
|
|
5
|
-
|
10
|
+
require "isolator/callbacks"
|
11
|
+
require "isolator/isolate"
|
12
|
+
|
13
|
+
require "isolator/ext/thread_fetch"
|
14
|
+
|
15
|
+
# Isolator detects unsafe operations performed within DB transactions.
|
16
|
+
module Isolator
|
17
|
+
using Isolator::ThreadFetch
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def config
|
21
|
+
@config ||= Configuration.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def configure
|
25
|
+
yield config
|
26
|
+
end
|
27
|
+
|
28
|
+
def notify(exception:, backtrace:)
|
29
|
+
Notifier.new(exception, backtrace).call
|
30
|
+
end
|
31
|
+
|
32
|
+
def enable!
|
33
|
+
Thread.current[:isolator_disabled] = false
|
34
|
+
end
|
35
|
+
|
36
|
+
def disable!
|
37
|
+
Thread.current[:isolator_disabled] = true
|
38
|
+
end
|
39
|
+
|
40
|
+
def transactions_threshold
|
41
|
+
Thread.current.fetch(:isolator_threshold)
|
42
|
+
end
|
43
|
+
|
44
|
+
def transactions_threshold=(val)
|
45
|
+
Thread.current[:isolator_threshold] = val
|
46
|
+
end
|
47
|
+
|
48
|
+
def incr_transactions!
|
49
|
+
return unless enabled?
|
50
|
+
Thread.current[:isolator_transactions] =
|
51
|
+
Thread.current.fetch(:isolator_transactions, 0) + 1
|
52
|
+
start! if Thread.current.fetch(:isolator_transactions) == transactions_threshold
|
53
|
+
end
|
54
|
+
|
55
|
+
def decr_transactions!
|
56
|
+
return unless enabled?
|
57
|
+
Thread.current[:isolator_transactions] =
|
58
|
+
Thread.current.fetch(:isolator_transactions) - 1
|
59
|
+
finish! if Thread.current.fetch(:isolator_transactions) == (transactions_threshold - 1)
|
60
|
+
end
|
61
|
+
|
62
|
+
def clear_transactions!
|
63
|
+
Thread.current[:isolator_transactions] = 0
|
64
|
+
end
|
65
|
+
|
66
|
+
def within_transaction?
|
67
|
+
Thread.current.fetch(:isolator_transactions, 0) >= transactions_threshold
|
68
|
+
end
|
69
|
+
|
70
|
+
def enabled?
|
71
|
+
Thread.current[:isolator_disabled] != true
|
72
|
+
end
|
73
|
+
|
74
|
+
def adapters
|
75
|
+
@adapters ||= Isolator::SimpleHashie.new
|
76
|
+
end
|
77
|
+
|
78
|
+
include Isolator::Isolate
|
79
|
+
include Isolator::Callbacks
|
80
|
+
end
|
81
|
+
|
82
|
+
self.transactions_threshold = 1
|
83
|
+
end
|
84
|
+
|
85
|
+
require "isolator/orm_adapters"
|
86
|
+
|
87
|
+
# Load adapters after application initialization
|
88
|
+
# (when all deps are likely loaded).
|
89
|
+
if defined?(Rails)
|
90
|
+
require "isolator/railtie"
|
91
|
+
else
|
92
|
+
require "isolator/adapters"
|
6
93
|
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: isolator
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.1.0.pre
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vladimir Dementyev
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-02-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sniffer
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.3.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.3.0
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: bundler
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,6 +66,34 @@ dependencies:
|
|
52
66
|
- - "~>"
|
53
67
|
- !ruby/object:Gem::Version
|
54
68
|
version: '3.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-rails
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: minitest
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 5.10.0
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 5.10.0
|
55
97
|
- !ruby/object:Gem::Dependency
|
56
98
|
name: rubocop
|
57
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,6 +122,34 @@ dependencies:
|
|
80
122
|
- - "~>"
|
81
123
|
- !ruby/object:Gem::Version
|
82
124
|
version: '0.2'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: uniform_notifier
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '1.11'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '1.11'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: sidekiq
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '5.0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '5.0'
|
83
153
|
description: Detect non-atomic interactions within DB transactions
|
84
154
|
email:
|
85
155
|
- dementiev.vm@gmail.com
|
@@ -102,6 +172,25 @@ files:
|
|
102
172
|
- gemfiles/railsmaster.gemfile
|
103
173
|
- isolator.gemspec
|
104
174
|
- lib/isolator.rb
|
175
|
+
- lib/isolator/adapter_builder.rb
|
176
|
+
- lib/isolator/adapters.rb
|
177
|
+
- lib/isolator/adapters/background_jobs.rb
|
178
|
+
- lib/isolator/adapters/background_jobs/active_job.rb
|
179
|
+
- lib/isolator/adapters/background_jobs/sidekiq.rb
|
180
|
+
- lib/isolator/adapters/base.rb
|
181
|
+
- lib/isolator/adapters/http.rb
|
182
|
+
- lib/isolator/callbacks.rb
|
183
|
+
- lib/isolator/configuration.rb
|
184
|
+
- lib/isolator/errors.rb
|
185
|
+
- lib/isolator/ext/thread_fetch.rb
|
186
|
+
- lib/isolator/isolate.rb
|
187
|
+
- lib/isolator/notifier.rb
|
188
|
+
- lib/isolator/orm_adapters.rb
|
189
|
+
- lib/isolator/orm_adapters/active_record.rb
|
190
|
+
- lib/isolator/orm_adapters/active_support_subscriber.rb
|
191
|
+
- lib/isolator/orm_adapters/rom_active_support.rb
|
192
|
+
- lib/isolator/railtie.rb
|
193
|
+
- lib/isolator/simple_hashie.rb
|
105
194
|
- lib/isolator/version.rb
|
106
195
|
homepage: https://github.com/palkan/isolator
|
107
196
|
licenses:
|
@@ -118,12 +207,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
118
207
|
version: 2.3.0
|
119
208
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
209
|
requirements:
|
121
|
-
- - "
|
210
|
+
- - ">"
|
122
211
|
- !ruby/object:Gem::Version
|
123
|
-
version:
|
212
|
+
version: 1.3.1
|
124
213
|
requirements: []
|
125
214
|
rubyforge_project:
|
126
|
-
rubygems_version: 2.
|
215
|
+
rubygems_version: 2.7.4
|
127
216
|
signing_key:
|
128
217
|
specification_version: 4
|
129
218
|
summary: Detect non-atomic interactions within DB transactions
|