Reported symptoms
Nothing is broken. That is the whole difficulty of this scenario.
A pre-audit review of rbdr-wh-01, the order warehouse, asks what protects
it. The answer is a cron entry that has run at 02:00 every night for four
years: cp -a over the live PostgreSQL data directory, then a tar of that
folder offsite. The cluster is never stopped, never quiesced, and never told
a copy is happening. Six restores have come out of it — three developer
refreshes, two storage migrations, one real incident — and all six started
and served queries. The monthly validation has recorded PASS forty-seven
times.
Two facts are worth holding onto. The orders table carries an amount
column that finance reconciles every month, and no restore has ever been
compared against that reconciliation. And pg_wal sits inside the data
directory on the same volume — a storage refresh next quarter moves WAL to a
dedicated volume, where the new platform snapshots the data volume only.
Evidence provided
The course capture reproduces this practice exactly: a live data directory,
copied with cp -a mid-workload, with no database involvement at all, then
started.
$ pg_ctl -D /work/naive-copy start waiting for server to start.... done
server started
>>> exit code: 0
2026-08-28 13:34:37.611 UTC [94] LOG: database system was interrupted; last known up at 2026-08-28 13:34:36 UTC
2026-08-28 13:34:37.613 UTC [94] LOG: database system was not properly shut down; automatic recovery in progress
2026-08-28 13:34:37.613 UTC [94] LOG: redo starts at 0/17615F8
2026-08-28 13:34:37.634 UTC [94] LOG: invalid record length at 0/256A8D8: expected at least 24, got 0
2026-08-28 13:34:37.634 UTC [94] LOG: redo done at 0/256A8B0 system usage: CPU: user: 0.01 s, system: 0.00 s, elapsed: 0.02 s
2026-08-28 13:34:37.639 UTC [88] LOG: database system is ready to accept connections
rows readable from the naive copy : 45000
rows in the live database : 45000Then the same data directory with pg_wal emptied — data on one volume, WAL
on another, and only the data volume captured. This is the arrangement
rbdr-wh-01 moves to next quarter.
$ pg_ctl -D /work/nowal start waiting for server to start.... stopped waiting
pg_ctl: could not start server
Examine the log output.
>>> exit code: 1
2026-08-28 13:34:37.879 UTC [132] LOG: creating missing WAL directory "pg_wal/archive_status"
2026-08-28 13:34:37.879 UTC [132] LOG: creating missing WAL directory "pg_wal/summaries"
2026-08-28 13:34:37.879 UTC [132] LOG: invalid checkpoint record
2026-08-28 13:34:37.879 UTC [132] PANIC: could not locate a valid checkpoint record at 0/2F20158
2026-08-28 13:34:37.941 UTC [126] LOG: startup process (PID 132) was terminated by signal 6: Aborted
2026-08-28 13:34:37.941 UTC [126] LOG: terminating any other active server processes
2026-08-28 13:34:37.942 UTC [126] LOG: shutting down due to startup process failure
2026-08-28 13:34:37.943 UTC [126] LOG: database system is shut downThe monthly validation is a shell script that starts the copy, runs one count, and compares it. It has never compared anything against the finance reconciliation.
Work the evidence before reading on
- The copy started and returned all 45000 rows. Name three things that
success depended on, none of which
cpcontrols. - Line two of the start log says the system was not properly shut down. The cluster it was copied from was running normally. What does that line describe, then?
redo done at 0/256A8B0, one record short of theinvalid record lengthat0/256A8D8. What ended redo — the end of the source’s history, or the end of what was inside the copy?- Forty-seven PASS results. Write down the one outcome that validation was capable of reporting as a failure.
- The row count from the copy and the row count from the live database are both 45000. What does that agreement establish?
Root cause
The copy was captured over an interval, not at an instant
cp -a walks the data directory for as long as the tree takes to read while
the cluster writes pages underneath it: early files are read before later
writes, late files after them, and the database is never asked to
participate. No single moment in the source’s history is what the collected
files jointly represent, so the image is not crash-consistent. Nor is it
application-consistent — nothing bracketed the walk with pg_backup_start
and pg_backup_stop, and nothing carried the WAL generated during it.
PostgreSQL documents that a plain file-system copy of a running cluster is
not a valid backup unless it has both.
The start log shows a cluster reconstructing itself. database system was not properly shut down; automatic recovery in progress is crash recovery, the
path taken after a power cut. Redo ran from 0/17615F8 and stopped at
0/256A8B0, one record short of an invalid record length: redo running out
of WAL inside the copy, not reaching the end of the source’s history.
The control compared a row count, not a business invariant
A row count is a single scalar, and a torn image reproduces it without effort — 45000 from the copy, 45000 from the live database. The numbers agreed and established nothing about whether the copy corresponded to any instant.
The validation also reads its number back out of the artefact it is validating, so it can report one thing only: that a cluster started and answered a query. It holds no independently recorded property of the business data to disagree with. Forty-seven PASS results measured whether a server would start, and a server starting was never in doubt.
Resolution
Do not remove the file copy first. It is the only protection the warehouse has. Retire it once the replacement is proven.
Enable continuous archiving as the documentation describes — wal_level,
archive_mode, archive_command — then read back that the archiver is
shipping. Read the counter and the directory together: a rising
archived_count beside an empty archive is not a recovery source.
RBDR_ARCHIVE=/srv/rbdr-wal-archive
RBDR_BASE="/srv/rbdr-basebackup/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$RBDR_ARCHIVE" "$RBDR_BASE"
psql -Atc 'SELECT archived_count, failed_count, last_archived_wal FROM pg_stat_archiver'
find "$RBDR_ARCHIVE" -maxdepth 1 -type f -printf '%f\n' | sort
pg_basebackup -D "$RBDR_BASE" -X stream -c fast
In the capture that readback was archived=6 failed=0 last=000000010000000000000005, against five WAL segments and one .backup
file present in the archive. -X stream carries the WAL generated during the
backup, so the image does not depend on the archive being complete at that
moment. Recover into an isolated instance on a spare port — never over the
running warehouse.
$ read the recovery log of the restored instance 2026-08-28 13:35:12.701 UTC [631] LOG: starting backup recovery with redo LSN 0/3000028, checkpoint LSN 0/3000080, on timeline ID 1
2026-08-28 13:35:12.707 UTC [631] LOG: restored log file "000000010000000000000003" from archive
2026-08-28 13:35:12.708 UTC [631] LOG: starting point-in-time recovery to 2026-08-28 13:34:40.077562+00
2026-08-28 13:35:12.708 UTC [631] LOG: redo starts at 0/3000028
2026-08-28 13:35:12.713 UTC [631] LOG: restored log file "000000010000000000000004" from archive
2026-08-28 13:35:12.713 UTC [631] LOG: completed backup recovery with redo LSN 0/3000028 and end LSN 0/3000120
2026-08-28 13:35:12.713 UTC [631] LOG: consistent recovery state reached at 0/3000120
2026-08-28 13:35:12.713 UTC [625] LOG: database system is ready to accept read-only connectionsCompare that opening to the file copy’s. One announces a defined starting point and reaches a consistent state; the other announces that it was not properly shut down.
Verification
Record the invariant from the source on a schedule, keep the instant it was
taken at beside it, store both outside the backup, and set the recovery
target to that instant. The capture did exactly this: it recorded 50000 rows
and sum(amount)=825025000 after the base backup and before the unqualified
DELETE, and recovered to 2026-08-28 13:34:40.077562+00.
RBDR_INV=/srv/rbdr-invariant
mkdir -p "$RBDR_INV"
psql -Atc 'SELECT now()' > "$RBDR_INV/current.at"
psql -Atc 'SELECT count(*), sum(amount) FROM orders' > "$RBDR_INV/current.txt"
# the isolated instance takes its recovery_target_time from current.at
psql -p 5434 -Atc 'SELECT count(*), sum(amount) FROM orders' > "$RBDR_INV/restored.txt"
diff "$RBDR_INV/current.txt" "$RBDR_INV/restored.txt"; echo "diff exit: $?"
diff must print nothing and report diff exit: 0. Any output at all is the
restore disagreeing with the business data, and that disagreement is the
whole point of the check.
$ count rows and sum amounts on the recovered cluster, and compare with the invariant recorded beforehand rows recovered : 50000 (expected 50000)
sum(amount) : 825025000 (expected 825025000)
RECOVERED - row count and business checksum both match the pre-DELETE stateRead both halves. The row count alone is the number that matched for the
naive copy too. Confirm the recovery log carries consistent recovery state reached and does not carry database system was not properly shut down.
Confirm the target was honoured rather than overrun: the capture’s proof is
recovery stopping before commit of transaction 836. Exercise a recovery to
a chosen point, not only to the end of the archive — recovering past a
destructive statement restores the damage. Finally, put the old file copy
through the same invariant comparison, so the retirement decision rests on
measurement.
Prevention
Back up a database with something the database knows about. A file copy of a running cluster collects an interval; a base backup collects a defined starting point plus the WAL that carries it to a consistent state.
Validate against a business invariant, not a row count, and alert on the age of the last successful comparison rather than on the exit code of the last copy.
Write down what each success depended on. Six restores depended on
pg_wal living inside the copied tree. Nobody had recorded that, so nobody
connected the storage refresh to the backup at all — which is why a WAL
directory moving to separate storage has to be treated as a change to the
backup. Capturing the data volume without its WAL is what produced the PANIC.
Rehearse a recovery to a point in the past, on a schedule. Recovering to
the end of the archive answers a hardware question. Recovering to a chosen
moment answers the question an unqualified DELETE actually asks.