ActiveDurable
◆Ruby gem for Rails Latest version on RubyGems

Finish the work, or undo it in order.

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.

bundle add active_durable
Try the simulator View on GitHub
Ruby 3.1+Rails 6.1+PostgreSQL · MySQL · SQLiteNo Redis, no extra servers
Animation: a checkout runs step by step, the server dies, and a new worker continues from the notebook

The problem

A ROLLBACK cannot reach Stripe.

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.

Nothing is done twice

Every finished step is written down. After a crash, another worker continues where the first one stopped.

Nothing is left half done

If a step fails for good, the steps that finished are undone, last one first.

Some things cannot be undone

Mark the point of no return. After it, steps are retried instead.

Nothing extra to run

Active Record and Active Job. No Redis, no workflow server.

Try it

A checkout in four steps

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.

Can be undone Point of no return Retried

Sweeperevery minute ActiveDurable::SweepJob Scheduled by Solid Queue (config/recurring.yml), sidekiq-cron, GoodJob cron or cron.

Worker #1

What a worker remembers is lost if the server dies.

Notebook a table in your database

Outside world

Stripe charges
0
Refunds
0
Stock reserved
0
Parcels shipped
0
Emails sent
0
Order status
placed

The code

Write the recipe once

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.

app/sagas/checkout_saga.rb

Building blocks

Everything a saga needs

flow.step

Talks to the outside world, with a ticket to use as idempotency key.

flow.transaction

Touches only your database and commits with its checkpoint: exactly once.

flow.pivot

The point of no return. Before it, failures are undone; after it, retried.

flow.parallel

Several steps at once, each with its own row, ticket and retries.

flow.sleep

Waits hours or days without holding a worker.

flow.wait_for

Waits for Durable.signal from a webhook, with a timeout.

flow.on

Updates 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.retry

A bug blocks the saga instead of undoing it: fix the code, retry, and it carries on.

All the details in the README →

Dashboard

Every saga, as a row of blocks

A running step beats, a waiting one pings like a radar, a sleeping one fills a ring until it wakes, and undone ones are wired backwards. Retry, undo everything or run a saga again from any step.

One saga, step by step
One saga, step by step
Executions by status
Executions by status
Undone in reverse
Undone in reverse

How it compares

Your database, undo included

Keeps progress inUndoes stepsNeeds
Active Job Continuations (Rails 8.1)the job (a cursor)nonothing extra
ChronoForgeyour databasenot in its docsnothing extra
ruby_reactorRedisyesRedis and Sidekiq
Temporalthe Temporal serverwritten by handa Temporal cluster
ActiveDurableyour databaseyes, in reverse, with a point of no returnnothing extra

Does it hold?

Workers killed mid-checkout, no double charge

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 16MySQL 9.6SQLite 3
A step (one durable commit)1.8 ms3.0 ms0.45 ms
Resuming a saga with 1,000 finished steps17 ms21 ms9 ms
2,000 sagas, 8 workers6.9 s8.1 s1,000 sagas, 4 workers: 6.5 s
The same, killing a worker every second9.2 s, 9 workers killed13.2 s, 13 workers killed—
Duplicate charges000

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.

Run it yourself

  1. git clone https://github.com/webresstudio/active_durable && cd active_durable && bundle install
  2. DB=postgresql CHAOS=1 bundle exec ruby benchmarks/load.rb

It 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

Three commands, then your first saga

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

  1. bundle add active_durable
  2. bin/rails generate active_durable:install
  3. bin/rails db:migrate

Updating from an older version? Run bin/rails generate active_durable:upgrade and bin/rails db:migrate.

Set up a Rails app →