idempotency_check
Detects whether a job — or any block of Ruby code — is idempotent, by running it multiple times and comparing the side effects it produces.
It works by running your block 3 times and comparing, between runs:
- database row counts and content (via ActiveRecord)
- ActionMailer deliveries
- ActiveJob activity (
perform_laterandperform_now), broken down by job class and arguments - optionally, Sidekiq activity (
perform_async), if you opt in - outbound HTTP calls, which can't be verified, so they're flagged instead of assumed safe
It has no runtime dependencies.
It works on any block of code, not only jobs — IdempotencyCheck.idempotent? { user.update!(...) } works the same way as it does for a job's perform.
Installation
gem "idempotency_check"
Usage
require "idempotency_check"
IdempotencyCheck.idempotent? { MyJob.perform_now(user_id) }
# => true, false
Returns true only when running the block again produces no additional effect.
Any unconfirmed situation (see "HTTP calls" below) is treated as false by default — the safe assumption is "not confirmed idempotent", never a false positive.
Ignoring timestamp-only changes
A job that calls .touch/.save on every run bumps updated_at even if nothing else changed.
By default that counts as non-idempotent (strict); opt out with:
IdempotencyCheck.idempotent?(ignore_timestamps: true) { MyJob.perform_now }
# or, to ignore other specific columns:
IdempotencyCheck.idempotent?(ignore_columns: ["last_checked_at"]) { MyJob.perform_now }
HTTP calls
Outbound HTTP calls can't be verified — the gem doesn't know whether the external service is itself idempotent.
By default, any HTTP call during the run makes the result false (unconfirmed = not safe to assume).
If you've independently verified a particular source is safe (e.g. it calls your own idempotent internal API), exclude it from the check:
IdempotencyCheck.idempotent?(trust: [:http]) { MyJob.perform_now }
trust: accepts any of the sources the checker tracks: :table, :row, :mailer, :active_job, :http, and (if loaded) :sidekiq.
Getting a reason, not just true/false
IdempotencyCheck.idempotent?(verbose: true) { MyJob.perform_now }
# => { result: true, reason: :ok }
# => { result: false, reason: [:row] } # which source(s) changed
# => { result: false, reason: :suspicious_http } # DB looked fine, but there was an unverified HTTP call
Optional integrations
Neither of these are gemspec dependencies — they're only loaded (and only need their gem installed) if you explicitly require them.
RSpec
require "idempotency_check/rspec"
expect { MyJob.perform_now(user_id) }.to be_idempotent
expect { MyJob.perform_now(user_id) }.to be_idempotent(trust: [:http])
Sidekiq (native include Sidekiq::Job / perform_async)
ActiveJob is supported out of the box, even when Sidekiq is its queue adapter.
This is only needed for jobs that use Sidekiq's own API directly (include Sidekiq::Job, perform_async), which doesn't go through ActiveJob at all.
require "idempotency_check/sidekiq"
IdempotencyCheck.idempotent? { MyWorker.perform_async(user_id) }
The check does not undo anything
Running a check executes your block 3 times, for real. Nothing is rolled back. A block that creates a row leaves 3 rows behind; one that sends a mail sends 3 mails.
Run checks inside a transactional test, which is what they're designed for:
# RSpec, with use_transactional_fixtures = true (the default)
it "is safe to retry" do
expect { MyJob.perform_now(user.id) }.to be_idempotent
end
The surrounding transaction wraps all 3 runs and rolls the database back when the example ends. Mailer deliveries and enqueued jobs aren't covered by the transaction, but the checker resets those itself at the start of every check, so they don't leak between examples.
Not undone by a transaction, in a test or anywhere else:
- HTTP requests — they really go out, 3 times.
This is also why an unverified HTTP call makes the result
falseby default. - Sidekiq's native
perform_async— pushes to Redis for real. - Redis and
Rails.cache— no rollback. after_commit— inside a test transaction the real commit never happens, so a job that depends on it may not behave the way it does in production.
Running a check against a development console is a bad idea for the same reason: it will triple whatever the block does.
What it doesn't cover (yet)
- Automatic retries (
retry_on/discard_on) — the checker runs the block cleanly 3 times, it doesn't simulate a mid-run failure and retry. - Cache/Redis side effects (e.g.
Rails.cache.increment). - External storage (S3, ActiveStorage).
- Real concurrency — every run is sequential, not parallel.
- Writes that don't go through the Rails adapter: database triggers and
ON DELETE CASCADE. Those tables never show up insql.active_record, so they're not snapshotted. - HTTP clients that bypass
Net::HTTP(httpx,excon,typhoeus,curb). Anything built onNet::HTTP— Faraday's default adapter, HTTParty, RestClient — is seen.
Development
bundle install
bundle exec rake test