ruby 91 lines · 3 tabs

Expiring Idle Shopping Carts with a TTL Check and a Sweeper Job in Rails

Shared by codesnips Sep 2026
3 tabs
class Cart < ApplicationRecord
  TTL = 30.minutes

  has_many :line_items, dependent: :destroy

  enum status: { active: 0, expired: 1, checked_out: 2 }

  scope :active, -> { where(status: statuses[:active]).where("last_active_at > ?", TTL.ago) }
  scope :stale,  -> { where(status: statuses[:active]).where("last_active_at <= ?", TTL.ago) }

  def touch_activity!
    update_column(:last_active_at, Time.current)
  end

  def expired?
    active? && last_active_at <= TTL.ago
  end

  def expire_if_stale!
    return false unless expired?

    with_lock do
      return false unless expired? # re-check under row lock
      expire!
    end
    true
  end

  def expire!
    transaction do
      line_items.find_each { |item| item.release_reservation! }
      update!(status: :expired, expired_at: Time.current)
    end
  end
end
3 files · ruby Explain with highlit

This snippet shows a common e-commerce housekeeping problem: shopping carts that sit untouched must eventually expire so that reserved inventory is released and stale carts don't clutter checkout. The approach combines a lazy TTL check that happens whenever a cart is read with an active sweeper job that periodically cleans up in bulk. Neither strategy alone is enough — the lazy check keeps individual sessions correct in real time, while the sweeper guarantees abandoned carts eventually get collected even if no one ever touches them again.

The Cart model treats last_active_at as the source of truth for freshness. TTL defines the inactivity window, and touch_activity! bumps the timestamp on every meaningful interaction so an active shopper never sees their cart vanish. expired? is a pure comparison against TTL.ago, keeping the rule in one place. The important detail is expire_if_stale!: it is called on read paths and, inside a transaction with lock!, re-checks expired? before acting. That double-check under a row lock avoids a race where two concurrent requests both try to expire the same cart. The expire! method releases inventory via line_items and flips the status, and the active / stale scopes push the TTL predicate into SQL so queries stay index-friendly on last_active_at.

The CartSweeperJob is the active half. It selects Cart.stale in batches with in_batches, and calls expire_if_stale! per record rather than a blind bulk UPDATE so that inventory release and any callbacks run correctly. find_each-style batching bounds memory, and rescuing per-cart means one bad row won't abort the whole sweep. It reschedules itself, making it safe to enqueue from a scheduler.

The CartsController wires the lazy check into the request cycle: set_cart loads the cart and immediately calls expire_if_stale!, so a returning shopper past the TTL gets a clean empty cart instead of stale reservations. Write actions call touch_activity! to keep live carts alive.

The trade-off is eventual rather than exact expiry — a cart may live slightly past its TTL until read or swept. For carts that reserve stock, tune the sweep frequency to the acceptable reservation slack, and rely on the row lock to keep concurrent expirations idempotent.


Related snips

Share this code

Here's the card — post it anywhere.

Expiring Idle Shopping Carts with a TTL Check and a Sweeper Job in Rails — share card
Link copied