Reported symptoms
A network partition at 02:40 isolated the primary from the monitoring network — but not from the application network.
The failover automation promoted the standby at 02:42 after failing to reach the primary. The old primary never stopped, and continued accepting writes from the application instances that could still reach it.
Both clusters answered pg_is_in_recovery() with false for the next
fifty minutes.
Customers reported orders that had disappeared, and other customers reported orders that had never been confirmed. Reporting totals from the two servers disagree by a hundred and twenty orders.
Neither server logged anything unusual. Each believed it was the only primary.
Evidence provided
$ psql -c "SELECT origin, count(*) FROM ha GROUP BY origin;" # on each server--- old primary (timeline 1) --- --- new primary (timeline 2) ---
origin | count origin | count
-------------+------- -------------+-------
OLD-PRIMARY | 120 NEW-PRIMARY | 300
primary | 500 primary | 500$ psql -c "SELECT pg_is_in_recovery();" -c "SELECT count(*) FROM pg_stat_replication;"old primary: pg_is_in_recovery -> f pg_stat_replication -> 0 rows
new primary: pg_is_in_recovery -> f pg_stat_replication -> 0 rowsThe new primary’s timeline history file:
1 0/43CCB2E0 no recovery target specified
Work the evidence before reading on
- Which PostgreSQL setting would have prevented two primaries?
- Can the 120 rows and the 300 rows be merged?
pg_rewindwill rejoin the old primary. What happens to its 120 rows?- The partition affected monitoring and not the application network. Why does that detail matter more than any other?
Root cause
Nothing stopped the first primary
The automation promoted on a failed health check and never verified that the old primary had stopped. The partition affected the monitoring network and not the application network, so from the application’s point of view both servers were reachable and both were writable.
There is no merge
The two clusters share 500 rows of common history and then diverge. Row identities collide, sequences have issued the same values on both sides, and no tool reconciles two divergent PostgreSQL timelines — the WAL describing them is mutually incompatible from the branch point onward.
Resolution
Establish the extent of the divergence before touching either server.
Once pg_rewind runs this cannot be repeated:
-- on each server
SELECT pg_is_in_recovery(), timeline_id FROM pg_control_checkpoint();
SELECT count(*) FROM pg_stat_replication;
cat /var/lib/postgresql/18/main/pg_wal/*.history
Take a full copy of the losing server’s data directory, or at minimum a
pg_dump of the affected tables. Once pg_rewind completes, the
divergent rows exist nowhere. A dump is the only route by which any of
that work can be reconciled by hand — and for a hundred and twenty
orders, reconciling by hand is frequently the right answer.
Choose the survivor deliberately and say why. Usually the new primary wins because the application wrote to it longer and its divergence is larger — 300 rows against 120 here. That is an argument, not a rule.
Rejoin the loser with pg_rewind, which requires a cleanly shut down
target:
pg_ctl -D /var/lib/postgresql/18/main -m fast stop
pg_rewind --target-pgdata=/var/lib/postgresql/18/main \
--source-server='host=new-primary user=repl' -P
$ pg_rewind --target-pgdata=... --source-server=... -Ppg_rewind: servers diverged at WAL location 0/43CCB2E0 on timeline 1
pg_rewind: rewinding from last common checkpoint at 0/43C807B8 on timeline 1pg_rewind needs full_page_writes on at the source and either a
superuser connection or a role holding the four required file-access
function grants. It also needs the WAL from the divergence point to still
exist — if segments have been recycled it fails, and the node must be
rebuilt from a base backup instead.
Then reconcile by hand from the dump you took. That is the only part of this incident where the lost work can be recovered at all.
Verification
Exactly one server reports pg_is_in_recovery() as false. Check
every node, including any you believe is stopped.
The rejoined node appears in pg_stat_replication on the survivor,
streaming:
SELECT application_name, state, sync_state, replay_lsn FROM pg_stat_replication;
Row counts match. After the measured rewind, the old primary held 800 rows, identical to the new primary.
The discarded work is enumerated and its fate recorded. This is a reporting obligation, not a technical one: a hundred and twenty acknowledged orders were lost, and somebody outside the database team needs to know which.
A write on the survivor appears on the rejoined node within seconds.
Fencing is tested. Trigger a promotion in a rehearsal without stopping the old primary, and confirm the fencing mechanism makes it unreachable or unwritable. If it does not, this incident is still possible and nothing about the recovery has changed that.
Prevention
Fence before promoting. Always. The promotion must not begin until the old primary is provably unable to accept writes — powered off, network withdrawn, storage detached, service stopped. “We could not reach it” is not fencing; it is the condition that makes fencing necessary.
Understand that PostgreSQL will not prevent this. Any belief that the database will refuse a second primary is mistaken and dangerous.
Do not promote on a failed health check alone. A monitoring partition looks identical to a dead primary from the monitor’s position and is a completely different situation. Require a second independent signal, or require fencing to succeed first.
Use a failover manager that fences, and read what its fencing actually does. A manager that promotes and hopes is a split-brain generator with a good user interface.
Alert on more than one node reporting pg_is_in_recovery() = false.
It is trivial across a node inventory, and it is the only direct
detection — neither server logs anything unusual.
Take a copy before running pg_rewind. It is the last moment at which
the discarded work exists.
Rehearse split brain in a test environment. Create one, resolve it, count what was lost, write the number down. A team that has done this once will fence properly.
Design the application to tolerate the loss. Idempotent writes with client-supplied identifiers, and an audit trail outside the database, turn “a hundred and twenty orders vanished” into “a hundred and twenty orders can be replayed”.