AcidicJob: durable multi-step workflows inside Rails Active Job
🧪 Durable execution workflows for Active Job
At a glance
- What is it?
- AcidicJob adds an Execution record and a small step DSL to Active Job so a multi-step background job can resume where it failed instead of starting over. It is a release candidate for v1 with no published GitHub release, so the stable line is v0.9.0.
- Who is it for?
- Adopt AcidicJob when your Rails background jobs run multi-step flows that touch both your database and external systems, and you need per-step state so a retry resumes instead of duplicating a charge or a side effect. Skip it for single-step jobs, where the added table and migration outweigh the benefit, and be careful with the v1 release candidate, since the README points you back to the v0.9.0 line for the stable release.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 119 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Active Job retries, but it does not remember where it was
The problem AcidicJob is built around is stated in the README's opening paragraph. Rails applications frequently need to coordinate complex multi-step operations across external services, databases and systems, and while Active Job provides eventual consistency guarantees, it does not address the challenges of managing stateful, long-running operations that must be resilient to failures, timeouts and partial completions. That gap is narrower than it sounds. Active Job will happily retry a job that failed halfway through three steps, and on retry it will run the whole job again from the first line, so your external side effects happen twice unless you make them idempotent yourself. AcidicJob's answer is to give the job a memory. When the job calls execute_workflow, a durable execution record is created in the AcidicJob::Execution table, and that record tracks the state of the workflow so a retried job picks up where it left off. The README is explicit about the payoff: if your job fails on step 3, the retry jumps straight back to the method defined for the third step and does not execute the first two step methods at all. That is what lets a step be idempotent on failure only, rather than needing to be safe to run twice.
The step DSL is deliberately tiny
The API surface is one method and one builder object, and the README is emphatic about how little there is. You include AcidicJob::Workflow in your job, which provides the execute_workflow method, and you pass it a unique_by keyword argument plus a block in which you define the steps.
class Job < ActiveJob::Base
include AcidicJob::Workflow
def perform(arg)
@arg = arg
execute_workflow(unique_by: @arg) do |w|
w.step :step_1, transactional: true
w.step :step_2
w.step :step_3
end
end
# ...
endThe step method is the only method available on the yielded workflow builder object, and it takes the name of a method on the job. The transactional keyword argument makes that step run inside a database transaction, which is the right choice for a step that only touches your own database and the wrong choice for one that calls a payment provider. The more elaborate example in the README shows a job that creates a ride and an audit record, then charges Stripe, then sends a receipt, with the first step marked transactional and the other two left plain. That example is a fair picture of what the gem is for, since the middle step is the one that must not happen twice.
One state mutation per step, and one foreign mutation per step
This is the part of the README that is less about API and more about discipline, and it is the single most useful thing in the documentation. To craft resilient workflows you need each step method to wrap a single unit of IO-bound work. You should not have a step method that performs multiple IO-bound operations, like writing to your database and calling an external API. Steps should be as granular and self-contained as possible, so that failures in third-party APIs and network errors leave as little as possible to redo. The rule of thumb is one state mutation per step, and the README says that this graduates to a hard and fast rule for foreign state mutations, where a foreign state mutation is any operation that writes to a system beyond your own boundaries, such as creating a charge on Stripe, adding a DNS record or sending an email, and you must only have one of those per step. The footnote credits the rule to Brandur Leach's post on implementing idempotency keys in Postgres. This constraint is the price of the resume behaviour, and it is worth internalising before you write the first workflow, because a step that quietly does two things will fail in a way that resumes incorrectly.
unique_by has to survive JSON, which rules out Active Record objects
The unique_by keyword argument defines the unique identifier for a particular execution of the workflow, and it is what lets a retry find the pre-existing execution and resume it. The constraint on it is technical and specific, and the README calls it out as important. The unique_by argument can only be something that JSON.generate(..., strict: true) can handle, which means it must be made up of only the JSON native types: Hash, Array, String, Integer, Float, true, false and nil. That rules out passing an Active Record model directly, even though the second example in the README looks like it does, because it passes an array of the user object and the params hash. The README does not resolve that tension, so the practical reading is that you pass identifiers and plain data rather than model instances, and you should check how your own objects serialise before relying on that pattern. The second consequence of the keyword is the guarantee itself, that retries of the job correctly resume the pre-existing workflow execution, which is what makes the whole design idempotent at the workflow level rather than at the step level.
Installing the release candidate and generating the migration
The gem is installed through Bundler with a pinned prerelease version.
bundle add acidic_job --version "1.0.0.rc3"Without Bundler, the install uses the gem command with the prerelease flag.
gem install acidic_job --preThen you run the generator, which creates a migration file under db/migrate to set up the tables the gem requires.
rails generate acidic_job:installThat migration step is the operational commitment. AcidicJob keeps workflow state in your application's database, so the gem adds a table and whatever else the migrations create, and that database has to be one you can write to from a background worker. The README also mentions that by default each step is executed and upon completion the Execution record is updated to reflect the completion of that step, and that this default makes sense for foreign state mutations, with the alternative behaviour for local ones described further along in the document. For local development the repository ships a docker-compose file with MySQL 8.0.31 and Postgres 15.1, each bound to localhost on a nonstandard port, with the MySQL instance configured to allow an empty password and the Postgres instance set to trust authentication, so a local database can be started without credential setup.
A v1 release candidate with no published release
The state of the project needs stating plainly because it affects how you adopt it. The README carries a warning at the top saying it documents the new release candidate of v1, which is a major refactor from the previous release candidate of v1, and that if you are looking for the stable release you should refer to the v0.9.0 README. The install instructions still pin the prerelease version 1.0.0.rc3. The repository has no GitHub releases at all, so there is no tagged artefact to compare against, and the last push was on 2026-06-27, with the repository not archived. The quality signals around the code are good, though. The README shows a test badge from a GitHub Actions workflow and a code coverage badge reading 98%, alongside a Codacy grade badge, and the repository layout supports that, with a Rakefile, a rubocop configuration, a .ruby-version file, gemfiles/ for testing against multiple dependency sets, a test/ directory and a .simplecov file. There is also an UPGRADE_GUIDE.md and a TODO file at the top level. The practical reading is that this is an actively worked on library whose current head is a major-version rewrite, so pinning the prerelease is a deliberate choice with a documented migration path rather than an accident.
Hand rolled job state versus a library that owns it
The alternative is what most teams do before reaching for a gem, which is to write the state machine into your own job. You add a jobs table with a status column, you record which step completed, and on retry you look up the row and skip what is done. That is not a foolish approach and for a single job class it can be less code than adopting a framework. The differences accumulate. Your version needs you to define the resumable unit, to decide what happens when two workers pick up the same job, to handle the case where the process dies between the side effect and the status write, and to keep all of that consistent across every job class in the application. AcidicJob moves those decisions into one place, gives them a name, and enforces the granularity rule through the DSL rather than through a code review comment. The trade is dependency and conceptual cost. You add a gem, a migration, a table and a set of conventions, and your steps have to be written in a particular granular style rather than as convenient large methods. For an application with many multi-step jobs and several engineers, that convention is usually cheaper than each team inventing its own resume logic. For one job that occasionally calls a flaky API, it is not.
Editorial conclusion
Adopt AcidicJob when your Rails background jobs run multi-step flows that touch both your database and external systems, and you need per-step state so a retry resumes instead of duplicating a charge or a side effect. Skip it for single-step jobs, where the added table and migration outweigh the benefit, and be careful with the v1 release candidate, since the README points you back to the v0.9.0 line for the stable release. Verify first that your database supports the migrations the installer generates, that each step performs exactly one state mutation, and that your unique_by key serialises through JSON.generate with strict mode.
Frequently asked questions
How do I install AcidicJob and set up its database tables?
Add the gem with bundle add acidic_job --version "1.0.0.rc3", or gem install acidic_job --pre without Bundler, then run rails generate acidic_job:install. The installer creates a migration file under db/migrate that sets up the tables the gem requires.
How does AcidicJob make a retried job avoid repeating completed steps?
Calling execute_workflow creates an AcidicJob::Execution record that tracks the state of the workflow. On retry the job resumes at the failed step, so earlier step methods are not executed again, which means step methods only need to be idempotent on failure rather than on success.
What can I pass as the unique_by argument in AcidicJob?
Only values that JSON.generate(..., strict: true) can handle, meaning Hash, Array, String, Integer, Float, true, false and nil. It is used to identify a particular workflow execution so a retry resumes the existing one instead of starting a new one.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/fractaledmind-acidic-job)Was this useful?