Reported symptoms
A planned failover rehearsal at 20:00 promotes db-standby-02. The
application immediately reports missing data from the preceding six
weeks.
The promotion itself worked perfectly — under a second, and the standby became writable normally.
The replication lag dashboard has shown a flat zero for the entire six weeks, and shows zero at the moment of the rehearsal. Nobody has been paged about replication since the standby was built eight months ago.
The standby has been serving read-only reporting traffic throughout. Those reports have not obviously been wrong, because they aggregate over months.
The rehearsal is aborted, the standby is taken out of service, and the estate now has no failover target.
The primary is entirely healthy and has been throughout.
Evidence provided
$ psql -c "SELECT * FROM pg_stat_replication;" pid | usesysid | usename | application_name | client_addr | state | sent_lsn | write_lsn | flush_lsn | replay_lsn
-----+----------+---------+------------------+-------------+-------+----------+-----------+-----------+------------
(0 rows)Illustrative output
$ grep FATAL /var/log/postgresql/postgresql-18-main.log | tail -22026-08-28 19:58:41.204 UTC [1188] FATAL: could not connect to the primary server: connection to server at "10.12.4.7", port 5432 failed: Connection timed out
2026-08-28 19:58:46.209 UTC [1189] FATAL: could not connect to the primary server: connection to server at "10.12.4.7", port 5432 failed: Connection timed outIllustrative output
The first such line is dated 11 July at 23:14.
primary_conninfo names 10.12.4.7. The primary has answered on
10.12.9.7 since a network renumbering completed on 11 July. Its DNS
name resolves correctly.
The dashboard graphs a metric derived from now() - pg_last_xact_replay_timestamp(), collected from the primary’s
pg_stat_replication. With no row present, the collector returns NULL.
The monitoring system renders NULL as 0.
On the standby, pg_last_wal_replay_lsn() equals
pg_last_wal_receive_lsn() — it is internally consistent and simply
stopped six weeks ago.
Work the evidence before reading on
- The lag graph showed zero for six weeks. What was it actually measuring?
pg_stat_wal_receiveron the standby is empty. What does that distinguish from a stalled receiver?- The standby served read queries throughout without anybody noticing. What does that tell you about those reports?
- If the monitoring had graphed the metric correctly, would it have caught this?
Root cause
The standby behaved correctly and told the truth
It retried every five seconds, logged a FATAL each time, served
read-only queries from the data it had, and reported its own state
accurately. It never claimed to be current.
primary_conninfo named a literal address that stopped existing on 11
July. The renumbering completed successfully and nothing asked what held
a hard-coded address.
The metric could not express the condition
The reports were not a safety net
Six weeks of missing recent data did not move numbers that aggregate over months. That is worth recording as its own finding: those reports could not have detected a six-week data gap, which limits what they can be trusted to tell anybody.
Resolution
Establish the standby’s real position:
-- on the standby
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp();
SELECT * FROM pg_stat_wal_receiver;
An empty pg_stat_wal_receiver means the receiver process is not
running — “cannot connect” — as distinct from a receiver that is
connected and stalled. Different causes, different fixes.
Correct primary_conninfo. It is reloadable, so no restart:
ALTER SYSTEM SET primary_conninfo =
'host=db-primary-01.internal port=5432 user=repl application_name=standby-02';
SELECT pg_reload_conf();
Use the DNS name. That is the change that prevents recurrence, and it is worth making even though the new literal address would also work.
Then establish whether catching up is even possible:
-- on the primary
SELECT slot_name, active, wal_status, restart_lsn FROM pg_replication_slots;
If this standby had a slot, six weeks of WAL has been retained and it will catch up — and the primary has been carrying six weeks of WAL, which is a second incident. If it had no slot, the WAL is gone and the standby must be rebuilt from a new base backup.
Verification
pg_stat_replication on the primary has a row for this standby with
state = 'streaming'. That is the check the monitoring should have been
making all along.
Byte lag falls to a small number and stays there.
pg_stat_wal_receiver on the standby shows status = 'streaming' and
the correct sender.
End to end, which is the only check that tests the whole path:
-- primary
CREATE TABLE IF NOT EXISTS repl_probe(at timestamptz);
INSERT INTO repl_probe VALUES (now());
-- standby, seconds later
SELECT max(at) FROM repl_probe;
And the alert fires. Stop the standby deliberately, confirm the page arrives, start it again. An alert that has never fired has never been tested, and this incident is what an untested alert looks like after six weeks.
Prevention
Alert on absence before lag. A missing row in pg_stat_replication
is the condition that matters most, and no lag threshold can express it.
Compare against an expected inventory of standbys, not against whatever
happens to be connected.
Never render NULL as zero. Missing data is a gap and a collection alert, never a healthy value.
Alert on byte lag, not time lag.
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) is meaningful on an
idle primary; now() - pg_last_xact_replay_timestamp() is not.
Collect from the standby too. The primary-side view is unavailable exactly when the primary is what failed.
Use DNS names in primary_conninfo.
Rehearse failover monthly, and treat the rehearsal as the test of the monitoring. This one found a six-week-old fault; a monthly cadence would have found it in under a month, and never rehearsing would have found it during a real failure.
Add replication to the network change checklist.