namespace :cleanup do
desc "Enqueue a job to purge expired sessions"
task expired_sessions: :environment do
job = ExpiredSessionCleanupJob.perform_later
Rails.logger.info("[cleanup:expired_sessions] enqueued job #{job.job_id}")
end
end
class ExpiredSessionCleanupJob < ApplicationJob
queue_as :maintenance
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
def perform
total = 0
Session.expired.in_batches(of: Session.cleanup_batch_size) do |batch|
total += batch.delete_all
end
Rails.logger.info("[ExpiredSessionCleanupJob] deleted #{total} expired sessions")
total
end
end
class Session < ApplicationRecord
belongs_to :user
CLEANUP_BATCH_SIZE = 1_000
scope :expired, -> { where(arel_table[:expires_at].lt(Time.current)) }
def self.cleanup_batch_size
CLEANUP_BATCH_SIZE
end
def expired?
expires_at.present? && expires_at < Time.current
end
end
set :output, "log/cron.log"
set :environment, ENV.fetch("RAILS_ENV", "production")
every 1.hour do
rake "cleanup:expired_sessions"
end
every :day, at: "3:30 am" do
rake "cleanup:expired_sessions"
end
This snippet shows the common Rails pattern for expiring stale records on a schedule: a thin rake task that the system cron (or a scheduler like whenever/cron in a container) invokes, which in turn enqueues an Active Job that does the real work. Separating the two layers matters because rake tasks run in the foreground of the invoking process, hold a full Rails boot, and offer no retries; the job, by contrast, runs on the queue with retry, timeout, and observability that the rest of the app already relies on.
In sessions.rake, the cleanup:expired_sessions task does almost nothing itself — it calls ExpiredSessionCleanupJob.perform_later and logs. Keeping the task trivial means the cron line stays stable and all business logic lives in one testable place. The task is namespaced under cleanup so related housekeeping tasks can be grouped and discovered with rake -T cleanup.
The real work is in ExpiredSessionCleanupJob. It runs on a dedicated :maintenance queue so a flood of cleanup work never starves latency-sensitive queues. Deletion is done in bounded batches via in_batches with an explicit of: size, which caps how many rows a single DELETE touches and keeps transactions short — important on Postgres where long-running deletes hold locks and bloat the table. The job counts affected rows through delete_all, which issues a set-based SQL DELETE and skips Active Record callbacks; that is the right call for pure purging where per-row callbacks would be wasteful.
Idempotency comes for free here: the query only matches rows already past expires_at, so running the job twice simply finds nothing the second time. That safety is what makes the cron-plus-retry combination reliable — a duplicated trigger or a retried job cannot corrupt state.
The scope logic lives on the model in Session, where expired and cleanup_batch_size centralize the definition of "stale" so both the job and any admin tooling agree. The schedule.rb tab shows how the rake task is wired into cron with the whenever gem, translating a Ruby DSL into a crontab entry. A key pitfall to note: because the job deletes in batches inside its own transactions, it should be safe to interrupt mid-run, which this design guarantees since each batch commits independently.
Related snips
require 'application_system_test_case'
class TasksTest < ApplicationSystemTestCase
test 'deleting a task removes it from the list' do
task = tasks(:one)
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.