ruby yaml 51 lines · 3 tabs

Expire Stale Shopping Carts With a Rails Scope and a Recurring Reaper Job

Shared by codesnips Aug 2026
3 tabs
class Cart < ApplicationRecord
  has_many :cart_items, dependent: :destroy

  EXPIRY_WINDOW = 2.hours

  scope :active, -> { where(state: :active) }

  scope :stale, lambda { |window = EXPIRY_WINDOW|
    active
      .where("carts.updated_at < ?", window.ago)
      .where("EXISTS (SELECT 1 FROM cart_items WHERE cart_items.cart_id = carts.id)")
  }

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

This snippet shows the common e-commerce pattern of reclaiming abandoned shopping carts on a schedule, split across the model that defines what "stale" means, the job that does the reaping, and the scheduler config that runs it. Concentrating the definition of staleness in the model keeps the policy in one place, so the job stays small and the meaning of an expired cart never drifts between callers.

In Cart model, the stale scope is the heart of the feature. It filters to carts that are still active, were last touched before a configurable cutoff, and — importantly — actually contain items via a subquery on cart_items. Empty carts are excluded because expiring them accomplishes nothing and just churns rows. The EXPIRY_WINDOW constant makes the threshold a single tunable value, and expire! performs the state transition on one record while releasing any held inventory in the same transaction, so stock is never leaked if the process dies mid-run. active is a plain enum-style scope that pairs naturally with stale, letting the two compose.

The ExpireStaleCartsJob is written for reliability rather than raw speed. Instead of loading every stale cart into memory, it uses in_batches to page through the relation with a bounded of: size, calling expire! per record. Wrapping each cart's transition in find_each-style iteration means one poison record cannot roll back an entire batch, and the rescue logs and continues so a single corrupt cart doesn't wedge the whole reaper. Because stale only ever matches carts that are still active, the job is naturally idempotent: a cart expired on a previous pass simply won't be selected again, so re-running after a crash is safe and reprocessing is harmless.

The sidekiq-cron schedule wires the job to run every fifteen minutes. Using cron-style scheduling rather than self-rescheduling jobs avoids the drift and duplicate-enqueue problems that plague perform_in loops, and it survives deploys because the schedule is declarative. The trade-off is that the window between runs bounds how quickly stock is released, so EXPIRY_WINDOW and the cron cadence should be tuned together. This approach fits any domain where rows accumulate a soft-expired state — sessions, reservations, invites — and need periodic, crash-safe cleanup.


Related snips

Share this code

Here's the card — post it anywhere.

Expire Stale Shopping Carts With a Rails Scope and a Recurring Reaper Job — share card
Link copied