Symptom-keyed diagnoses. Each entry follows the same shape:
- Symptom — what you see.
- What it means — what the tool is telling you.
- What to do — exact commands.
- Doctor check — what
pg_hardstorage doctorwould surface.
For full incident playbooks see runbooks/. For the exit-code contract see operator-guide.md#9-output-modes.
Symptom. A backup, wal stream, or init invocation hangs and
then fails with pg.connect_failed or pg.connect_timeout.
What it means. The agent could not establish a libpq connection
to the deployment's pg_connection URL within
DefaultConnectTimeout (30 s). libpq's own default is infinite —
we explicitly cap at 30 s so a wedged TCP stack doesn't tie up the
process forever.
What to do.
pg_hardstorage doctor db1 # confirms the symptom
psql 'postgres://pgbackup@db1.example.com/postgres' -c 'SELECT 1'Common causes:
pg_hba.confdoesn't allow the agent's source IP on thereplicationvirtual database (forwal streamandbackup) or onpostgres(forinit's probe). Reload PG after editing.- A firewall or security group is dropping TCP/5432.
- The host name resolves but the listener is on a different port.
Pass an explicit
?port=...in the URL.
Override the timeout per-call by adding ?connect_timeout=N to the
URL.
Doctor check. Reachability check fails with the libpq error
text and a pg.connect_failed code.
Symptom. auth.denied (exit 3). Body carries the libpq error,
typically password authentication failed for user "pgbackup" or
no pg_hba.conf entry for replication connection from host "...".
What it means. PG returned SQLSTATE 28000
(invalid_authorization_specification) or 28P01
(invalid_password). The agent maps both to auth.denied.
What to do.
-
For
28P01: the password is wrong. Check the URL or the password store; rotate the role's password if necessary (ALTER ROLE pgbackup WITH PASSWORD '...'). -
For
28000: the role lacksREPLICATIONorpg_hba.confdoesn't allow this source IP for thereplicationdatabase. Run on PG:SELECT rolname, rolreplication FROM pg_roles WHERE rolname = 'pgbackup'; -- expect rolreplication = t
Add an entry to
pg_hba.conf:host replication pgbackup <agent-ip>/32 scram-sha-256Reload (
SELECT pg_reload_conf()).
Doctor check. Reachability check fails with auth.denied code.
Symptom. wal stream exits with wal.slot_missing.
What it means. The persistent slot
pg_hardstorage_<deployment> doesn't exist on the connected PG
node. Most common causes: an admin dropped it, you connected to a
different cluster (different system_identifier), or a Patroni
failover happened and the slot didn't get recreated on the new
leader (Strategy A wasn't configured).
What to do.
pg_hardstorage wal repair db1
# or, identical:
pg_hardstorage repair slot db1wal repair recreates the slot at PG's current LSN. If the agent's
last confirmed LSN is behind the new slot's restart_lsn, the gap
is reported as wal_gap_detected and recorded against the repo's
WAL inventory. PITR inside the gap window is then explicitly refused.
Doctor check. slot health fails with wal.slot_missing.
Symptom. A wal_gap_detected notice in wal stream logs, or a
restore returns wal.gap_in_target (exit 6) when the requested LSN
falls inside a known gap.
What it means. Some range of WAL LSNs is missing from the repo. The gap auditor records the start and end of every gap it knows about; restores intersecting that range cannot be honoured because PG would have nothing to replay.
What to do.
- For a recent gap caused by
wal repair: the gap is real WAL loss. Pick a restore target outside the gap. The gap bounds are reported in the manifest of any backup taken after the gap, and inpg_hardstorage wal list <deployment>. - For a gap caused by Patroni failover without
permanent_slots: configure Strategy A (Patronipermanent_slots, lands in v0.5; today the wizard prints the YAML to add). - For a gap caused by the agent being killed for too long: take a fresh backup. New backups are independent of WAL gaps that predate them.
Doctor check. WAL inventory lists each gap with start/end LSN
and span in seconds.
Symptom. Any command that touches the repo fails with
storage.unreachable (exit 8).
What it means. The storage backend returned a network error, DNS failure, or a 5xx with no recovery. For S3, this might be an expired credential, a missing IAM role, or a regional outage.
What to do.
pg_hardstorage repo check <repo-url> # one-shot reachability test
pg_hardstorage doctor # validates every configured deployment's repoFor S3:
- Verify the credential chain:
aws sts get-caller-identityfrom the same shell. - Inspect endpoint and
path_style: MinIO and other S3-compatible stores almost always need?endpoint=https://...&path_style=true. - Check the bucket region matches the URL's
?region=parameter.
For filesystem repos:
- Permissions on the parent directory.
- The mount point is actually mounted (tools like NFS/EFS can drop).
Doctor check. Repo reachability fails with the underlying error.
Symptom. restore or verify exits with
kms.unreachable / kms.key_missing (exit 8). The body carries the
manifest's KEKRef so you know which key the read path was looking
for.
What it means. The local keyring file referenced by the
manifest's KEKRef is missing, has wrong permissions, or has been
moved. The DEK in the manifest cannot be unwrapped without the
matching KEK.
What to do.
pg_hardstorage kms inspect # lists keyring contents, modes, mtime
ls -l ~/.config/pg_hardstorage/keyring/If the keyring file is on the wrong host, copy it back. The
kms inspect output includes a SHA-256 fingerprint of the public
signing key — use that to match the right keyring to the right
backup set.
If the key is genuinely lost: backups encrypted under it are unrecoverable. This is the crypto-shred semantic working as designed; plan key custody before you need it.
Doctor check. keystore presence reports each expected file.
Symptom. verify or repair scrub exits 9 with a body listing
the failing chunk(s).
What it means. The decrypted plaintext SHA-256 of one or more chunks does not match the value the manifest declares. Either the manifest was tampered with, the chunk on disk is corrupt, or there was an envelope-format mismatch (very rare).
What to do.
pg_hardstorage repo check <repo-url> # narrows to the failing manifest
pg_hardstorage repair chunks --missing # if chunks have actually disappeared
pg_hardstorage repair chunks --orphans # safe to delete unreferenced chunksIf the corrupt chunk is referenced by a backup that has a replica region, fetch from there. v0.5 will auto-heal from the replica; today this is manual.
If the failing manifest is the primary copy, repair from the replica:
pg_hardstorage repair manifest db1 <backup-id>Doctor check. Not surfaced; verify is its own command.
Symptom. Any destructive command (restore, rotate --apply,
repair X --apply, repo gc --apply) exits 4 without touching
state.
What it means. A pre-flight check identified a condition that makes the operation unsafe. No bytes have moved.
What to do. Read the body. Each refusal carries a Suggestion
with the exact next step. Common refusals:
- Restore target dir non-empty → use
--forceafter confirming. - Restore target dir contains live
postmaster.pid→ don't restore on top of a running PG. - Restore on a Patroni primary → pick a replica node; the suggestion tells you which.
- KMS key unreachable → see above.
- Manifest signature verification failed →
repair manifestorrepair attestationdepending on which side is broken.
Doctor check. Doctor doesn't surface these (they're per-call).
Symptom. wal stream or backup exits 4 with
preflight.patroni_split_brain.
What it means. The agent's view of "who is the leader" disagrees
with PG's view. Either Patroni's REST endpoint reports a different
leader than the connected node thinks it is, or two candidates
report role=primary. Continuing would risk writing WAL or backup
chunks tagged with the wrong timeline.
What to do. This is an operator-only resolution. See
runbooks/R7-patroni-split-brain.
Do not --force past it.
Doctor check. Patroni topology flags it as a critical finding.
Symptom. restore exits 4 with preflight.target_not_empty.
What it means. The --target directory is not empty. The
restore won't overwrite arbitrary files.
What to do.
- For a clean restore: pick an empty target directory.
- For a deliberate overwrite: pass
--force. The body lists every top-level entry that will be deleted before it proceeds. Overwriting a target that is a different cluster additionally requires--force-foreign.
--force is destructive: it removes the existing contents of the
target directory before unpacking. Use it on data dirs, not on /.
Doctor check. N/A.
Symptom. Agent killed (SIGKILL, OOM, host reboot) during a
backup. On restart, pg_hardstorage doctor reports a stale inflight
record; pg_backup_start may still be open on PG.
What it means. Idempotent design: the chunks already uploaded
are CAS-keyed and safe; the pg_backup_start slot needs releasing.
What to do. The agent reconciles state/inflight.json on
startup automatically — it issues pg_backup_stop(false) to release
server-side state and marks the manifest aborted (never committed).
You don't normally need to do anything.
If the agent isn't running and you want manual cleanup:
SELECT pg_backup_stop(false); -- on the affected PG, as a superuserThis releases the backup label without waiting for archive. The
partial manifest in the repo is never visible (it lived as .tmp
and never got renamed).
Doctor check. inflight reconciliation reports any stale record
and the action it took.
Symptom. backup exits with storage.no_space (exit 8) or the
filesystem returns ENOSPC during chunk writes.
What it means. The repository ran out of space. The pre-flight capacity check (asserts repo has at least 110% of projected backup size free) is configurable but not infallible — a parallel writer (another deployment, another tenant) can eat space between pre-flight and commit.
What to do.
pg_hardstorage repo usage <repo-url> # bytes-by-category
pg_hardstorage repo gc <repo-url> # dry-run reclaim
pg_hardstorage repo gc <repo-url> --apply # actually reclaim orphansIf GC alone doesn't free enough, run rotate --apply to tombstone
old backups, then repo gc --apply to actually delete the chunks.
Tombstoned + GC'd chunks are gone permanently; this is destructive.
Doctor check. Repository capacity reports free / projected
ratio.
Symptom. Backup throughput is below what the network and storage
can sustain. NDJSON progress events show
chunker_paused reason=backpressure stage=storage_put.
What it means. The chunker is faster than the storage backend. Backpressure is intentional — we never buffer-bomb memory to mask slow storage. The agent's adaptive concurrency starts at 4 parallel uploads and ramps based on observed RTT and error rate (TCP-style).
What to do.
- Confirm the backend bandwidth: a sequential
aws s3 cpof a large object from the same host gives the actual ceiling. - For S3 endpoints with low per-connection bandwidth (some
endpoints regional throttle), set
max_storage_concurrency: 32in the deployment config. - For bandwidth-sensitive environments, set a hard cap with
max_storage_mb_per_second: 100so the chunker doesn't burn CPU it can't ship. - Conversely, if the host is saturating egress and that's hurting user queries, lower the cap rather than letting backpressure oscillate.
Doctor check. N/A; throughput tuning is per-deployment.