ruby 92 lines · 4 tabs

Safer Time-Based Deletes with “mark then sweep”

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Safer Time-Based Deletes with “mark then sweep” — share card
Link copied