Verifying Backup Integrity Across Shards
Eight shards means eight backup configurations, eight archive destinations and eight opportunities for one of them to have quietly stopped working. This guide builds fleet-wide verification: freshness and continuity checks that run continuously, restore drills on a rotation, and one view that answers “which shards can we actually recover?”. It operationalises the verification requirement from Backup & Restore for Partitioned Databases, part of Shard Migration & Rebalancing Operations.
Prerequisites
Step 1 — Check freshness and continuity on every shard, continuously
Two questions per shard, answered from the tool’s own metadata:
#!/usr/bin/env bash
# backup_status.sh — one line of metrics per shard, scraped every 5 minutes
for stanza in shard0 shard1 shard2 shard3 shard4 shard5 shard6 shard7; do
json=$(pgbackrest --stanza="$stanza" info --output=json)
last_stop=$(jq -r '.[0].backup[-1].timestamp.stop' <<<"$json")
wal_min=$(jq -r '.[0].archive[-1].min' <<<"$json")
wal_max=$(jq -r '.[0].archive[-1].max' <<<"$json")
age=$(( $(date +%s) - last_stop ))
echo "backup_age_seconds{shard=\"$stanza\"} $age"
echo "backup_wal_present{shard=\"$stanza\",min=\"$wal_min\",max=\"$wal_max\"} 1"
done
# a backup older than 36 hours means the schedule is broken somewhere
backup_age_seconds > 129600
# archiving stopped while the database kept running — the backup is already invalid
pg_stat_archiver_last_failed_time > pg_stat_archiver_last_archived_time
Operational note: Freshness on its own is misleading. A base backup taken an hour ago is worthless if WAL archiving broke two days ago, because recovery to any point after the break is impossible.
DBA tip: Record the WAL range covered by the archive alongside the backup age. The pair — recent base backup plus continuous WAL up to now — is what “recoverable” actually means.
Step 2 — Scan the WAL archive for gaps directly
Trusting the tool’s summary is not the same as checking the objects:
#!/usr/bin/env bash
# wal_gap_scan.sh — list archived segments and find discontinuities
STANZA=shard3
aws s3 ls "s3://backups/${STANZA}/archive/" --recursive \
| awk '{print $4}' | grep -oE '[0-9A-F]{24}' | sort -u > /tmp/wal_present.txt
python3 - <<'PY'
segs = [l.strip() for l in open('/tmp/wal_present.txt') if l.strip()]
def n(s): # timeline is the first 8 hex chars; the rest is the segment number
return int(s[8:], 16)
gaps = [(a, b) for a, b in zip(segs, segs[1:]) if n(b) != n(a) + 1]
print(f"{len(segs)} segments, {len(gaps)} gap(s)")
for a, b in gaps[:10]:
print(f" gap after {a} → next present {b}")
PY
1284391 segments, 1 gap(s)
gap after 0000000100000A2B000000C4 → next present 0000000100000A2B000000C9
Operational note: Run this weekly, not continuously — it lists every object in the archive, which is slow and costs money on some object stores. The continuous check is the archiver metric; this is the audit.
SRE tip: A gap found here is not repairable. The correct response is to take a fresh base backup immediately, which re-establishes a recoverable window from that moment, and to treat the period before it as lost.
Step 3 — Drill one shard on a rotation
The drill is the only check that proves recoverability end to end:
# .github/workflows/restore-drill.yml — one shard per week, rotating
name: restore-drill
on:
schedule:
- cron: '0 3 * * 1'
jobs:
drill:
runs-on: [self-hosted, drill-host]
steps:
- name: pick the shard whose drill is oldest
id: pick
run: echo "stanza=$(python3 scripts/oldest_drill.py)" >> "$GITHUB_OUTPUT"
- name: restore to 24 hours ago
run: |
pgbackrest --stanza=${{ steps.pick.outputs.stanza }} \
--type=time --target="$(date -u -d '24 hours ago' '+%Y-%m-%d %H:%M:%S+00')" \
--target-action=promote --pg1-path=/drill/pgdata restore
pg_ctl -D /drill/pgdata start
- name: assert the instance is usable
run: python3 scripts/drill_assertions.py --stanza ${{ steps.pick.outputs.stanza }}
- name: record the result
run: python3 scripts/record_drill.py --stanza ${{ steps.pick.outputs.stanza }} --status "$?"
# scripts/drill_assertions.py — what a drill must actually prove
def assertions(conn, expected):
assert conn.query_one("SELECT pg_is_in_recovery()") is False, "still in recovery"
assert conn.query_one("SELECT count(*) FROM pg_tables") > 40, "schema looks empty"
parts = conn.query_one("""SELECT count(*) FROM pg_inherits
WHERE inhparent = 'events'::regclass""")
assert parts >= expected["min_partitions"], f"only {parts} partitions"
newest = conn.query_one("SELECT max(occurred_at) FROM events")
assert newest > expected["target"] - timedelta(hours=1), "data is older than the target"
run_readonly_smoke_tests(conn) # the application's own checks
Operational note: Pick the shard with the oldest successful drill rather than a fixed order. It self-corrects when a drill is skipped or fails.
SRE tip: Run the drill against a target 24 hours in the past rather than “latest”. It exercises WAL replay, which the latest-only path barely touches.
Step 4 — Publish one recoverability metric per shard
Reduce all of it to a number an on-call engineer can read at a glance:
# a shard is recoverable when all three hold
backup_recoverable{shard="shard3"} =
(backup_age_seconds{shard="shard3"} < 129600)
* (wal_gap_count{shard="shard3"} == 0)
* (drill_age_days{shard="shard3"} < 90)
# alert when any shard falls out of the recoverable set
min by (shard) (backup_recoverable) == 0
Operational note: Compose it from the three inputs rather than replacing them. During an incident the composite tells you there is a problem; the components tell you which one.
DBA tip: Put this metric on the same dashboard as replication lag and disk. Recoverability is a property people only look for after they need it unless it is somewhere they already look.
Verification
Test the verification itself by breaking something deliberately on a non-production shard:
# staging: point the archive command at an unwritable location
psql -c "ALTER SYSTEM SET archive_command = 'false'"
psql -c "SELECT pg_reload_conf()"
psql -c "SELECT pg_switch_wal()"
SELECT failed_count, last_failed_wal, last_failed_time FROM pg_stat_archiver;
failed_count | last_failed_wal | last_failed_time
--------------+--------------------------+-------------------------------
14 | 0000000100000A2C00000101 | 2026-08-03 10:22:41.118+00
The alert must fire within its evaluation window, the recoverability metric for that shard must drop to zero, and restoring the correct archive_command must clear both. A verification pipeline that does not fail when the thing it watches is broken is not verifying anything.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Every check green, recovery still impossible | checks watched backup freshness only, not WAL continuity | compose recoverability from freshness, gap count and drill age; alert on the composite |
| Drills always pass, real recovery fails | the drill restored to “latest” and never exercised WAL replay, or always used the same shard | target a point 24 hours in the past; rotate shards by oldest drill age |
| Drill host unavailable when needed | the drill infrastructure was only provisioned for the scheduled job | keep the drill host in the standard fleet with capacity for the largest shard; the drill and the real recovery use the same path |
FAQ
Is a backup tool's own check enough?
It verifies that the stored files match their recorded checksums, which catches corruption in the object store and nothing else. It does not prove that the database can start from them, that the WAL chain is unbroken to now, or that the schema restores usably. Those are only demonstrated by an actual restore, which is why a rotation of real restore drills is the check that matters.
How often should each shard be drilled?
Rotate so that every shard is fully restored at least once a quarter, which for an eight-shard fleet means roughly one drill a week. Rotation matters more than frequency: drilling the same shard weekly proves one shard works and tells you nothing about the other seven, which are usually the ones with the drifted configuration.
What should the drill actually assert?
That the instance starts, that recovery reaches the requested target, that the schema matches production’s, that row counts for a sample of partitions are within an expected range, and that the application’s read-only smoke tests pass against it. A drill that only asserts the process exited zero will pass against an empty database.
Related
- Backup & Restore for Partitioned Databases — the parent topic and the two backup layers this verifies
- Point-in-Time Recovery for a Single Shard — the recovery path the drills exercise
- Restoring a Sharded Database into a Staging Environment — turning a drill restore into a usable environment