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
class ExpireStaleCartsJob < ApplicationJob
queue_as :low
BATCH_SIZE = 500
def perform(window: Cart::EXPIRY_WINDOW)
expired = 0
Cart.stale(window).in_batches(of: BATCH_SIZE) do |batch|
batch.each do |cart|
begin
cart.expire!
expired += 1
rescue => e
Rails.logger.error("[reaper] cart=#{cart.id} failed: #{e.class} #{e.message}")
next
end
end
end
Rails.logger.info("[reaper] expired #{expired} stale carts")
expired
end
end
expire_stale_carts:
cron: "*/15 * * * *"
class: "ExpireStaleCartsJob"
queue: low
description: "Expire abandoned carts and release held inventory"
# args are passed positionally; empty means use job defaults
args: []
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.