ruby 67 lines · 3 tabs

Database-Backed “Run Once” Migrations for Maintenance Tasks

Shared by codesnips Jan 2026
3 tabs
class CreateMaintenanceTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :maintenance_tasks do |t|
      t.string :name, null: false
      t.string :state, null: false, default: "pending"
      t.datetime :started_at
      t.datetime :finished_at
      t.text :error_message
      t.timestamps
    end

    add_index :maintenance_tasks, :name, unique: true
  end
end
3 files · ruby Explain with highlit

Schema migrations are great for structural changes, but heavy data backfills do not belong inline in a db:migrate run — they lock deploys, time out, and are hard to retry. This snippet shows the common alternative: durable, database-backed maintenance tasks that are guaranteed to run exactly once, tracked in their own table rather than in the schema migration history.

The create_maintenance_tasks migration tab defines the tracking table. Each task has a unique name, a state, and timestamps for started_at/finished_at. The unique index on name is the backbone of idempotency: even under a race between two app servers booting simultaneously, the database refuses a second row with the same name, so a task can only be claimed once.

The MaintenanceTask model tab wraps that table with the claim logic. MaintenanceTask.run_once is the public entry point: it first checks whether a finished record exists and returns early if so, making the whole thing a no-op on subsequent deploys. To coordinate concurrent processes it takes a Postgres advisory lock via with_advisory_lock, keyed on a hash of the task name. Advisory locks are session-scoped and cheap, and they let the code serialize the critical section without locking any real table rows. Inside the lock it re-checks completion (the double-check guards against a task that finished while waiting on the lock), then transitions the record through running to finished, capturing error_message and leaving the state as failed if the block raises so a later run can retry.

The BackfillUserSlugsTask job tab is a realistic consumer: an ActiveJob that calls run_once('backfill_user_slugs') and does the actual work in batches with find_each to keep memory flat. Enqueuing this job from a deploy hook or a rake task means the backfill happens asynchronously, retries safely, and never blocks a migration.

The trade-off is that these tasks live outside schema.rb, so they must be cleaned up manually once fully rolled out. The pattern shines when a backfill is large, slow, or must survive partial failures — situations where an inline migration would be fragile.


Related snips

Share this code

Here's the card — post it anywhere.

Database-Backed “Run Once” Migrations for Maintenance Tasks — share card
Link copied