class AddSoftDeleteToDocuments < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
add_column :documents, :marked_for_deletion_at, :datetime, null: true
add_index :documents,
:marked_for_deletion_at,
where: "marked_for_deletion_at IS NOT NULL",
name: "index_documents_pending_deletion",
algorithm: :concurrently
end
end
class Document < ApplicationRecord
RETENTION = 90.days
GRACE_PERIOD = 7.days
scope :stale, ->(now = Time.current) do
where(marked_for_deletion_at: nil)
.where("updated_at < ?", now - RETENTION)
end
scope :marked_before, ->(cutoff) do
where.not(marked_for_deletion_at: nil)
.where("marked_for_deletion_at <= ?", cutoff)
end
scope :pending_deletion, -> { where.not(marked_for_deletion_at: nil) }
def soft_delete!
update!(marked_for_deletion_at: Time.current)
end
def restore!
update!(marked_for_deletion_at: nil)
end
def pending_deletion?
marked_for_deletion_at.present?
end
end
class MarkStaleDocumentsJob
include Sidekiq::Job
sidekiq_options queue: :maintenance, retry: 3
BATCH_SIZE = 1_000
def perform
now = Time.current
total = 0
loop do
scope = Document.stale(now).limit(BATCH_SIZE)
marked = Document.where(id: scope.select(:id))
.update_all(marked_for_deletion_at: now)
total += marked
break if marked.zero?
end
Rails.logger.info("[mark] flagged #{total} documents at #{now.iso8601}")
SweepMarkedDocumentsJob.perform_in(Document::GRACE_PERIOD) if total.positive?
end
end
class SweepMarkedDocumentsJob
include Sidekiq::Job
sidekiq_options queue: :maintenance, retry: 5
MAX_PER_RUN = 5_000
def perform
cutoff = Time.current - Document::GRACE_PERIOD
swept = 0
Document.marked_before(cutoff).find_in_batches(batch_size: 500) do |batch|
batch.each do |doc|
doc.destroy!
swept += 1
return log_and_reschedule(swept) if swept >= MAX_PER_RUN
end
end
Rails.logger.info("[sweep] destroyed #{swept} documents")
end
private
def log_and_reschedule(swept)
Rails.logger.warn("[sweep] hit cap of #{swept}, rescheduling")
self.class.perform_in(5.minutes)
end
end
Deleting rows on a schedule is deceptively dangerous: a single overly broad WHERE clause, a replication lag issue, or a bug in the cutoff calculation can wipe out live data with no undo. The safer industry pattern is "mark then sweep" — a two-phase delete that separates the decision of what to remove from the act of physically removing it, with a grace window in between so mistakes can be caught and reversed before anything is gone for good.
In add_soft_delete_to_documents migration, each candidate row gets a nullable marked_for_deletion_at timestamp plus a partial index. The partial index only covers rows where marked_for_deletion_at IS NOT NULL, so it stays tiny — the sweep query scans a fraction of the table instead of the whole thing, and the common case (nothing marked) costs nothing.
Document model encodes the two phases as explicit scopes. stale finds records older than the retention window that are not yet marked; marked_before finds already-marked rows whose grace period has elapsed. Splitting these keeps the marking pass and the sweeping pass reading from disjoint sets, which makes each job idempotent — re-running the marker never re-marks, and re-running the sweeper never touches un-graced rows. soft_delete! and restore! are the manual escape hatches an operator uses during the grace window.
MarkStaleDocumentsJob is the mark phase. It calls update_all in batches, setting marked_for_deletion_at without instantiating models or firing callbacks, which is what makes it fast and safe to run frequently. Crucially it marks but never destroys, so the blast radius of a bad stale_before value is zero — marked rows are still fully readable and restorable.
SweepMarkedDocumentsJob is the sweep phase, deliberately gated behind GRACE_PERIOD. It walks marked rows in find_in_batches and destroys each so dependent-record callbacks and audit hooks run properly. The MAX_PER_RUN cap bounds how much a single run can delete, protecting the database from a runaway purge and giving replicas time to keep up.
The trade-off is added storage and a delay before space is reclaimed, but in exchange deletes become observable, cancelable, and recoverable — the right default whenever data loss is expensive.
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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
Share this code
Here's the card — post it anywhere.