BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 102 — The Backfill That Blocked Prod

Theme: Migrations & Releases · Format: 20-min drill · Difficulty: core

The story

A payments platform ships a small feature: a new risk_tier column on the ledger_entries table, derived from each entry's amount and merchant category. Adding the nullable column is instant — a metadata-only change, reviewed and merged without drama. The only thing left is to fill in the value for the rows that already exist. An engineer writes a one-off script: a single UPDATE ledger_entries SET risk_tier = compute(...) WHERE risk_tier IS NULL. It runs in a blink against the ten-thousand-row staging database. She opens a ticket, gets a thumbs-up, and at 2:40 on a Tuesday afternoon runs it against production.

Production has six hundred million rows. The UPDATE opens one transaction and starts rewriting them all. For the first minute nothing looks wrong — the job is "just running," the database process is healthy, CPU is busy but not pegged, and the deploy dashboard is green. Under the surface the single transaction is taking row locks it never releases, the write-ahead log is filling faster than disk can flush it, and I/O is saturating. Live INSERTs from the checkout path — every card tap, every settlement — begin queuing behind the backfill's locks and the starved disk.

By 2:44 the symptoms surface where nobody is looking for a backfill: card taps time out, the checkout service's connection pool exhausts waiting on writes, the read replica falls minutes behind, and the mobile app shows spinners that never resolve. The on-call for payments is paged for an availability incident and starts looking at the checkout service, the load balancer, the card processor — everywhere except a data-fix ticket that said "quick backfill, low risk." The product is down, and the cause is a five-minute data change nobody thought of as a deploy.

⏸ Stop here and ask the room: the database never crashed and every health check is green. So what took the product down, and why is the on-call looking in the wrong place?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

The exact same statement ran instantly in staging. Why does it take production down?

The database process never died and the dashboards were green. So what should have alarmed?

How do you run this backfill so it cannot take prod down?

The reveal

The one-line UPDATE was the outage. Rolling it back was worse than letting it finish: killing the transaction triggered a rollback that had to undo everything it had done, extending the incident. Total customer-facing downtime was nineteen minutes. The fix was not "run the same statement more carefully" — it was to delete the one-shot script and replace it with a batched backfill job: five thousand rows per transaction, a two-hundred-millisecond pause between batches, values read from a replica, a progress table with a resume cursor, and an auto-pause wired to replica lag and write latency. Re-run in the overnight trough, it populated all six hundred million rows over a few hours with zero measurable impact on checkout.

Why this happens — the mechanics

A backfill is a write the size of the whole table, and every property that makes a single big UPDATE convenient is exactly what makes it dangerous at scale. One transaction means one atomic unit: the locks it acquires are held until commit, so a run that lasts minutes blocks conflicting live writes for minutes; the undo/redo it generates (write-ahead log, rollback segments) grows with the row count, so disk I/O and replication lag scale with the table, not with the "five minutes" the author estimated from staging. Staging lies precisely because it is small — it validates the syntax of the statement, never its mechanics under production volume and concurrent load. The migration checklist calls this out directly: staging with ten thousand rows validates syntax, not mechanics.

The reason it stays invisible until customers feel it is a monitoring seam. Availability dashboards and health checks are built to catch liveness failures — the process died, the port stopped answering, the box fell over. A backfill causes none of those. The database is up, serving reads, answering health checks; it is simply refusing to make progress on the write path because a giant transaction is sitting on the locks and the disk. The signals that would reveal it — lock-wait time, longest-transaction age, write p99, replica lag — live one layer below the green lights, and nobody wired them to a page because "the database is fine" was true in the only sense the alarm understood.

Underneath both is a category error that this theme keeps punishing: treating a data change as a quick fix instead of as a migration. A migration gets a rehearsal on production-sized data, a rollback plan, a run window, and a kill switch. A "quick backfill" gets a thumbs-up on a ticket. The instant a data change touches every row of a hot table, it is a migration whether or not anyone called it one — and the moment of maximum risk is a mid-afternoon UPDATE that "should only take a minute."

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant E as Engineer
    participant DB as Ledger DB
    participant API as Payment API
    participant U as Cardholders
    E->>DB: Run one-shot backfill<br/>UPDATE, unthrottled
    Note over DB: One txn locks 600M rows<br/>WAL and disk IO saturate
    API--xDB: Live writes queue<br/>behind the locks
    U--xAPI: Card taps time out<br/>checkout fails
    Note over DB: Process still up<br/>health checks stay green
    Note over E,U: Found by failing payments<br/>not by the job

Solution — where the guardrails go:

flowchart TD
    A["New column added<br/>nullable, no default"] --> B["Backfill in small batches<br/>(chunk + throttle)"]
    B --> C["Run off-peak<br/>(traffic-trough window)"]
    C --> D["Compute values from<br/>a read replica"]
    D --> E["Progress table +<br/>kill switch (resumable)"]
    E -.-> AL["Replica lag over 5s or<br/>write p99 over 200ms<br/>auto-pauses the job"]
    AL --> F["Batches resume when<br/>the signals recover"]

What good looks like

The lesson

Any write that touches every row of a hot table is a migration, not a quick fix — so it gets the migration treatment: chunk it into throttled batches with a bounded lock-hold, run it off-peak, read from a replica, and give it a kill switch tied to live-traffic signals. A green health check proves the database is alive, not that it is making progress; alarm on contention and saturation, not just liveness.

Named concepts: online / chunked backfill, throttling & off-peak scheduling, replica offload, kill switch + progress monitoring.

Real-world echoes

Transfer

Which one-off script or data fix in your stack would take a table lock or saturate I/O if someone ran it against production at 2 p.m. today? "It's just an update" is the tell, not the reassurance — name the batch size, the run window, and the signal that would pause it.

Make it a ticket: inventory every backfill/data-fix path that can touch a large hot table; for each, convert it to a chunked+throttled job with an off-peak window, a replica read, and an auto-pause on replica lag > 5 s or write p99 > 200 ms — starting with anything on a table the payment path writes to.

Related