Reported symptoms
The primary host was lost to a hypervisor failure at 03:11. The on-call engineer began the documented failover.
pg_promote returned true on the standby within a second and the
standby began accepting connections.
The runbook then says to confirm the timeline has advanced using
pg_controldata — and it still reported timeline 1.
The engineer concluded the promotion had not completed and issued a second promote request, which returned an error. Then restarted the new primary, which did not change the output either.
Nineteen minutes elapsed between a working database and the application being pointed at it. The database had been writable and correct throughout.
Evidence provided
$ grep -E 'promote|timeline|ready to accept' /var/log/postgresql/postgresql-18-main.log2026-08-27 22:53:36.888 UTC [210] LOG: received promote request
2026-08-27 22:53:36.889 UTC [210] LOG: redo done at 0/43CCB2A8 system usage: CPU: user: 0.35 s, system: 0.16 s, elapsed: 2330.84 s
2026-08-27 22:53:36.894 UTC [210] LOG: selected new timeline ID: 2
2026-08-27 22:53:36.977 UTC [204] LOG: database system is ready to accept connectionsUnder a second, request to accepting writes. pg_is_in_recovery()
returned false immediately.
$ pg_controldata | grep TimeLineIDLatest checkpoint's TimeLineID: 1
Latest checkpoint's PrevTimeLineID: 1The file pg_wal/00000002.history existed on disk:
1 0/43CCB2E0 no recovery target specified
The engineer read elapsed: 2330.84 s as the promotion taking 39
minutes.
The runbook was written against a cluster that had been idle when it was tested.
Work the evidence before reading on
- The log says
selected new timeline ID: 2andpg_controldatasays- Which is wrong?
- What is
elapsed: 2330.84 smeasuring? - Name three independent ways to confirm a promotion succeeded.
- What actually consumed the nineteen minutes?
Root cause
pg_controldata reports the last checkpoint, not the present
Three signals were available and all three agreed the promotion had worked:
| Check | Result | Authoritative? |
|---|---|---|
pg_is_in_recovery() | false | Yes — one query, correct immediately |
Log: selected new timeline ID: 2 | present | Yes |
pg_wal/00000002.history | exists | Yes |
pg_controldata TimeLineID | 1 | No — reports the last checkpoint |
The history file is worth reading
1 0/43CCB2E0 no recovery target specified
The third field records why the timeline diverged. A promotion
produces no recovery target specified; a point-in-time recovery
produces the target, for example before 2026-08-27 21:22:51.345091+00.
Months later, that field is the only record of which one happened.
The runbook had never met a busy cluster
It was written and tested against an idle one, where a checkpoint ran
soon enough that pg_controldata agreed. It had never been exercised
under load, and never at 03:11.
Resolution
Confirm with pg_is_in_recovery(). Authoritative, one query, correct the
instant the promotion completes:
SELECT pg_is_in_recovery(); -- false means this node is a primary
Confirm the timeline from the running server rather than the control file:
SELECT timeline_id FROM pg_control_checkpoint();
Read the log:
grep -E 'promote request|selected new timeline|ready to accept' \
/var/log/postgresql/postgresql-18-main.log | tail -5
Confirm it is genuinely writable — the only property the application cares about at 03:11:
CREATE TABLE IF NOT EXISTS failover_probe(at timestamptz);
INSERT INTO failover_probe VALUES (now());
Do not issue a second pg_promote — on a server that is already a
primary it fails, safely, and costs time and confidence. Do not restart
to “make the promotion take effect”; it is complete the moment the log
says so.
Then finish the failover, which is what actually took nineteen minutes: point the application at the new primary. DNS, a virtual IP, a connection-string update, a pooler reconfiguration — whichever it is, that step has a real duration and deserves the attention the timeline check consumed.
Verification
pg_is_in_recovery() returns false on the new primary.
A write succeeds from the application’s own connection path, not from the database host.
pg_control_checkpoint() reports the new timeline. Trigger a checkpoint
if you want pg_controldata to agree immediately:
CHECKPOINT;
The history file exists and reads sensibly:
cat /var/lib/postgresql/18/main/pg_wal/00000002.history
Remaining standbys follow the new primary on the new timeline:
SELECT application_name, state, sent_lsn, replay_lsn FROM pg_stat_replication;
The old primary is fenced, confirmed by trying to write to it and finding that you cannot. A promotion that leaves the old primary writable is not a completed failover — it is a split brain waiting for a client to find it.
Rehearse the whole sequence against a busy cluster, and time it. The measured promotion was under a second; the measured failover was nineteen minutes. The difference is the runbook.
Prevention
Check pg_is_in_recovery(), not pg_controldata.
Put exact commands and exact expected output in the runbook. “Confirm
the timeline has advanced” is not a step; SELECT pg_is_in_recovery();
returning f is a step.
Rehearse failover against a cluster under load. An untested runbook is a hypothesis, and 03:11 is a poor time to test it.
Know what redo done at ... elapsed means.
Read the timeline history file, and keep it.
Separate “the database is promoted” from “the failover is complete”. Different milestones, different owners, very different durations.
Time each step of a rehearsal and publish the numbers. A team that knows promotion is sub-second and traffic redirection is minutes will spend its attention in the right place.