d. Failover
Start from a clean cluster, so the output matches the one below:
docker compose down -v
docker compose up -d --wait
docker compose exec pg-primary psql -U store -c "CREATE TABLE orders (id serial PRIMARY KEY, item text NOT NULL, created_at timestamptz DEFAULT now());" -c "INSERT INTO orders (item) VALUES ('keyboard'), ('mouse');"
1. Planned failover
The primary goes down
A client that needs to write finds no primary:
docker compose run --rm client "host=pg-primary,pg-replica1,pg-replica2 target_session_attrs=read-write connect_timeout=2" -c "SELECT 1;"
psql: error: could not translate host name "pg-primary" to address: Name or service not known
connection to server at "pg-replica1" (172.18.0.3), port 5432 failed: session is read-only
connection to server at "pg-replica2" (172.18.0.4), port 5432 failed: session is read-only
(The first line may also read Connection refused.) The replicas are still up and still answer reads β the data is not lost, but writes are unavailable. The replicas keep trying to reconnect:
FATAL: could not connect to the primary server: connection to server at "pg-primary" (172.18.0.2), port 5432 failed: Connection refused
LOG: waiting for WAL to become available at 0/4038AB8
Promote a replica
pg-replica1 leaves recovery, starts a new timeline (2) and accepts writes:
docker compose logs --tail 4 pg-replica1
docker compose exec pg-replica1 psql -U store -c "SELECT pg_is_in_recovery();" -c "INSERT INTO orders (item) VALUES ('after-failover');"
LOG: selected new timeline ID: 2
LOG: database system is ready to accept connections
...
pg_is_in_recovery
-------------------
f
INSERT 0 1
The timeline marks the fork in the history: WAL on timeline 2 belongs to the new primary, and any server still on timeline 1 is a copy of the past.
The clients find the new primary
Run the same command that failed a minute ago:
docker compose run --rm client "host=pg-primary,pg-replica1,pg-replica2 target_session_attrs=read-write connect_timeout=2" -Atc "SELECT current_setting('cluster_name');"
No configuration changed in the client: the driver tried the list again and kept the host that accepts writes. (A service already connected to the old primary loses its connections and must reconnect β connection pools such as HikariCP do this automatically.)
Re-point the other replica
pg-replica2 still streams from pg-primary, which no longer exists. Tell it where the new primary is; primary_conninfo takes effect with a reload, no restart:
docker compose exec pg-replica2 psql -U store -c "ALTER SYSTEM SET primary_conninfo = 'host=pg-replica1 user=replicator password=replpass';" -c "SELECT pg_reload_conf();"
docker compose logs --tail 3 pg-replica2
docker compose exec pg-replica2 psql -U store -c "SELECT count(*) FROM orders;" -c "SELECT status, sender_host, received_tli FROM pg_stat_wal_receiver;"
LOG: new target timeline is 2
LOG: restarted WAL streaming at 0/5000000 on timeline 2
...
count
-------
3
status | sender_host | received_tli
-----------+-------------+--------------
streaming | pg-replica1 | 2
pg-replica2 followed the timeline switch and already has the after-failover row.
Bring the old primary back, as a replica
The old primary must not start as a primary again: there would be two primaries (split brain). rejoin-as-replica.sh runs pg_rewind against the new primary β it discards anything the old primary wrote after the fork β and adds standby.signal and primary_conninfo, like pg_basebackup -R did for the replicas. Run it in a one-off container that mounts the old primary's volume:
The node must be stopped
Check with docker compose ps that pg-primary is not running before you run the script: two servers on the same data directory corrupt it.
docker compose run --rm --no-deps --entrypoint bash pg-primary /scripts/rejoin-as-replica.sh pg-replica1
pg_rewind: connected to server
pg_rewind: servers diverged at WAL location 0/4038AA0 on timeline 1
pg_rewind: no rewind required
rejoin: pg-primary will follow pg-replica1 on its next start
"No rewind required": the timelines split right after the old primary's last WAL record (its shutdown checkpoint, sent to the replicas during a clean stop), so it wrote nothing that the new primary lacks β nothing to discard. Start it:
docker compose start pg-primary
docker compose exec pg-replica1 psql -U store -c "SELECT application_name, state FROM pg_stat_replication;"
application_name | state
------------------+-----------
pg-replica2 | streaming
pg-primary | streaming
The cluster is whole again, with the roles swapped. The names pg-primary and pg-replica1 no longer describe the roles β which is why real clusters name nodes neutrally (pg-1, pg-2, pg-3) and always ask a node its role (pg_is_in_recovery()).
stateDiagram-v2
direction LR
state "pg-primary: primary" as P1
state "pg-primary: down" as P2
state "pg-primary: replica of pg-replica1" as P3
P1 --> P2: docker compose stop
P2 --> P3: pg_rewind + standby.signal
state "pg-replica1: replica" as R1
state "pg-replica1: primary (timeline 2)" as R2
R1 --> R2: pg_promote() 2. Unplanned failover: a lost write
A planned stop loses nothing. A crash with asynchronous replication can. To make it reproducible, keep the replicas away from the last write: stop them, write on the primary, and kill it.
docker compose down -v
docker compose up -d --wait
docker compose exec pg-primary psql -U store -c "CREATE TABLE orders (id serial PRIMARY KEY, item text NOT NULL, created_at timestamptz DEFAULT now());" -c "INSERT INTO orders (item) VALUES ('keyboard'), ('mouse');"
docker compose stop pg-replica1 pg-replica2
docker compose exec pg-primary psql -U store -c "INSERT INTO orders (item) VALUES ('lost-write');"
docker compose kill pg-primary
docker compose start pg-replica1 pg-replica2
The INSERT answered INSERT 0 1: for the client, the order exists. Now fail over as before:
docker compose exec pg-replica1 psql -U store -c "SELECT pg_promote();" -c "INSERT INTO orders (item) VALUES ('after-failover');" -c "SELECT * FROM orders ORDER BY id;"
docker compose exec pg-replica2 psql -U store -c "ALTER SYSTEM SET primary_conninfo = 'host=pg-replica1 user=replicator password=replpass';" -c "SELECT pg_reload_conf();"
id | item | created_at
----+----------------+-------------------------------
1 | keyboard | 2026-10-31 18:51:44.203097+00
2 | mouse | 2026-10-31 18:51:44.203097+00
34 | after-failover | 2026-10-31 18:51:50.223978+00
lost-write is gone: it existed only in the WAL of the dead primary. This is the RPO (recovery point objective) of asynchronous replication β the commits of the last instant before a crash. (The jump from 2 to 34 is not a bug: a sequence logs its values in batches of 32, and a failover skips the rest of the batch. Never expect serial ids without gaps.)
Now rejoin the old primary. Its data directory still has lost-write, on timeline 1, after the fork β this time pg_rewind has work to do:
docker compose run --rm --no-deps --entrypoint bash pg-primary /scripts/rejoin-as-replica.sh pg-replica1
pg_rewind: executing "/usr/lib/postgresql/17/bin/postgres" for target server to complete crash recovery
...
pg_rewind: servers diverged at WAL location 0/5038A28 on timeline 1
pg_rewind: rewinding from last common checkpoint at 0/3000130 on timeline 1
...
pg_rewind: Done!
rejoin: pg-primary will follow pg-replica1 on its next start
docker compose start pg-primary
docker compose exec pg-primary psql -U store -c "SELECT pg_is_in_recovery();" -c "SELECT item FROM orders ORDER BY id;"
pg-primary is now a replica and agrees with the new primary: lost-write was discarded for good.
Would synchronous replication have saved lost-write?
Yes, but not for free. With synchronous_standby_names = 'ANY 1 (...)', the INSERT would not have answered while both replicas were down β it would have blocked (experiment 5 of Replication). The client would never have been told the order existed. RPO = 0 is paid with write availability: CAP again.
3. Why not automate it with a script?
Everything you did by hand fits in a script. The hard part is the first step β deciding that the primary is dead:
- A primary that does not answer the health check may be slow, or isolated from the monitor but not from the clients. Promoting a replica then creates two primaries accepting different writes: a split brain, much worse than a few seconds of downtime.
- The monitor itself may fail, or two monitors may disagree.
That is why automatic failover is built on consensus: in Patroni, each node runs an agent, and only the agent that holds the leader key in etcd (Raft) may run as primary. The key has a TTL: an isolated primary cannot renew it and demotes itself before a replica is promoted. Managed services (Amazon RDS Multi-AZ, Aurora, Google Cloud SQL) run the same logic for you.
4. Connecting the platform
The services of the previous hands-on read the JDBC URL from spring.datasource.url, which the environment variable SPRING_DATASOURCE_URL overrides. To point the account service at the cluster (with the cluster's services in the same compose.yaml), list the nodes and ask for the primary:
account:
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://pg-primary:5432,pg-replica1:5432,pg-replica2:5432/store?targetServerType=primary
SPRING_DATASOURCE_USERNAME: store
SPRING_DATASOURCE_PASSWORD: devpass
After a failover, HikariCP drops the broken connections and the driver finds the new primary. Sending the reads to the replicas needs a second DataSource with targetServerType=preferSecondary and a router that picks it for @Transactional(readOnly = true) methods (Spring's AbstractRoutingDataSource) β a good exercise, as long as you remember the stale reads of experiment 3.
Clean-up
-v removes the three volumes; without it the cluster, with whatever roles it ended in, comes back on the next up.
Checklist
- Both replicas appear as
streaminginpg_stat_replication. - A row inserted on the primary is read on both replicas; an
INSERTon a replica fails. - With replay paused, a replica returns stale data, and
bytes_behindshows it. -
target_session_attrs=read-writefinds the primary, before and after a failover. - With
FIRST 1 ("pg-replica1")and that replica stopped, a commit blocks; withANY 1it does not. - After a crash failover, you can explain why
lost-writedisappeared and what would have prevented it. - The old primary rejoins as a replica with
pg_rewind.