Durable Orchestration on Rails: Turning Active Job Continuations into Workflows

Open on YouTube ↗
Overview

Vladimir Dementyev leads a backend team at Evil Martians and has contributed to Rails for about a decade. In this talk he looks at how teams coordinate multi-job processes today: pipelines, batches, timers, and human-in-the-loop approvals. He argues that Active Job continuations, introduced about a year earlier, have quietly become an orchestration primitive. They are still too primitive for most workflows, though. His proposal is to extend continuations, in a Rails-native way, into "durable continuations" so that an entire workflow can live in a single job class.

17 min read

From thin facade to a feature of its own

Dementyev starts with a short history of Active Job from Rails 4.2 to 8.2. When it was announced in 2014, the same year he shipped his first production Rails app, he thought it was nice that Rails finally had its own background job interface. He saw no reason to migrate, though, because it seemed like a thin compatibility layer over existing gems that added nothing and lacked a lot. That app is still alive as a large monolith and still doesn't use Active Job. He says he has seen many apps like it, and he urges teams still using, for example, Sidekiq workers directly to reconsider. In his telling, Active Job has gradually picked up solid ideas of its own. A year ago it gained something that backend engines themselves didn't provide: continuations.

What continuations were built for

Dementyev describes continuations as a resilience concept by design. They make long-running jobs interruptible and resumable by introducing steps and cursors that act as checkpoints. He points to the original pull request, where the stated motivation was surviving deployments.

He illustrates this with a webhook dispatch job. Without continuations, a deploy in the middle of an iteration drops the job. The backend retries it from the beginning, so the same webhook event can be sent twice. The common workaround has been to split the work into an orchestrating job that enqueues one atomic job per record. That approach is robust but more verbose and less cohesive. The continuation version keeps the same guarantees with the whole workflow in one file. Dementyev prefers this, saying it is "more resilient to human or agent errors."

Looking ahead, he highlights a set of recent Active Job pull requests, some merged and some not. About half of them relate to continuations, and they come from community members rather than the core team. He reads this as rapid adoption, but by users whose goals differ from the original intent. They use continuations to orchestrate long-running multi-step operations with state or fairness guarantees, not to survive deploys. He shows a snippet he uses often: splitting a long LLM-related job into isolated steps so work is distributed more fairly across workers. His conclusion is that a feature "born as a workaround for infrastructure quirks" has become an orchestration primitive. It is still too primitive to be a building block for common orchestration scenarios, and the rest of the talk explains why.

Pattern 1: Pipelines

The first pattern is the pipeline: a sequence of jobs, common in file processing, data imports, and syncing. Each job does one step and enqueues the next on success. Chains can grow very long and even branch. Dementyev says this is typical of large Rails apps, and that months later nobody knows where the chain starts, which way it goes, what its outcomes are, or how it fails.

Refactoring the chain into continuation steps puts the workflow in one place. He notes this has nothing to do with interruptibility; it is purely about organizing code. Some awkwardness remains. Step execution still depends on state saved on the model, which he calls "not the best way," but there is no other state to use.

A second pipeline example comes from Evil Martians' AnyCable Plus project. A single job is re-enqueued for each step, and a separate state machine controls it. No job class even appears in the code, because the team uses the Active Job Performs plugin, which turns a method into a background job. The problem is that too many entities are involved in orchestrating the work. The process could be written as a continuation-based state machine. The new attributes API helps keep state during the workflow, but that state lives in the job payload and can't be seen from outside.

From these examples Dementyev lists what is missing:

  • No step-level control-flow hooks. There are no step callbacks and no per-step rescue handlers. In his example every step repeats a broadcast update that an after_step callback could handle.
  • No observability. With no persistent state, you can't tell which step a job is on, how long it has been running, or what its step history looks like.
  • No uniqueness. Long-running workflows usually shouldn't be enqueued again while one is running, or the running one should be cancelled and replaced. Some providers offer this, but Active Job has no framework-level concept for it.

Existing third-party options

Dementyev reviews the tools available today. His team has used Acidic Jobs on a couple of projects. It stores the workflow's state in the database so it can be restored and observed, and it has built-in uniqueness. What matters most to him is that it keeps the Active Job interface: callers still see an Active Job, and durability stays hidden inside the workflow class.

He also mentions Geneva Drive, a more recent project that he says has almost everything he would want in a workflow implementation, and he returns to it several times. His main objection is that "it's not a Rails thing." It has its own DSL and its own entity, isn't fully compatible with Active Job, and requires refactoring everything to adopt it. He wants something framework-friendly with the same guarantees.

13:54

Pattern 2: Batches

Batching means one job does setup, many jobs do their own tasks, and something runs on completion. Sequential batches can already be written with continuations. He shows an example from Spree: begin processing, create a job per CSV row, then finalize. Batches usually imply parallel execution, though, and that requires a coordinator. Typical coordinators are counters, often in Redis, or polling jobs that check every few seconds whether everyone is done.

Teams have usually reached for third-party gems here. Dementyev notes that Solid Queue (he cites version 1.7) has just gained batch support, thanks to JP Camara with significant help from Rosa. He recommends reading the story of how it got there. In his view it shows how much effort an outside contributor needs to ship a major feature to a cornerstone library. In Solid Queue's design everything is a job, and completion handlers are jobs enqueued on success or failure. The interface is inspired by GoodJob, which has had batches for a while.

Many libraries offer batching, each with its own interface. Dementyev calls this a problem, though a manageable one. He singles out Doximity's Simplekiq. It is Sidekiq-specific, but its interface closely resembles continuations, with steps and also parallel steps, which continuations lack. To him this suggests continuations could adopt parallel steps. The blocker is that Active Job itself has no batch interface yet. He says there have been discussions about adding one, and once it lands, continuations could become batch-aware. Until then, "we need to wait."

17:03

Pattern 3: Timers

The next group covers reminders, expirations, notifications, and similar time-based tasks. His AnyCable example is a license-expiration workflow made of recurring polling jobs that run every ten minutes or every hour. Each job is an entry point that enqueues more jobs, which adds many concepts for a single workflow, and the structure must be repeated for each new workflow. Continuations help somewhat: iteration within steps and cursors can collapse everything into one job class. The scheduling layer is still needed.

The other time-based approach is to schedule a job in the future with wait or wait_until. Dementyev says this has many problems. Active Job has no framework-level API to cancel a scheduled job, so teams usually check whether the job is still relevant when it runs. Provider-specific cancellation exists but tends to be a lot of work, and he finds the relevance check simpler and more reliable. The approach sounds good conceptually but is hard to get right, so teams usually fall back to cron-driven sweeps.

He surveys tools that make this easier. One relatively new project extends Active Job with DSL-style method decorations, including a wait that marks where the job stops, records itself in a waiting table, and gets re-enqueued to continue when the time comes. Geneva Drive offers timers as a step option. It also links workflows to Active Record models through what it calls "heroes," so you can find and cancel the workflow tied to a given record. Dementyev calls this very useful. Third-party services such as Temporal go further. In an appointment-reminder example, when the date changes, you update the reminder's deadline as part of the workflow's state instead of cancelling and restarting the workflow.

20:28

Pattern 4: Signals and human-in-the-loop

Dementyev treats approval and signal-driven workflows as a broader version of timers. The workflow waits for something other than time, such as a user action. In code, a signal typically splits the workflow into "before" and "after" halves in different places. His example from Mastodon is one process that should be read as a single flow with a pause in the middle, but Rails offers no way to write it that way. Because a signal may never arrive, the app also needs ad hoc cleanup for tasks that were never signaled.

Webhooks are another common kind of signal. In a real app he worked on, a withdrawal workflow was spread across three files: a job that requests the withdrawal, a webhook handler that receives confirmation or rejection, and another job that processes the result. Using the signaling feature of a gem he calls ChronoForge (via a step he describes as having the non-obvious name continue_if), the flow could be a single perform with three steps: run the first step, wait for the payment receipt, run the credit step.

The team behind that project chose Temporal instead, and Dementyev says he isn't happy with the result. In his view, something you can describe in a few English words shouldn't cost hundreds of lines of code, dozens of Ruby objects, and "voodoo magic" to work. The general problem, as he sees it, is that more sophisticated workflows push teams toward more sophisticated tools. He doesn't think that is the right direction for Rails. He wants "more batteries included."

23:21

The proposal: durable continuations

Dementyev says he first floated the idea at a conference about a year ago. Rather than investing in custom gems or SDKs, the community could build on continuations and turn them into something bigger. In this talk he presents the first implementation.

He defines "durable" plainly and says the word is overhyped. The core difference is that the job's execution is a record, or several records, in a database or other store, rather than just a payload in the queue. Other properties, such as surviving killed processes, follow from carefully committing that state and making it restorable and replayable.

His main design requirement, which he says he applies to every library he builds, is staying as close to the Rails way as possible. It should feel like using the framework and be an extension rather than a replacement, something that could eventually be merged into Rails. He argues that these style and taste constraints usually lead to a better implementation.

25:39

Persisted state

His walkthrough uses a card-generation job from one of the team's projects, which turns user photos into cute characters. It has three steps: process the original image, moderate it so inappropriate images aren't shown to the community, and generate the final content. In the durable version, a table records each run, its state, and its steps. A record appears when the job is enqueued. The state changes when a worker picks it up, and each step updates as it progresses. Job attributes are persisted too, so they can be observed from outside and used by other features. A query API lets you inspect, cancel, or retry runs. Unlike Geneva Drive, it doesn't use "heroes." It infers identifiers from the job's arguments or from identifiers defined explicitly on the job.

He then revisits the webhook example. With plain continuations, a hard crash or uncaught exception can leave the cursor uncommitted, so the job starts over with no memory of progress within a step. With durable continuations, which take a one-word change to the class, the cursor is committed (configurably, not necessarily on every iteration). He says this gives stronger reliability guarantees against various failures.

28:47

Lifecycle features

Durable continuations add step callbacks such as after_step, which removes the repeated inline code from step bodies. Dementyev says this makes a workflow look like a typical Rails object with the conveniences of models and controllers, "no longer just a tiny wrapper over background job execution." A halt_on rescue handler ends the workflow after a particular step. It differs from discard_on because the job can be restarted once the problem is fixed, since some errors are recoverable. Uniqueness comes almost for free because runs are stored in a database.

29:58

Timers

For timers, the license-expiration workflow that previously needed a scheduler and many jobs becomes a single job whose steps take wait and wait_until. With uniqueness, renewing a license just means enqueuing the job again: the new run replaces the previous workflow and continues. Another example shows an Active Record integration, a has_one_performed-style association that gives easy access to a model's associated runs.

30:32

Signals

Running short on time, Dementyev moves to what he calls his favorite feature. He converts the Temporal-based withdrawal example, and it becomes a single Ruby class with a few configuration lines. All the logic sits in one place that can be observed and controlled. When someone asks whether it reads better than English, he replies that it's Ruby, which "has structure and clear meaning."

He then upgrades the card-generation example. If the moderation score for a photo is borderline, a human is asked whether the image may be posted. The current implementation tracks this with enum states on the model and checks them from the job. He lists the problems with that: possible concurrent executions, no history, and no review deadlines. With durable continuations this becomes a single wait step, a special step with an empty body that parks the job. It can be released by a timeout or by a signal sent from a controller or anywhere else. He says this is the whole API, built on the same primitives as timers.

As a bonus, he mentions that RubyLLM 2.0 was recently released with example snippets that use continuations. He argues they would be simpler with durable continuations. An agent loop could live in one job that halts and resumes after the user approves or rejects an action. RubyLLM already stores chat messages durably, so durable data isn't really needed here. What is needed is durable control-flow state.

33:20

Availability and next steps

Dementyev announced that he would open source the library right after the talk as a gem named AJDC (Active Job Durable Continuation, "nothing related to music"). It includes everything shown in the talk, with more features planned. It also ships with AI agent skills, which he tied to a Rails project he called "hyperdrive," so you can install the gem and ask an agent to configure it, find jobs worth migrating, and migrate them. He added that he doesn't recommend skipping a read of the code afterward.

The goal, as he puts it, is to bring just enough orchestration into Active Job to stay on the Rails way, without reaching for external services or "cryptic gems." Batches and parallel steps remain open until Active Job gains a native batch interface. He asked the audience to pay more attention to continuations, look for these orchestration patterns in their own code, consider extracting them into workflow-like structures, and try durable workflows.