0The incident
The nightly job recomputes recommendations. Someone "optimized" it to TRUNCATE the table and repopulate — but the repopulate query errored halfway, and because it wasn't wrapped in a transaction, the table was left empty. Worse: the TRUNCATE replicated to every standby in milliseconds, so there was no live copy to fail over to. This is a logical disaster — the kind only a point-in-time backup can undo.
⚠️ Replicas don't save you from a bad script — backups do
RecoEngine had Multi-AZ and read replicas — great for a server dying, useless here. A TRUNCATE or bad UPDATE is a valid command; replication copies it faithfully and instantly to every standby. The only copy that exists at a different point in time — before the script ran — is a backup. That's why continuous backups (base backup + WAL, giving point-in-time recovery) are the backbone of real safety, and replicas are for availability.
-- ❌ what the "optimized" job did — TRUNCATE + repopulate, NOT in a transaction TRUNCATE recommendations; -- table now empty INSERT INTO recommendations SELECT ...; -- errored partway → 0 rows inserted -- result: recommendations is EMPTY in prod, and every replica copied the TRUNCATE -- there is no live copy to fail over to; this needs point-in-time recovery.
1Assess — scope it and find the clean point
Recovery starts with two questions: exactly what is damaged, and exactly when did it start? For a single corrupted table with continuous backups, the answers point to a clean, targeted restore rather than a dramatic failover.
| Question | Answer at RecoEngine | Why it matters |
|---|---|---|
| What's damaged? | One table (recommendations), ~30k rows, emptied | Scoped blast radius → a targeted restore, not a full DB failover |
| When did it start? | 03:00:00 (the cron start; confirmed in logs) | The PITR target is the second before — 02:59:59 |
| Is anything else affected? | No — orders, users, products untouched | Don't roll back the whole DB and lose legitimate writes elsewhere |
| Recoverable? | Yes — continuous backups cover 02:59:59 | PITR window must span the target; retention is what makes this possible |
recommendations table out of it, and puts it back. Scope the recovery to the damage.2The recovery — step by step
AStop the bleeding▶
Goal: make sure the job can't run again and re-corrupt whatever you restore.
- Disable the batch job — pause the cron/scheduler so it can't fire again mid-recovery.
- Serve a safe fallback — have the app show popular/default recommendations (or hide the panel) while you recover, so users see something sensible.
- Pin the exact time the script started from logs — this is your PITR target minus one second.
BRestore a COPY to just before the script▶
Goal: get a clean copy of the data at 02:59:59 — without touching production.
- Point-in-time restore to a NEW instance at 02:59:59. Production keeps running (it has good data everywhere except the one table).
- Wait for it to come up and confirm it's a writer/readable, at the right timestamp.
$ aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier reco-prod \
--target-db-instance-identifier reco-restore \
--restore-time 2026-07-15T02:59:59Z
# self-managed equivalent: restore base backup + set
# recovery_target_time = '2026-07-15 02:59:59+00' then replay WAL to itCValidate the restored table▶
Goal: prove the restored copy is actually clean before you touch prod.
- Row count sane? ~30,000 rows, not 0 and not garbage.
- Spot-check content — scores in range, no NULLs where there shouldn't be, a few known users have sensible recs.
- Confirm the timestamp — the newest row predates 03:00, i.e. you landed before the script.
$ psql -h reco-restore... -c "SELECT count(*) FROM recommendations;" # ~30000? $ psql -h reco-restore... -c "SELECT min(score), max(score) FROM recommendations;" $ psql -h reco-restore... -c "SELECT max(created_at) FROM recommendations;" # < 03:00?
DLift the clean table back into production▶
Goal: put the good recommendations back with minimal disruption and a rollback path.
- Copy just the table from the restored copy into prod, loading into a new table first (don't overwrite in place).
- Swap atomically — rename the (empty) live table aside and the restored one into place in a single transaction, so reads never see a half-loaded table.
- Keep the old one briefly for rollback, then re-enable the (fixed) batch job.
# pull ONLY the recommendations table from the restored copy into prod (staging name)
$ pg_dump -h reco-restore... -t recommendations --data-only reco \
| psql -h reco-prod... -c "\\copy recommendations_restored FROM STDIN" reco
-- atomic swap in prod: reads never see an empty/half table BEGIN; ALTER TABLE recommendations RENAME TO recommendations_empty; ALTER TABLE recommendations_restored RENAME TO recommendations; COMMIT; -- verify, then DROP recommendations_empty later; re-enable the fixed job.
3The recovery, visualized
The corruption and the targeted restore, as an ASCII timeline, an entity diagram of the recommendation model, and a sequence diagram of the whole recovery.
continuous backup (base backup + WAL)
... 02:00 ───────────────────────► 02:59:59 │ 03:00:00 BAD SCRIPT truncates
▲
recovery_target_time = 02:59:59 ← restore a COPY here
prod keeps its good data everywhere else (orders, users) since 03:00 —
so lift ONLY the recommendations table out of the copy and swap it back.
one corrupted table → targeted restore, NOT a full-database rollback.4Prevention & scorecard
The recovery was clean because backups were continuous and the corruption was scoped. But the incident shouldn't have been possible — the batch job design was the real bug.
-- ✅ never TRUNCATE-in-place; build the new data alongside the old, then swap CREATE TABLE recommendations_new (LIKE recommendations INCLUDING ALL); INSERT INTO recommendations_new SELECT ... ; -- fill the NEW table -- validate BEFORE swapping: is the count sane? any NULLs? scores in range? BEGIN; ALTER TABLE recommendations RENAME TO recommendations_old; ALTER TABLE recommendations_new RENAME TO recommendations; COMMIT; -- atomic; old kept for rollback -- the live table is never empty for even a moment, and a bad run can't wipe it.
TRUNCATE in place.