/sidekiq-transaction_guard

Guard against invoking Sidekiq workers during database transactions from ActiveRecord.

Primary LanguageRubyMIT LicenseMIT

Sidekiq::TransactionGuard

Build Status Maintainability

You should never call a Sidekiq worker that relies on the state of the database from within a database transaction. You will end up with a race condition since the worker could kick off before the transaction is actually written to the database. This gem can be used to highlight where your code may be scheduling workers in an indeterminate state.

The Problem

Consider this case:

class Post < ActiveRecord::Base
  # BAD: DO NOT DO THIS
  after_create do
    PostCreatedWorker.perform_async(id)
  end
end

class PostCreatedWorker
  include Sidekiq::Worker

  def perform(post_id)
    post = Post.find_by(id: post_id)
    if post
      do_something_with(post)
    end
  end
end

In this case, the PostCreatedWorker job will be created for a new Post record in Sidekiq before the data is actually written to the database. If Sidekiq picks up that worker and tries to execute it before the transaction is committed, Post.find_by(id: post_id) won't find anything and the worker will exit without performing it's task. Even if the worker doesn't need to read from the database, there is still a chance for an error to rollback the transaction leaving a possibility of workers running that should not have been scheduled.

To solve this, workers like this should be invoked in ActiveRecord from an after_commit callback. These callbacks are guaranteed to only execute after the data has been written to the database. However, as your application grows and gets more complicated, it can be difficult to ensure that workers are not being scheduled in the middle of transactions.

Switching from callbacks to service objects won't help you either, because service objects can be wrapped in transactions as well. The will just give you a new problem to solve.

class CreatePost
  def initialize(attributes)
    @attributes = attributes
  end

  def call
    post = Post.create!(attributes)
    PostCreatedWorker.perform_async(post.id)
  end
end

# Still calling `perform_async` inside a transaction.
Post.transaction do
  CreatePost.new(post_1_attributes)
  CreatePost.new(post_2_attributes)
end

The Solution

You can use this gem to add Sidekiq client middleware that will either warn you or raise an error when workers are scheduled inside of a database transaction. You can do this by simply adding this to your application's initialization code:

require 'sidekiq/transaction_guard'

Sidekiq.configure_client do |config|
  config.client_middleware do |chain|
    chain.add(Sidekiq::TransactionGuard::Middleware)
  end
end

Mode

By default, the behavior is to log that a worker is being scheduled inside of a transaction to the Sidekiq.logger. If you are running a test suite, you may want to expose the problematic calls by either raising errors or logging the calls to standard error. The mode can be one of [:warn, :stderr, :error, :disabled].

# Raise errors
Sidekiq::TransactionGuard.mode = :error

# Log to STDERR
Sidekiq::TransactionGuard.mode = :stderr

# Log to Sidekiq.logger
Sidekiq::TransactionGuard.mode = :warn

# Disable entirely
Sidekiq::TransactionGuard.mode = :disabled

You can also set the mode on individual worker classes with sidekiq_options transaction_guard: mode.

class SomeWorker
  include Sidekiq::Worker

  sidekiq_options transaction_guard: :error
end

You can use the :disabled mode to allow individual worker classes to be scheduled inside of transactions where the worker logic doesn't care about the state of the database. For instance, if you use a Sidekiq worker to report errors, you would want to all it inside of transactions. If you don't control the worker you want to change the mode on, you simply call this in an initializer:

SomeWorker.sidekiq_options.merge(transaction_guard: :disabled)

You could

Notification Handlers

You can also set a block to be called if a worker is scheduled inside of a transaction. This can be useful if you use an error logging service to notify you of problematic calls in production so you can fix them.

# Define a global notify handler
Sidekiq::TransactionGuard.notify do |job|
  # Do what ever you need to. The job argument will be a Sidekiq job hash.
end

# Define on a per worker level
class SomeWorker
  include Sidekiq::Worker

  sidekiq_options notify_in_transaction: -> (job) { # Do something }
end

# Disable the global notification handler on a worker
class SomeOtherWorker
  include Sidekiq::Worker

  sidekiq_options notify_in_transaction: false
end

Multiple Databases

Out of the box, this gem only deals with one database and monitors the connection pool returned by ActiveRecord::Base.connection. If you have multiple databases (or even multiple connections to the same database) that you want to track, you need to tell Sidekiq::TransactionGuard about them.

class MyClass < ActiveRecord::Base
  # This estabilishes a new connection pool.
  establish_connection(configurations["otherdb"])
end

Sidekiq::TransactionGuard.add_connection_class(MyClass)

The class is used to get to the connection pool used for the class. You only need to add one class per connection pool, so you don't need to add any subclasses of MyClass.

Transaction Fixtures In Tests

If you're using transaction fixtures in your tests, there will always be a database transaction open. If you're using DatabaseCleaner in your tests, you just need to include this snippet in your test suite initializer:

require 'sidekiq/transaction_guard/database_cleaner'

This will add the appropriate code so that the surrounding transaction in the test suite is ignored (i.e. workers will only warn/error if there is more than one open transaction).

If you're using something else for your transactional fixtures or have some other weird setup, look in the lib/sidekiq_transaction_guard/database_cleaner.rb file for an example of what you need to do.