Running Backfills as Queued Background Jobs

The deploy pipeline has a 30-minute timeout, and the migration that adds and fills invoices.tax_region needed four hours for the fill. The pipeline killed it at minute 30; the migration tool recorded it as failed; the next deploy was blocked until someone repaired the state by hand. Schema changes and data changes have very different shapes: DDL should take seconds and run once, as a gated deploy step; a backfill of hundreds of millions of rows takes hours, should be throttled against production load, needs to survive restarts, and has no business holding up a deploy. The fix is structural — the migration adds the column and enqueues work; background workers do the work. This guide shows how to split a backfill into queued chunks, pace it, track it to completion, and connect it to the next schema step. It belongs to Backfill Optimization.

Migration Enqueues, Workers Backfill The schema migration adds the nullable column and enqueues a coordinator job. The coordinator splits the id range into chunks and enqueues one job per chunk. A pool of workers limited to a small concurrency processes chunks in short batches, checking replica lag. A progress table records completed chunks; when all are done, the next release tightens the column. Migration Enqueues, Workers Backfill Migration ADD COLUMN; enqueue coordinator Coordinator job split id range into chunks Queue N chunk jobs Workers (max 4) batches, lag-aware Progress table chunks done / total Next release tighten once complete enqueue record gate
The deploy only adds a column and enqueues one job; everything slow happens in workers with their own concurrency and pacing.

Symptom / Error Signatures

Backfills embedded in migrations cause:

  • Deploy pipelines timing out on the migration step, and migration tools left in a failed or dirty state (Dirty database version, P3009, a failed Flyway entry).
  • Long transactions from the migration holding locks or preventing vacuum, visible as an old xact_start in pg_stat_activity.
  • No way to pause the backfill when production load rises without killing the deploy.
  • A single thread doing all the work, so the backfill is slower than the database could safely sustain.

Root Cause Analysis

Migration runners are built for short, ordered, transactional steps executed once per deploy. A large backfill violates every one of those assumptions: it is long, it benefits from limited parallelism, it must be paced against live traffic, it must be restartable, and its completion is a precondition for later schema steps rather than part of the current one. Job queues — Sidekiq, Celery, BullMQ, Oban, a database-backed queue — are built for exactly those properties: retries, concurrency limits, scheduling and visibility.

The shape that works is a coordinator plus chunks. The coordinator divides the key space into chunks (for example 100,000 ids each), enqueues one job per chunk, and records the plan. Each chunk job processes its range in small batches, idempotently, and records completion. Concurrency is capped at the queue level. Pacing uses a shared signal — replica lag or a pause flag — checked before each batch.

Concern In a migration As background jobs
Deploy duration backfill time seconds
Failure blocks deploy, dirty state one chunk retries
Parallelism none bounded by worker concurrency
Pause / resume kill the deploy pause the queue or set a flag
Completion signal migration success progress table: chunks done = total
Deploy vs Backfill Time Stacked bars comparing total deploy time. Backfill inside the migration: migration DDL 0.1 minutes plus backfill 240 minutes inside the deploy. Backfill as jobs: 0.1 minutes of DDL in the deploy, backfill 90 minutes afterwards across 4 workers. Deploy vs Backfill Time in migration 240 min as jobs 90 min deploy-time DDL backfill inside deploy backfill after deploy
Queued jobs take the backfill out of the deploy entirely, and bounded parallelism shortens it too.

Immediate Mitigation

If a migration-embedded backfill has failed or is blocking deploys:

1. Repair the migration state so the schema part is recorded correctly — the column exists — using your tool’s repair command (migrate force, prisma migrate resolve, flyway repair). The schema change itself was short and committed or rolled back cleanly; only the data part is incomplete.

2. Replace the backfill with a queued job. The migration keeps only the DDL and, optionally, enqueues the coordinator. A progress table makes the job observable:

-- PostgreSQL · migration role · tracks chunk completion for the backfill
CREATE TABLE IF NOT EXISTS backfill_chunks (
  job      text   NOT NULL,
  lo       bigint NOT NULL,
  hi       bigint NOT NULL,
  done_at  timestamptz,
  PRIMARY KEY (job, lo)
);
-- ROLLBACK PATH: DROP TABLE backfill_chunks;  (after the job is complete)

3. Enqueue chunks from the coordinator.

# Python · Celery coordinator task · runs once, enqueues one task per id range
# WARNING: route chunk tasks to a dedicated queue whose worker concurrency is capped (e.g. 4).
@app.task
def plan_tax_region_backfill(chunk=100_000):
    with db.cursor() as cur:
        cur.execute("SELECT min(id), max(id) FROM invoices")
        lo, hi = cur.fetchone()
        for start in range(lo, hi + 1, chunk):
            cur.execute("INSERT INTO backfill_chunks (job, lo, hi) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
                        ("invoices_tax_region", start, start + chunk - 1))
            backfill_tax_region_chunk.apply_async(args=[start, start + chunk - 1], queue="backfill")
    db.commit()

4. Process each chunk in small, paced, idempotent batches.

# Python · Celery chunk task · batches of 2,000 with a lag check before each
@app.task(bind=True, max_retries=10, default_retry_delay=30, acks_late=True)
def backfill_tax_region_chunk(self, lo, hi, batch=2000):
    for start in range(lo, hi + 1, batch):
        wait_until_replica_lag_below(seconds=2)
        with db.cursor() as cur:
            cur.execute("""UPDATE invoices SET tax_region = compute_tax_region(country, postcode)
                           WHERE id BETWEEN %s AND %s AND tax_region IS NULL""",
                        (start, min(start + batch - 1, hi)))
        db.commit()
    with db.cursor() as cur:
        cur.execute("UPDATE backfill_chunks SET done_at = now() WHERE job = %s AND lo = %s",
                    ("invoices_tax_region", lo))
    db.commit()

Permanent Fix / Long-Term Pattern

Adopt a team rule: migrations change schema; jobs change data. Any data change touching more than a few thousand rows ships as a background job, with the migration adding structure and the next release — gated on the job’s completion — adding constraints or removing old structure. Give backfills their own queue with capped concurrency, so they never starve user-facing jobs, and a global pause switch (a feature flag or a row in a control table) that every chunk checks. The pacing logic is covered in throttling backfills to protect OLTP latency.

Make completion a gate, not a guess. The release that tightens the column checks SELECT count(*) FILTER (WHERE done_at IS NULL) FROM backfill_chunks WHERE job = ... equals zero and a final remaining-rows count is zero. Framework-specific versions of the same pattern appear in backfilling data in Rails without locking, and resumability within a single worker in resuming an interrupted backfill from a checkpoint.

From Column to Constraint, Gated on the Job Pipeline across releases. Release 1 adds the nullable column and enqueues the coordinator. Chunk jobs run in the background. A gate checks all chunks are done; a second gate checks zero remaining NULLs; then release 2 adds and validates the constraint. From Column to Constraint, Gated on the Job Release 1 ADD COLUMN + enqueue Chunk jobs capped, paced chunks all done? nulls remaining = 0? Release 2 validate + NOT NULL wait / retry failed rerun chunks fail
The backfill's completion is checked by queries, not assumed from elapsed time.

Verification Checklist

Frequently Asked Questions

Why not just raise the deploy timeout? Because the problem is not only time. A backfill inside a migration cannot be paused, parallelised, or retried piecemeal, holds a long transaction in many tools, and blocks every other deploy while it runs.

How many workers should process chunks? Few — often two to four. The limit is the database’s spare capacity and replica apply rate, not the queue’s throughput. Start low, watch lag and latency, and increase cautiously.

What if a chunk job fails permanently? Its row in the progress table stays incomplete, so the completion gate does not pass. Inspect the error, fix the cause (often bad data in a few rows), and re-enqueue that chunk; idempotent batches make reruns safe.

Should the migration enqueue the job automatically? It can, but some teams prefer an explicit operator step after the deploy, so the backfill starts at a chosen time. Either way, the job must be safe to enqueue twice.