Guide · Ruby on Rails

GoodJob concurrency controls and Solid Objects: ordered work for each customer in Rails

GoodJob concurrency controls limit jobs for each label but do not keep FIFO order. Compare them with Solid Objects for ordered work in Rails.

GoodJob is a PostgreSQL-backed Active Job backend. Its concurrency controls limit how many jobs with the same label run at a time, but they do not guarantee first-in, first-out order. Solid Objects (the solid_objects gem) gives each customer one actor with an ordered mailbox, its own durable state, and reminders, in the SQL database that the app already uses. Use GoodJob when jobs are independent or order does not matter. Use Solid Objects when the updates for one customer must apply in the order they arrived, and each customer has state that a retry must keep.

Solid Objects is an independent project. It is not affiliated with GoodJob or Rails.

The short answer

Question GoodJob with a concurrency rule Solid Objects
What runs one at a time Jobs with the same label, with perform_limit: 1 Calls to the same actor ID
How a second job waits It raises ConcurrencyExceededError and retries with backoff. With the good_job_concurrency_claims table, the longest-waiting job is rescheduled to run at once when the key becomes free It waits in the actor mailbox
Order for each key Not guaranteed. The README says "First-in-first-out job execution order is not strictly preserved" Kept. Messages for one actor run in the order that the runtime stores them
State for each key Your own table Actor attributes, stored by the gem
Later work set(wait:), retry_on, cron, and batches Reminders with schedule(at:, key:)
A worker stops in the middle of a run GoodJob retries the job automatically The lease expires and the turn runs again. Delivery is at least once
Process to run bundle exec good_job start, or async mode in the web process bundle exec solid_objects start
Databases PostgreSQL only SQLite, PostgreSQL, MySQL
Licence MIT MIT

The GoodJob facts come from the GoodJob 4.22.1 README (checked October 15, 2026).

Choose GoodJob when

Choose Solid Objects when

The same terms in each

GoodJob and Active Job Solid Objects
The label: of good_job_concurrency_rule The actor ID
A job class with perform An actor method (an operation)
perform_later ref(id).async.method(...)
retry_on with wait: Effect retries (max_attempts, retry_delay), then on_failure and a reminder
set(wait:) ref(id).async(available_at:), or schedule(at:, key:) inside the actor
A row in your own table attribute on the actor
Cron and batches No equivalent. Keep them in GoodJob

GoodJob 4.21.0 deprecated rules with both label: and key:. GoodJob 4.22.0 deprecated good_job_control_concurrency_with and rules with key: alone. The README says that they "will raise in GoodJob v5". The current form is good_job_concurrency_rule with label:.

One billing sync for each customer

The task: a Rails 8 app on PostgreSQL syncs each customer's subscription with an external billing API. The updates for one customer must run one at a time and in the order they arrived. Different customers run in parallel. Each customer has state: the last synced version, a retry count, and the next retry time. A failed sync must retry with backoff and survive a restart.

With GoodJob

This job follows the GoodJob and Active Job documentation. It was not run by the Solid Objects test suite.

class SyncSubscriptionJob < ApplicationJob
  include GoodJob::ActiveJobExtensions::Concurrency

  BACKOFF = ->(executions) { (executions**4) + 2 }

  good_job_concurrency_rule(label: -> { "customer-#{arguments.first}" }, apply_label: true, perform_limit: 1)
  retry_on BillingApi::TransientError, wait: BACKOFF, attempts: 10

  def perform(customer_id, version)
    sync = BillingSyncState.find_or_create_by!(customer_id:)
    return if version <= sync.last_synced_version

    BillingApi.push_subscription(customer_id, version)
    sync.update!(last_synced_version: version, retry_count: 0, next_retry_at: nil)
  rescue BillingApi::TransientError
    failures = exception_executions["[BillingApi::TransientError]"].to_i + 1
    sync.update!(retry_count: sync.retry_count + 1, next_retry_at: BACKOFF.call(failures).seconds.from_now)
    raise
  end
end

BillingSyncState is a plain Active Record model. Its last_synced_version and retry_count columns are integers with a default of 0, and next_retry_at is a timestamp. exception_executions counts the earlier failures for one error class, and retry_on uses the same count for its delay.

What this job gets from GoodJob:

What it does not get:

GoodJob also says that its strategy "assumes that collisions are atypical". The updates for one busy customer collide often.

With Solid Objects

There is one CustomerBillingSync actor for each customer ID.

class CustomerBillingSync < SolidObjects::Actor
  MAXIMUM_BACKOFF_MINUTES = 60

  attribute :last_synced_version, default: 0
  attribute :pending, default: -> { [] }
  attribute :pushing, default: false
  attribute :retry_count, default: 0
  attribute :next_retry_at, default: nil

  def update_subscription(version:, plan:)
    return newest_version if version <= newest_version

    self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
    push_next unless pushing || next_retry_at
    version
  end

  def pushed(effect_id:, arguments:, result:)
    self.pushing = false
    self.last_synced_version = arguments.fetch("version")
    self.pending = pending.drop(1)
    self.retry_count = 0
    self.next_retry_at = nil
    push_next
  end

  def push_failed(effect_id:, arguments:, error:)
    self.pushing = false
    self.retry_count += 1
    retry_at = [ 2**retry_count, MAXIMUM_BACKOFF_MINUTES ].min.minutes.from_now
    self.next_retry_at = retry_at.iso8601
    schedule(at: retry_at, key: "retry").retry_push
  end

  def retry_push
    self.next_retry_at = nil
    push_next unless pushing
  end

  private

  def newest_version
    pending.last&.fetch("version") || last_synced_version
  end

  def push_next
    update = pending.first
    return unless update

    self.pushing = true
    emit :push_subscription,
      idempotency_key: update.fetch("key"),
      customer_id: actor_id,
      version: update.fetch("version"),
      plan: update.fetch("plan"),
      on_success: :pushed,
      on_failure: :push_failed
  end
end

The initializer registers the effect that calls the billing API:

SolidObjects.register_effect(:push_subscription) do |arguments, _context|
  BillingApi.push_subscription(
    idempotency_key: arguments.fetch("idempotency_key"),
    customer_id: arguments.fetch("customer_id"),
    version: arguments.fetch("version"),
    plan: arguments.fetch("plan")
  )
end

The app sends each update with async:

CustomerBillingSync.ref(customer.id.to_s)
  .async(idempotency_key: "subscription-#{subscription.id}-v#{subscription.version}", authorization_context: Current.user)
  .update_subscription(version: subscription.version, plan: subscription.plan)

The generated policies deny every call. Allow this call in your authorize_message policy. See authorization policies.

How it works:

What the tests prove

The tests show these results:

Questions that teams ask

Is Solid Objects mature enough? Here are the facts:

GoodJob is older and has more users. If that matters more than order for each customer, choose GoodJob and add a sequence check, as the ordered jobs guide shows.

Does it need another process? Yes. bundle exec solid_objects start runs async messages, effects, and reminders. GoodJob in external mode also needs a process: bundle exec good_job start. While the Solid Objects process is down, pending work stays in SQL.

Is delivery exactly once? No. Delivery is at least once. A turn can run again after a crash. Give each external call a stable idempotency key. The effect ID (context.id) is stable across the retries of one effect. The example stores a key with each update, so the key also survives a backoff retry. See correctness and delivery semantics.

Can actor code write my tables? Not directly. An actor reads application records. To write a row in the same transaction as the actor state, use a commit action. The ordered jobs guide shows one.

What if one customer is very busy? One actor runs one turn at a time. If one customer has independent streams of work, give each stream its own actor ID, for example one for each subscription.

Can I use both? Yes. Keep your other jobs in GoodJob. The two use different tables in the same database.

Sources

Checked October 15, 2026:

More information

Generated from docs/compare/good-job.md in cardmagic/solid-objects-ruby, v1.0.2, commit 5528659. For TypeScript and Node.js, read Virtual actors in TypeScript and Node.js.