ruby 77 lines · 3 tabs

Transactional “Reserve Inventory” with SELECT … FOR UPDATE

Shared by codesnips Jan 2026
3 tabs
class CreateInventoryReservations < ActiveRecord::Migration[7.1]
  def change
    create_table :inventory_items do |t|
      t.string  :sku, null: false
      t.integer :quantity_on_hand,  null: false, default: 0
      t.integer :quantity_reserved, null: false, default: 0
      t.timestamps
    end
    add_index :inventory_items, :sku, unique: true
    add_check_constraint :inventory_items,
      "quantity_reserved >= 0 AND quantity_reserved <= quantity_on_hand",
      name: "reserved_within_on_hand"

    create_table :reservations do |t|
      t.references :inventory_item, null: false, foreign_key: true
      t.string  :dedupe_key, null: false
      t.integer :quantity, null: false
      t.string  :status, null: false, default: "held"
      t.timestamps
    end
    add_index :reservations, :dedupe_key, unique: true
  end
end
3 files · ruby Explain with highlit

This snippet shows the classic problem of reserving stock under concurrent checkout traffic: two requests read the same available quantity, both decide there's enough, and both commit — an oversell. The fix is pessimistic row locking with SELECT ... FOR UPDATE, serializing the read-modify-write against a single inventory row for the duration of a transaction.

The create_inventory_reservations migration defines the two tables involved. inventory_items holds quantity_on_hand and quantity_reserved as non-null integers, and a check constraint (quantity_reserved <= quantity_on_hand) acts as a last line of defense at the database level — even if application logic is buggy, Postgres refuses to persist an oversell. The reservations table carries a dedupe_key with a unique index so the same logical checkout can be retried safely without double-reserving.

In InventoryItem model, reserve! is the core routine. It opens a transaction and calls lock!, which issues SELECT ... FOR UPDATE and reloads the row; any other transaction touching the same item now blocks until this one commits or rolls back. Only after acquiring the lock does it compute available and either raise InsufficientStock or increment quantity_reserved. Because the read and write happen inside the same locked window, the decision can't be invalidated by a competing writer. The available helper is a plain in-memory subtraction, correct only because callers hold the lock.

ReservationsController ties it together. It first guards on dedupe_key by returning any existing Reservation, giving idempotent retries. The find_by! and the reserve! call run inside one ActiveRecord::Base.transaction, so the reservation row and the counter update commit atomically. A rescue of InsufficientStock maps cleanly to 422, while the unique index converts a racing duplicate into RecordNotUnique, handled as a 409.

The main trade-off is throughput: FOR UPDATE serializes access per item, so a single hot SKU becomes a contention point. It's the right tool when correctness matters more than raw concurrency on that row. Keeping transactions short — lock, decide, write, commit — minimizes the blocking window, and lock ordering matters if a request ever reserves multiple items to avoid deadlocks.


Related snips

Share this code

Here's the card — post it anywhere.

Transactional “Reserve Inventory” with SELECT … FOR UPDATE — share card
Link copied