{"id":13411691,"url":"https://github.com/palkan/isolator","last_synced_at":"2025-05-13T20:13:47.789Z","repository":{"id":37549886,"uuid":"119246604","full_name":"palkan/isolator","owner":"palkan","description":"Detect non-atomic interactions within DB transactions","archived":false,"fork":false,"pushed_at":"2025-04-16T17:45:21.000Z","size":261,"stargazers_count":935,"open_issues_count":6,"forks_count":27,"subscribers_count":10,"default_branch":"master","last_synced_at":"2025-04-28T11:52:38.710Z","etag":null,"topics":["activerecord","developer-tools","hacktoberfest","rails","testing-tools"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/palkan.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":"palkan"}},"created_at":"2018-01-28T09:57:47.000Z","updated_at":"2025-04-25T17:44:09.000Z","dependencies_parsed_at":"2023-09-28T05:09:26.232Z","dependency_job_id":"1a218a32-1b0f-4e72-a09c-99d2af540efb","html_url":"https://github.com/palkan/isolator","commit_stats":{"total_commits":189,"total_committers":28,"mean_commits":6.75,"dds":"0.43386243386243384","last_synced_commit":"70ba32e51e86535ea1699f4d78e241c898da984e"},"previous_names":[],"tags_count":20,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Fisolator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Fisolator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Fisolator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/palkan%2Fisolator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/palkan","download_url":"https://codeload.github.com/palkan/isolator/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254020630,"owners_count":22000755,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","developer-tools","hacktoberfest","rails","testing-tools"],"created_at":"2024-07-30T20:01:15.825Z","updated_at":"2025-05-13T20:13:47.771Z","avatar_url":"https://github.com/palkan.png","language":"Ruby","readme":"[![Cult Of Martians](http://cultofmartians.com/assets/badges/badge.svg)](http://cultofmartians.com/tasks/isolator.html)\n[![Gem Version](https://badge.fury.io/rb/isolator.svg)](https://badge.fury.io/rb/isolator)\n![Build](https://github.com/palkan/isolator/workflows/Build/badge.svg)\n\n# Isolator\n\nDetect non-atomic interactions within DB transactions.\n\nExamples:\n\n```ruby\n# HTTP calls within transaction\nUser.transaction do\n  user = User.new(user_params)\n  user.save!\n  # HTTP API call\n  PaymentsService.charge!(user)\nend\n\n#=\u003e raises Isolator::HTTPError\n\n# background job\nUser.transaction do\n  user.update!(confirmed_at: Time.now)\n  UserMailer.successful_confirmation(user).deliver_later\nend\n\n#=\u003e raises Isolator::BackgroundJobError\n```\n\nOf course, Isolator can detect _implicit_ transactions too. Consider this pretty common bad practice–enqueueing background job from `after_create` callback:\n\n```ruby\nclass Comment \u003c ApplicationRecord\n  # the good way is to use after_create_commit\n  # (or not use callbacks at all)\n  after_create :notify_author\n\n  private\n\n  def notify_author\n    CommentMailer.comment_created(self).deliver_later\n  end\nend\n\nComment.create(text: \"Mars is watching you!\")\n#=\u003e raises Isolator::BackgroundJobError\n```\n\nIsolator is supposed to be used in tests and on staging.\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\n# We suppose that Isolator is used in development and test\n# environments.\ngroup :development, :test do\n  gem \"isolator\"\nend\n\n# Or you can add it to Gemfile with `require: false`\n# and require it manually in your code.\n#\n# This approach is useful when you want to use it in staging env too.\ngem \"isolator\", require: false\n```\n\n## Usage\n\nIsolator is a plug-n-play tool, so, it begins to work right after required.\n\nHowever, there are some potential caveats:\n\n1) 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).\n\n2) 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. You can do this by adding an initializer:\n\n    ```rb\n    require \"active_job/base\"\n    Isolator.adapters.active_job.disable!\n    ```\n\n4) 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)).\n\n5) Experimental [multiple databases](https://guides.rubyonrails.org/active_record_multiple_databases.html) has been added in v0.7.0. Please, let us know if you encounter any issues.\n\n### Configuration\n\n```ruby\nIsolator.configure do |config|\n  # Specify a custom logger to log offenses\n  config.logger = nil\n\n  # Raise exception on offense\n  config.raise_exceptions = false # true in test env\n\n  # Send notifications to uniform_notifier\n  config.send_notifications = false\n\n  # Customize backtrace filtering (provide a callable)\n  # By default, just takes the top-5 lines\n  config.backtrace_filter = -\u003e(backtrace) { backtrace.take(5) }\n\n  # Define a custom ignorer class (must implement .prepare)\n  # uses a row number based list from the .isolator_todo.yml file\n  config.ignorer = Isolator::Ignorer\n\n  # Turn on/off raising exceptions for simultaneous transactions to different databases\n  config.disallow_per_thread_concurrent_transactions = false\nend\n```\n\nIsolator relies on [uniform_notifier][] to send custom notifications.\n\n**NOTE:** `uniform_notifier` should be installed separately (i.e., added to Gemfile).\n\n### Callbacks\n\nIsolator different callbacks so you can inject your own logic or build custom extensions.\n\n```ruby\n# This callback is called when Isolator enters the \"danger zone\"—a within-transaction context\nIsolator.before_isolate do\n  puts \"Entering a database transaction. Be careful!\"\nend\n\n# This callback is called when Isolator leaves the \"danger zone\"\nIsolator.after_isolate do\n  puts \"Leaving a database transaction. Everything is fine. Feel free to call slow HTTP APIs\"\nend\n\n# This callback is called every time a new transaction is open (root or nested)\nIsolator.on_transaction_open do |event|\n  puts \"New transaction from #{event[:connection_id]}. \" \\\n       \"Current depth: #{event[:depth]}\"\nend\n\n# This callback is called every time a transaction is completed\nIsolator.on_transaction_close do |event|\n  puts \"Transaction completed from #{event[:connection_id]}. \" \\\n       \"Current depth: #{event[:depth]}\"\nend\n```\n\n### Transactional tests support\n\n - Rails' baked-in [use_transactional_tests](https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html#class-ActiveRecord::FixtureSet-label-Transactional+Tests)\n - [database_cleaner](https://github.com/DatabaseCleaner/database_cleaner) gem. Make sure that you require isolator _after_ database_cleaner.\n\n### Supported ORMs\n\n- `ActiveRecord` \u003e= 6.0 (see older versions of Isolator for previous versions)\n- `ROM::SQL` (only if Active Support instrumentation extension is loaded)\n\n### Adapters\n\nIsolator has a bunch of built-in adapters:\n- `:http` – built on top of [Sniffer][]\n- `:active_job`\n- `:sidekiq`\n- `:resque`\n- `:resque_scheduler`\n- `:sucker_punch`\n- `:mailer`\n- `:webmock` – track mocked HTTP requests (unseen by Sniffer) in tests\n- `:action_cable`\n\nYou can dynamically enable/disable adapters, e.g.:\n\n```ruby\n# Disable HTTP adapter == do not spy on HTTP requests\nIsolator.adapters.http.disable!\n\n# Enable back\n\nIsolator.adapters.http.enable!\n```\n\nFor `active_job`, be sure to first `require \"active_job/base\"`.\n\n### Fix Offenses\n\nFor 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).\n\n### Ignore Offenses\n\nSince 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.\n\nConsider an example: we use Sidekiq along with [`sidekiq-postpone`](https://github.com/marshall-lee/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).\n\nTo ignore offenses when `sidekiq-postpone` is active, you can add an ignore `proc`:\n\n```ruby\nIsolator.adapters.sidekiq.ignore_if { Thread.current[:sidekiq_postpone] }\n```\n\nYou can add as many _ignores_ as you want, the offense is registered iff all of them return false.\n\n### Using with sidekiq/testing\n\nIf 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.\n\nIf you're using Rails and want to use isolator in development and staging, then here is a way to do this.\n\n```ruby\n\n# Gemfile\ngem \"isolator\", require: false # so it delays loading till after sidekiq/testing\n\n# config/initializers/isolator.rb\nrequire \"sidekiq/testing\" if Rails.env.test?\n\nunless Rails.env.production? # so we get it in staging too\n  require \"isolator\"\n  Isolator.configure do |config|\n    config.send_notifications = true # ...\n  end\nend\n```\n\n### Using with legacy Rails codebases\n\nIf you already have a huge Rails project it can be 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 files `.isolator_todo.yml` and `.isolator_ignore.yml` in the following way:\n\n```\nsidekiq:\n  - app/models/user.rb:20\n  - app/models/sales/**/*.rb\n```\n\nYou can ignore the same files in multiple adapters using YML aliases in the following way:\n\n```\nhttp_common: \u0026http_common\n  - app/models/user.rb:20\n\nhttp: *http_common\nwebmock: *http_common\n```\n\nAll the exceptions raised in the listed lines will be ignored.\n\nThe `.isolator_todo.yml` file is intended to point to the code that should be fixed later, and `.isolator_ignore.yml` points to the code that for some reasons is not expected to be fixed. (See https://github.com/palkan/isolator/issues/40)\n\n### Using with legacy Ruby codebases\n\nIf you are not using Rails, you'll have to load ignores from file manually, using `Isolator::Ignorer.prepare(path:)`, for instance `Isolator::Ignorer.prepare(path: \"./config/.isolator_todo.yml\")`\n\n## Custom Adapters\n\nAn adapter is just a combination of a _method wrapper_ and lifecycle hooks.\n\nSuppose 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):\n\n```ruby\n# The first argument is a unique adapter id,\n# you can use it later to enable/disable the adapter\n#\n# The second argument is the method owner and\n# the third one is a method name.\nIsolator.isolate :danger, Danger, :explode, options\n\n# NOTE: if you want to isolate a class method, use singleton_class instead\nIsolator.isolate :danger, Danger.singleton_class, :explode, options\n```\n\nPossible `options` are:\n- `exception_class` – an exception class to raise in case of offense\n- `exception_message` – custom exception message (could be specified without a class)\n- `details_message` – a block to generate additional exception message information:\n\n```ruby\nIsolator.isolate :active_job,\n  target: ActiveJob::Base,\n  method_name: :enqueue,\n  exception_class: Isolator::BackgroundJobError,\n  details_message: -\u003e(obj) {\n    \"#{obj.class.name}(#{obj.arguments})\"\n  }\n\nIsolator.isolate :promoter,\n  target: UserPromoter,\n  method_name: :call,\n  details_message: -\u003e(obj_, args, kwargs) {\n    # UserPromoter.call(user, role, by: nil)\n    user, role = args\n    by = kwargs[:by]\n    \"#{user.name} promoted to #{role} by #{by\u0026.name || \"system\"})\"\n  }\n```\n\nTrying to register the same adapter name twice will raise an error. You can guard for it, or remove old adapters before in order to replace them.\n\n```ruby\nunless Isolator.has_adapter?(:promoter)\n  Isolator.isolate(:promoter, *rest)\nend\n```\n\n```ruby\n# Handle code reloading\nclass Messager\nend\n\nIsolator.remove_adapter(:messager)\nIsolator.isolate(:messager, target: Messager, **rest)\n```\n\nYou can also add some callbacks to be run before and after the transaction:\n\n```ruby\nIsolator.before_isolate do\n # right after we enter the transaction\nend\n\nIsolator.after_isolate do\n # right after the transaction has been committed/rolled back\nend\n```\n\n## Troubleshooting\n\n### Verbose output\n\nIn most cases, turning on verbose output for Isolator helps to identify the issue. To do that, you can either specify `ISOLATOR_DEBUG=true` environment variable or set `Isolator.debug_enabled` manually.\n\n### Tests failing after upgrading to Rails 6.0.3 while using [Combustion](https://github.com/pat/combustion)\n\nThe reason is that Rails started using a [separate connection pool for advisory locks](https://github.com/rails/rails/pull/38235) since 6.0.3. Since Combustion usually applies migrations for every test run, this pool becomse visible to [test fixtures](https://github.com/rails/rails/blob/b738f1930f3c82f51741ef7241c1fee691d7deb2/activerecord/lib/active_record/test_fixtures.rb#L123-L127), which resulted in 2 transactional commits tracked by Isolator, which only expects one. That leads to false negatives.\n\nTo fix this disable migrations advisory locks by adding `advisory_locks: false` to your database configuration in `(spec|test)/internal/config/database.yml`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/palkan/isolator.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).\n\n[Sniffer]: https://github.com/aderyabin/sniffer\n[uniform_notifier]: https://github.com/flyerhzm/uniform_notifier\n[after_commit_everywhere]: https://github.com/Envek/after_commit_everywhere\n","funding_links":["https://github.com/sponsors/palkan"],"categories":["Ruby","By Language","Gems"],"sub_categories":["Ruby","Performance Optimization"],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpalkan%2Fisolator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpalkan%2Fisolator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpalkan%2Fisolator/lists"}