Guide · Ruby on Rails

Prevent race conditions in Rails

Use SQL updates and locks to fix Rails races. Learn when Solid Objects fits ticket holds, retries, expiry, and work after a restart.

Most Rails races need a database constraint, an atomic update, a row lock, or optimistic locking. A virtual actor helps when one resource has a lifecycle. Requests, jobs, and timers change it, and these changes must occur in order and survive a restart. Solid Objects (the solid_objects gem) provides that actor on the SQL database that the application already uses.

Choose the right tool

Problem Start with When Solid Objects becomes relevant
Duplicate records A unique index in the database A larger entity lifecycle also needs ordered durable work
Concurrent increments or an inventory decrement An atomic SQL update or a short transaction The operation is part of holds, expiry, retries, and later commands
Two people edit from an old form Optimistic locking or a revision check The entity also needs coordination across jobs and requests
Several database changes in one request A transaction and the correct row locks Work must continue after that transaction and survive failures
Commands that arrive through requests, jobs, and reminders An explicit coordination design This is the main use for an actor. The rest of this guide shows it

Use the Rails tools first

Unique index

A Rails uniqueness validation does not create a uniqueness constraint in the database. Two database connections can create two records with the same value. The validation alone does not stop duplicates.

Create a unique index on the column in the database. See the Rails Guides, section 2.10: uniqueness (checked October 9, 2026).

Pessimistic locking

Rails supports row-level locks through SELECT … FOR UPDATE. The with_lock method wraps the block in a transaction. It reloads the object with a lock before it runs the block.

event.with_lock do
  event.update!(seats_available: event.seats_available - 1) if event.seats_available.positive?
end

See the Rails pessimistic locking reference (checked October 9, 2026).

Optimistic locking

Active Record uses an integer lock_version column for optimistic locking. Each update increments lock_version. A stale save raises ActiveRecord::StaleObjectError.

The Rails documentation recommends a hidden lock_version field in the form. That field lets the check work across web requests. See the Rails optimistic locking reference (checked October 9, 2026).

A worked example: ticket holds

An event has a fixed number of seats. Buyers hold seats before they pay.

Step 1: Reproduce the race

Two requests load the same event. Each request sees one free seat. Each request writes the value it computed. Both holds succeed, although the event has only one seat.

class Event < ApplicationRecord
  def hold_seat_unsafely
    return false if seats_available.zero?

    update!(seats_available: seats_available - 1)
    true
  end

  def hold_seat
    self.class.where(id:).where("seats_available > 0")
      .update_all("seats_available = seats_available - 1") == 1
  end
end

The hold_seat_unsafely method shows the race. The hold_seat method provides the fix in step 2.

The test reproduces the race without timed delays. It loads two copies of the event, then holds a seat with each copy. Both holds succeed. See the race condition tests.

Step 2: Fix it with one SQL statement

The hold_seat method lets the database decide. The UPDATE changes the row only when a seat is free. The method returns true only when the statement changes one row.

In the test, ten requests load the event before they wait at a barrier. Then they try to hold seats at the same time. The event has three seats. Exactly three holds succeed.

Stop here if a hold never expires and no later operation changes it. An atomic SQL update is the correct fix for a simple counter.

Step 3: The lifecycle needs more than a lock

A real ticket hold has more requirements:

A pure SQL design needs a holds table, an expiry job, retry keys, and recovery after a restart. Every path must take the same lock in the same order.

Step 4: One actor owns the event

There is one EventTickets actor for each event ID. Calls for one actor run one at a time, in order. Different events can run at the same time.

class EventTickets < SolidObjects::Actor
  HOLD_DURATION = 10.minutes

  attribute :opened, default: false
  attribute :seats_available, default: 0
  attribute :holds, default: -> { {} }
  attribute :sold, default: -> { [] }
  attribute :title, default: ""
  attribute :revision, default: 0

  def open_sales(seats:)
    return seats_available if opened

    self.opened = true
    self.seats_available = seats
  end

  def hold(buyer:, hold_id:)
    release_expired_holds
    return { held: true, hold_id: } if holds.dig(buyer, "hold_id") == hold_id
    return { held: false, reason: "already_held" } if holds.key?(buyer)
    return { held: false, reason: "sold_out" } if seats_available.zero?

    deadline = HOLD_DURATION.from_now
    self.seats_available -= 1
    self.holds = holds.merge(buyer => { "hold_id" => hold_id, "expires_at" => deadline.to_i })
    schedule(at: deadline, key: buyer).expire(buyer:, hold_id:, expires_at: deadline.to_i)
    { held: true, hold_id: }
  end

  def confirm(buyer:, hold_id:)
    return { confirmed: true } if sold.include?(hold_id)

    release_expired_holds
    reject(:no_hold, "The hold expired or does not exist") unless holds.dig(buyer, "hold_id") == hold_id

    self.holds = holds.except(buyer)
    self.sold = sold + [ hold_id ]
    unschedule(:expire, key: buyer)
    { confirmed: true }
  end

  def expire(buyer:, hold_id:, expires_at:)
    return seats_available unless holds[buyer] == { "hold_id" => hold_id, "expires_at" => expires_at }

    self.holds = holds.except(buyer)
    self.seats_available += 1
  end

  def update_details(title:, base_revision:)
    reject(:stale_revision, "Reload the event and try again") unless base_revision == revision

    self.title = title
    self.revision += 1
  end

  private

  def release_expired_holds
    expired_buyers = holds.select { |_buyer, hold| hold.fetch("expires_at") <= Time.current.to_i }.keys
    self.holds = holds.except(*expired_buyers)
    self.seats_available += expired_buyers.length
  end
end

The actor owns the lifecycle:

Call the actor from a controller:

result = EventTickets.ref(event.id.to_s).hold(
  buyer: Current.user.id.to_s,
  hold_id: params.require(:hold_id),
  authorization_context: Current.user
)

Step 5: What the tests prove

Serial execution does not stop a stale form

An actor runs one call at a time. A stale form can still replace newer data. If two people load revision 0 and both submit, the second submit still runs after the first.

Use a domain operation or a revision check. The example's update_details method rejects a call when base_revision differs from the current revision.

Run it in production

Authorization

The install generator creates policies that deny every call. Write a policy that checks the caller and the actor type. For example, the policy can check a signed-in user.

Pass authorization_context: on each call. An actor ID is not a permission. See authorization policies.

The runtime process

Reminders run only while bundle exec solid_objects start runs. SQL keeps the unfinished work while the runtime process is down. The runtime process runs an overdue reminder after it starts again. See reminders.

Retention cost

Every actor call creates a durable message row. The default message_retention keeps terminal message history for 30 days. Actor state stays until you destroy the actor or configure instance_retention_by_actor_type.

In this example, the event capacity bounds the state. See retention and backups.

External effects

Do not call a payment provider inside the actor. Use emit and an effect handler. Pass context.id to the provider as the idempotency key.

Delivery is at least once, so an effect can run more than once. See effect idempotency.

Limits

More information

Generated from docs/guides/race-conditions.md in cardmagic/solid-objects-ruby, v0.17.3, commit 8c9f0f9. For TypeScript and Node.js, read Virtual actors in TypeScript and Node.js.