Nothing is done twice
Every finished step is written down. After a crash, another worker continues where the first one stopped.
Durable sagas stored in your own database. Finished steps are never repeated, a step that fails for good undoes the rest in reverse, and a crash halfway loses nothing.
The problem
A checkout reserves stock, charges a card, ships a parcel and sends an email. ActiveRecord::Base.transaction can roll back your tables, but not the charge. If the server dies after it, or the carrier refuses the parcel, you end up with money taken and no order.
Every finished step is written down. After a crash, another worker continues where the first one stopped.
If a step fails for good, the steps that finished are undone, last one first.
Mark the point of no return. After it, steps are retried instead.
Active Record and Active Job. No Redis, no workflow server.
Try it
Pick what goes wrong and press Play. Watch the steps, the notebook that lives in your database, and how many times the outside world was touched.
ActiveDurable::SweepJob
Scheduled by Solid Queue (config/recurring.yml), sidekiq-cron, GoodJob cron or cron.
What a worker remembers is lost if the server dies.
The code
Each step is a block. Its undo sits on the same line, and the Stripe calls live in a plain module, doing and undoing side by side.
Building blocks
flow.stepTalks to the outside world, with a ticket to use as idempotency key.
flow.transactionTouches only your database and commits with its checkpoint: exactly once.
flow.pivotThe point of no return. Before it, failures are undone; after it, retried.
flow.parallelSeveral steps at once, each with its own row, ticket and retries.
flow.sleepWaits hours or days without holding a worker.
flow.wait_forWaits for Durable.signal from a webhook, with a timeout.
flow.onUpdates your own records when the saga ends: completed or compensated.
flow.abort!Rejects the work for a business reason: no retries, straight to undoing.
ActiveDurable.retryA bug blocks the saga instead of undoing it: fix the code, retry, and it carries on.
How it compares
| Keeps progress in | Undoes steps | Needs | |
|---|---|---|---|
| Active Job Continuations (Rails 8.1) | the job (a cursor) | no | nothing extra |
| ChronoForge | your database | not in its docs | nothing extra |
| ruby_reactor | Redis | yes | Redis and Sidekiq |
| Temporal | the Temporal server | written by hand | a Temporal cluster |
| ActiveDurable | your database | yes, in reverse, with a point of no return | nothing extra |
Does it hold?
2,000 checkouts of 5 steps on 8 worker processes. Then the same while a worker is killed with SIGKILL every second: every saga settles, and none is charged twice.
| PostgreSQL 16 | MySQL 9.6 | SQLite 3 | |
|---|---|---|---|
| A step (one durable commit) | 1.8 ms | 3.0 ms | 0.45 ms |
| Resuming a saga with 1,000 finished steps | 17 ms | 21 ms | 9 ms |
| 2,000 sagas, 8 workers | 6.9 s | 8.1 s | 1,000 sagas, 4 workers: 6.5 s |
| The same, killing a worker every second | 9.2 s, 9 workers killed | 13.2 s, 13 workers killed | — |
| Duplicate charges | 0 | 0 | 0 |
Apple M1 Ultra, Ruby 4.0.7, Rails 8.1, each database on the same machine. Each call to the outside world takes 5 ms, like a real API.
git clone https://github.com/webresstudio/active_durable && cd active_durable && bundle installDB=postgresql CHAOS=1 bundle exec ruby benchmarks/load.rbIt needs a PostgreSQL server on your machine; DB=mysql and DB=sqlite3 run the others. The script fails unless every saga completes with exactly one charge.
How the benchmarks work →Install
The step-by-step guide sets up the rest of a Rails app: job backend, sweeper, initializer, dashboard and tests.
Ruby 3.1+ · Rails 6.1 to 8.1 · PostgreSQL, MySQL or SQLite
bundle add active_durablebin/rails generate active_durable:installbin/rails db:migrateUpdating from an older version? Run bin/rails generate active_durable:upgrade and bin/rails db:migrate.