isolator 0.0.1 → 0.1.0.pre
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 +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
|