Detect non-atomic interactions within DB transactions.
Examples:
# HTTP calls within transaction
User.transaction do
user = User.new(user_params)
user.save!
# HTTP API call
PaymentsService.charge!(user)
end
#=> raises Isolator::HTTPError
# background job
User.transaction do
user.update!(confirmed_at: Time.now)
UserMailer.successful_confirmation(user).deliver_later
end
#=> raises Isolator::BackgroundJobError
Of course, Isolator can detect implicit transactions too. Consider this pretty common bad practice–enqueueing background job from after_create
callback:
class Comment < ApplicationRecord
# the good way is to use after_create_commit
# (or not use callbacks at all)
after_create :notify_author
private
def notify_author
CommentMailer.comment_created(self).deliver_later
end
end
Comment.create(text: "Mars is watching you!")
#=> raises Isolator::BackgroundJobError
Isolator is supposed to be used in tests and on staging.
Add this line to your application's Gemfile:
# We suppose that Isolator is used in development and test
# environments.
group :development, :test do
gem "isolator"
end
# Or you can add it to Gemfile with `require: false`
# and require it manually in your code.
#
# This approach is useful when you want to use it in staging env too.
gem "isolator", require: false
Isolator is a plug-n-play tool, so, it begins to work right after required.
However, there are some potential caveats:
-
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). -
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!
). -
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).
Isolator.configure do |config|
# Specify a custom logger to log offenses
config.logger = nil
# Raise exception on offense
config.raise_exceptions = false # true in test env
# Send notifications to uniform_notifier
config.send_notifications = false
end
Isolator relys on uniform_notifier to send custom notifications.
NOTE: uniform_notifier
should be installed separately (i.e., added to Gemfile).
- Rails' baked-in use_transactional_tests
- database_cleaner gem. Make sure that you require isolator after database_cleaner.
ActiveRecord
>= 4.1ROM::SQL
(only if Active Support instrumentation extenstion is loaded)
Isolator has a bunch of built-in adapters:
:http
– built on top of Sniffer:active_job
:sidekiq
:resque
:resque_scheduler
:sucker_punch
:mailer
:webmock
– track mocked HTTP requests (unseen by Sniffer) in tests
You can dynamically enable/disable adapters, e.g.:
# Disable HTTP adapter == do not spy on HTTP requests
Isolator.adapters.http.disable!
# Enable back
Isolator.adapters.http.enable!
For the actions that should be executed only after successful transaction commit (which is mostly always so), you can try to use the after_commit
callback from after_commit_everywhere gem (or use native AR callback in models if it's applicable).
Since Isolator adapter is just a wrapper over original code, it may lead to false positives when there is another library patching the same behaviour. In that case you might want to ignore some offenses.
Consider an example: we use Sidekiq along with sidekiq-postpone
–gem that patches Sidekiq::Client#raw_push
and allows you to postpone jobs enqueueing (e.g. to enqueue everything when a transaction is commited–we don't want to raise exceptions in such situation).
To ignore offenses when sidekiq-postpone
is active, you can add an ignore proc
:
Isolator.adapters.sidekiq.ignore_if { Thread.current[:sidekiq_postpone] }
You can add as many ignores as you want, the offense is registered iff all of them return false.
If you already have a huge Rails project it can be a tricky to turn Isolator on because you'll immediately get a lot of failed specs. If you want to fix detected issues one by one, you can list all of them in the special file .isolator_todo.yml
in a following way:
sidekiq:
- app/models/user.rb:20
- app/models/sales/**/*.rb
All the exceptions raised in the listed lines will be ignored.
If you are not using Rails, you'll have to load ignores from file manually, using Isolator#load_ignore_config
, for instance Isolator.load_ignore_config("./config/.isolator_todo.yml")
An adapter is just a combination of a method wrapper and lifecycle hooks.
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):
# The first argument is a unique adapter id,
# you can use it later to enable/disable the adapter
#
# The second argument is the method owner and
# the third one is a method name.
Isolator.isolate :danger, Danger, :explode, options
# NOTE: if you want to isolate a class method, use signleton_class instead
Isolator.isolate :danger, Danger.singleton_class, :explode, options
Possible options
are:
exception_class
– an exception class to raise in case of offenseexception_message
– custom exception message (could be specified without a class)
You can also add some callbacks to be run before and after the transaction:
Isolator.before_isolate do
# right after we enter the transaction
end
Isolator.after_isolate do
# right after the transaction has been committed/rollbacked
end
Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/isolator.
The gem is available as open source under the terms of the MIT License.