Guide · Ruby on Rails

Expiring reservations in Rails

Use SQL deadlines and locks to prevent excess reservations in Rails. Follow a Solid Objects example with hold extensions and durable expiry reminders.

A reservation holds stock for a short time, until the buyer confirms it or the hold expires. The stock must never go below zero. A retry must not take stock twice. A late expiry must not cancel a newer state. Solid Objects (the gem solid_objects) puts the stock and its holds in one actor, with durable reminders for the deadlines.

Put the stock under the right identity

An actor for each reservation cannot prevent an oversold show. Two reservations can take the same stock. Two reservation actors are two identities. They run at the same time, and neither actor sees the other actor's hold.

Put the stock and all of its holds in the actor that owns the stock. This guide uses one actor for each show.

The plain SQL design

This design is not enough when an action must occur at the deadline. You need a timer for these actions:

The timer, a confirmation, and a client retry can all touch the same hold. Each path must take the same lock. Each path must handle a duplicate or late delivery.

The actor

class SeatInventory < SolidObjects::Actor
  HOLD_DURATION = 15.minutes
  EXTENSION = 5.minutes
  MAX_EXTENSIONS = 2

  attribute :capacity, default: 0
  attribute :holds, default: -> { {} }
  attribute :confirmed, default: -> { {} }

  query :seats_left do
    seats_available
  end

  def open_show(capacity:)
    self.capacity = capacity if self.capacity.zero?
    seats_available
  end

  def hold(hold_id:, buyer:, seats:)
    reject(:invalid_seats, "Hold at least one seat") unless seats.is_a?(Integer) && seats.positive?
    return hold_result(hold_id) if active_hold?(hold_id)
    return { status: "confirmed" } if confirmed.key?(hold_id)
    reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available

    deadline = HOLD_DURATION.from_now
    self.holds = holds.merge(
      hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 }
    )
    schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
    hold_result(hold_id)
  end

  def extend_hold(hold_id:)
    reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)

    hold = holds.fetch(hold_id)
    reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS

    deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
    self.holds = holds.merge(
      hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1)
    )
    schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
    hold_result(hold_id)
  end

  def confirm(hold_id:)
    return { status: "confirmed" } if confirmed.key?(hold_id)
    reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)

    hold = holds.fetch(hold_id)
    self.holds = holds.except(hold_id)
    self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
    unschedule(:expire, key: hold_id)
    { status: "confirmed" }
  end

  def expire(hold_id:, expires_at:)
    return seats_available unless holds.dig(hold_id, "expires_at") == expires_at

    self.holds = holds.except(hold_id)
    seats_available
  end

  private

  def active_hold?(hold_id)
    holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
  end

  def seats_available
    held_seats = holds.each_key.select { |hold_id| active_hold?(hold_id) }.sum { |hold_id| holds.dig(hold_id, "seats") }
    capacity - held_seats - confirmed.values.sum
  end

  def hold_result(hold_id)
    { status: "held", expires_at: holds.dig(hold_id, "expires_at") }
  end
end

Active holds and confirmed seats are bounded by the show capacity. An expired hold stays in the state until its reminder runs.

Deadlines that survive a restart

Use durable reminders for persistent timers in Rails.

schedule(at:, key:) stores the reminder in the database in the same commit as the state change. A reminder is one named alarm for each actor and key. A new schedule with the same key moves the alarm. unschedule(:expire, key: hold_id) cancels it. The deadline check in confirm and extend_hold does not wait for the reminder.

Reminders run only while bundle exec solid_objects start runs. A reminder that falls due while the process is stopped runs after the process starts again. A reminder runs an ordinary actor message, so it runs in order with the other calls for that show.

Delivery is at least once, so expire checks the deadline before it changes anything. See reminders.

What the tests prove

See the tests for this guide.

Payment and other side effects

Do not call a payment provider inside the actor. An effect can run more than once.

See the transactional outbox guide.

Run it in production

Limits

More information

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