Patroni Site Switchover: Zero Data Loss Between Two Independent Clusters
The maintenance window opens at 02:00. The DBA confirms that DC2 is streaming, runs patronictl promote-cluster, waits for DC2 to report Leader, and redirects the load balancer. The change record is closed at 02:14.
At 09:30 the application team reports that a batch of orders placed between 02:00 and 02:08 is missing from the database.
What happened is straightforward in retrospect. DC2 had replayed WAL up to 02:03:51. The patronictl promote-cluster command issued at 02:04 activated DC2 on a new PostgreSQL timeline without waiting for the remaining replication lag to close. The eight minutes of missing orders were committed on DC1, had not reached DC2, and were abandoned on DC1’s diverged timeline when DC2 was promoted.
The data was gone.
The answer
A Patroni standby-cluster promotion is not, by itself, a zero-data-loss switchover. The zero-data-loss guarantee comes from a coordinated sequence:
- Quiesce application writes.
- Drain active and prepared transactions.
- Capture the final durable source WAL flush LSN.
- Wait until the destination has replayed at least that LSN.
- Stop and verify Patroni on every source-site member.
- Promote the standby cluster.
- Validate the new primary.
- Redirect application traffic.
- Rejoin the old site as a standby cluster.
Steps 5 and 4 are not optional. They are the two decisive controls. Without both, RPO 0 and split-brain prevention cannot be credibly claimed.
What you will learn
- Why two independent Patroni scopes require explicit fencing before a site promotion
- What the state machine for a controlled switchover looks like
- How to capture and verify the final WAL position before promoting
- A complete, abort-on-failure Bash script for the 3+1 topology
- Failure-scenario handling and the point after which rollback is prohibited
Scope: PostgreSQL 14 or later, Patroni 4.1 or later. The script uses
patronictl promote-clusterandpatronictl edit-config, which require Patroni 3.0+ and 4.1+ respectively. Test against exact installed versions before production use. Cloud-managed PostgreSQL services (RDS, Cloud SQL, Azure Database for PostgreSQL Flexible Server) use proprietary HA implementations and are not in scope.
1. Architecture
The design uses two independent Patroni scopes and two independent Distributed Configuration Store namespaces.
Application write endpoint
VIP / HAProxy / load balancer / DNS
|
v
+------------------------------------------------+
| |
| DC1 |
| |
| Patroni scope: postgres-dc1 |
| |
| pg1 ----+ |
| pg2 ----+-- one Leader + two local replicas |
| pg3 ----+ |
| |
+-----------------------+------------------------+
|
| physical WAL streaming
| from the current DC1 leader
v
+------------------------------------------------+
| |
| DC2 |
| |
| Patroni scope: postgres-dc2 |
| |
| pg4: Standby Leader |
| - follows the active-site endpoint |
| - remains in PostgreSQL recovery |
| |
+------------------------------------------------+
When DC2 is promoted, the roles reverse:
DC2 pg4: Leader / writable primary
|
+---- WAL streaming ----> DC1 standby cluster
pg1/pg2/pg3
one Standby Leader + cascade replicas
A Patroni standby cluster is a separate Patroni cluster. Its standby leader streams from a remote PostgreSQL primary and can cascade WAL to its own replicas. The primary and standby clusters must use different Patroni scopes; they do not coordinate failover decisions with each other.
Relevant Patroni documentation:
2. What “zero data loss” means here
This architecture normally uses asynchronous inter-site replication. It does not guarantee RPO 0 during an unplanned loss of the active datacenter.
It can provide RPO 0 during a controlled planned switchover by enforcing the following invariant:
No additional application commits are possible on the old primary AND DC2 has replayed at least the final durable DC1 WAL position AND DC1 is fenced before DC2 is promoted.
The required order is non-negotiable. Changing it creates either a data-loss window or a split-brain window.
3. Why the source must be fenced
The two Patroni scopes make independent decisions. DC2 cannot prove that DC1 is unavailable merely because replication or network connectivity has stopped.
Promoting DC2 while DC1 still has a running writable primary creates two PostgreSQL timelines and potentially two independently writable databases. Patroni documentation explicitly warns that promoting a standby cluster while the source cluster is still running creates split brain and requires the source to be stopped or fenced first.
Database read-only mode alone is not fencing because:
default_transaction_read_onlyaffects the initial state of new transactions only- Existing transactions may already be writable
- Privileged users can override transaction read-only mode
- Application sessions may reconnect through another endpoint
- Jobs and integration services may bypass the normal application path
A defensible switchover requires both:
- Application fencing: load balancer, VIP, proxy, DNS, job scheduler, or application maintenance mode
- Database fencing: read-only setting, connection drain, and eventual Patroni/PostgreSQL shutdown on the old site
4. Required prerequisites
4.1 Patroni requirements
- Patroni 4.1 or later (uses
patronictl promote-clusterandpatronictl edit-config) - DC1 and DC2 must have different Patroni scopes
- Member names must be unique across both clusters
patronictl -f json listmust return valid JSON- Each site must have an accessible Patroni configuration file
- The current active cluster must report one Leader
- The remote cluster must report one Standby Leader
4.2 PostgreSQL requirements
- The destination standby leader must be streaming and replaying WAL
- WAL replay must not be paused
- Both sites must have the same PostgreSQL system identifier
- Replication authentication and
pg_hba.confmust already be valid in both directions - WAL retention or WAL archiving must be sufficient for rewind and rejoin operations
pg_rewind prerequisites must be met:
SHOW wal_log_hints;
SELECT current_setting('data_checksums');
At least one must be true: data checksums enabled at cluster initialization, or wal_log_hints = on.
pg_rewind can still fail for operational reasons, so a tested pg_basebackup rebuild path is mandatory.
4.3 Operating-system requirements
- Script runs as
postgres - Passwordless SSH from the execution node to every PostgreSQL node
- SSH host keys already trusted
postgrescan execute service-control commands through restricted passwordless sudo
Example restricted sudoers entry:
postgres ALL=(root) NOPASSWD: /usr/bin/systemctl start patroni, /usr/bin/systemctl stop patroni, /usr/bin/systemctl is-active patroni
Use exact executable paths and the actual service name from the target servers.
4.4 Application-routing requirements
The organization must provide two tested commands:
- A command that blocks all application writes to the old site
- A command that activates the new site after validation
Examples: disable/enable an HAProxy backend, move a floating VIP, update a load-balancer pool, enable application maintenance mode, stop/start application writer services, change a service-discovery record.
The script exposes these as hooks and refuses to proceed when a quiesce hook is not configured unless the operator explicitly overrides that safety control.
5. Site inventory
| Member | Host | Port | Patroni config | Site |
|---|---|---|---|---|
| pg1 | server1 | 5432 | /etc/patroni/dc1-pg1.yml | dc1 |
| pg2 | server2 | 5432 | /etc/patroni/dc1-pg2.yml | dc1 |
| pg3 | server3 | 5432 | /etc/patroni/dc1-pg3.yml | dc1 |
| pg4 | server4 | 5432 | /etc/patroni/dc2-pg4.yml | dc2 |
DC1 Patroni scope: postgres-dc1
DC2 Patroni scope: postgres-dc2
The standby cluster should follow a stable primary endpoint rather than a single physical host — a VIP, HAProxy address, or DNS name that always routes to the current active-site primary:
postgres-primary.internal.example.com:5432
6. Switchover state machine
State A — Normal operation
- One site is a regular Patroni cluster
- One site is a Patroni standby cluster
- Application connects only to the regular cluster leader
State B — Application quiesced
- Application routing is disabled
- New transactions default to read-only
- Application sessions are terminated
- No active transactions or prepared transactions remain
State C — RPO 0 established
- Final source durable LSN is captured
- Destination replay LSN >= final source LSN
State D — Source fenced
- Patroni is stopped on every old-site member
- PostgreSQL does not accept connections on any old-site member
- The old application endpoint remains disabled
State E — Destination promoted
- Destination reports Leader
pg_is_in_recovery()is false- A controlled write validation succeeds
State F — Traffic moved and old site rejoined
- Application writes use the new site
- Old site is converted to a standby cluster
- Old-site members are rewound or rebuilt
- One old-site member is Standby Leader and remaining members are replicas
7. Production script
The following script is intentionally conservative. It aborts on failed safety gates, logs each action, and does not automatically re-enable the old site after the destination has been promoted.
WARNING: Test before production use. Review permissions, assumptions, workload impact, and rollback requirements. No script should be executed in production without change-control approval.
Save as /etc/patroni/site-switchover-rpo0.sh:
#!/usr/bin/env bash
#
# Planned Patroni site switchover with RPO 0 for committed application writes.
# Architecture: DC1 = 3 Patroni members, DC2 = 1 standby-cluster member.
# Direction is detected automatically.
#
# Safety model:
# 1. Quiesce external writes.
# 2. Drain transactions and prepared transactions.
# 3. Capture final durable source WAL LSN.
# 4. Wait until destination has replayed that LSN.
# 5. Stop and verify every source-site Patroni service.
# 6. Promote destination with patronictl promote-cluster.
# 7. Validate write capability.
# 8. Activate application routing.
# 9. Convert old site with patronictl edit-config and restart it.
#
# IMPORTANT:
# - Run only during an approved maintenance window.
# - Test the script against the exact Patroni/PostgreSQL versions.
# - APP_QUIESCE_CMD and APP_ACTIVATE_CMD must be adapted to the environment.
# - The script must not be used as an automatic disaster-failover mechanism.
set -Eeuo pipefail
IFS=$'\n\t'
umask 077
# -----------------------------------------------------------------------------
# Inventory
# Format: member|host|pg_port|patroni_config|site|cluster_scope
# -----------------------------------------------------------------------------
NODES=(
'pg1|server1|5432|/etc/patroni/dc1-pg1.yml|dc1|postgres-dc1'
'pg2|server2|5432|/etc/patroni/dc1-pg2.yml|dc1|postgres-dc1'
'pg3|server3|5432|/etc/patroni/dc1-pg3.yml|dc1|postgres-dc1'
'pg4|server4|5432|/etc/patroni/dc2-pg4.yml|dc2|postgres-dc2'
)
SSH_USER='postgres'
SSH_OPTS=(
-o BatchMode=yes
-o ConnectTimeout=10
-o ServerAliveInterval=5
-o ServerAliveCountMax=3
)
PGUSER='postgres'
PGDATABASE='postgres'
# Use ~/.pgpass or TLS certificate authentication. Do not store a password here.
PSQL_BASE=(psql -X -v ON_ERROR_STOP=1 -tAq -w)
PATRONI_SERVICE='patroni'
REMOTE_SUDO=(sudo -n)
# A stable endpoint that routes to the current active-site primary.
# Written into standby_cluster.host when the old site is demoted.
ACTIVE_PRIMARY_ENDPOINT='postgres-primary.internal.example.com'
ACTIVE_PRIMARY_PORT='5432'
PRIMARY_SLOT_NAME='dr_standby_cluster'
APP_QUIESCE_CMD='/usr/local/sbin/postgres-app-routing quiesce'
APP_ACTIVATE_CMD='/usr/local/sbin/postgres-app-routing activate-new-primary'
APP_ROLLBACK_CMD='/usr/local/sbin/postgres-app-routing restore-old-primary'
# Set to 1 only for a controlled laboratory test where no application can connect.
ALLOW_EMPTY_APP_HOOKS=0
LSN_WAIT_SECONDS=300
PROMOTION_WAIT_SECONDS=180
DEMOTION_WAIT_SECONDS=600
POST_PROMOTION_WRITE_TEST=1
LOG_DIR='/var/log/patroni'
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
LOG_FILE="${LOG_DIR}/site-switchover-${RUN_ID}.log"
LOCK_FILE='/var/run/patroni-site-switchover.lock'
PHASE='initialization'
APP_QUIESCED=0
SOURCE_FENCED=0
DESTINATION_PROMOTED=0
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1
log() {
printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$PHASE" "$*"
}
die() {
log "ERROR: $*"
exit 1
}
on_error() {
local rc=$?
log "FAILED with exit code ${rc}."
if (( DESTINATION_PROMOTED == 1 )); then
log 'Destination has already been promoted. Automatic rollback is prohibited.'
log 'Keep the old source fenced. Validate the new primary and complete recovery manually.'
elif (( SOURCE_FENCED == 1 )); then
log 'Source site is already fenced but destination is not confirmed promoted.'
log 'Do not restart either site blindly. Inspect both Patroni scopes and PostgreSQL roles.'
elif (( APP_QUIESCED == 1 )); then
log 'Failure occurred before source fencing. Attempting controlled pre-promotion rollback.'
set +e
clear_source_read_only
run_hook "$APP_ROLLBACK_CMD" 'application rollback'
set -e
fi
exit "$rc"
}
trap on_error ERR
exec 9>"$LOCK_FILE"
flock -n 9 || die "another switchover process holds ${LOCK_FILE}"
require_command() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
shell_quote_command() {
local arg quoted='' result=''
for arg in "$@"; do
printf -v quoted '%q' "$arg"
result+=" ${quoted}"
done
printf '%s' "${result# }"
}
remote() {
local host=$1
shift
local cmd
cmd=$(shell_quote_command "$@")
ssh "${SSH_OPTS[@]}" "${SSH_USER}@${host}" "$cmd"
}
psql_on() {
local host=$1 port=$2 sql=$3
printf '%s\n' "$sql" |
ssh "${SSH_OPTS[@]}" "${SSH_USER}@${host}" \
"${PSQL_BASE[*]} -h 127.0.0.1 -p $(printf '%q' "$port") -U $(printf '%q' "$PGUSER") -d $(printf '%q' "$PGDATABASE") -f -"
}
run_hook() {
local command_text=$1 description=$2
if [[ -z $command_text ]]; then
if (( ALLOW_EMPTY_APP_HOOKS == 1 )); then
log "WARNING: no command configured for ${description}; explicit laboratory override is active."
return 0
fi
die "no command configured for ${description}"
fi
log "Executing ${description}: ${command_text}"
bash -o pipefail -c "$command_text"
}
parse_node() {
local row=$1
IFS='|' read -r NODE_MEMBER NODE_HOST NODE_PORT NODE_CFG NODE_SITE NODE_SCOPE <<<"$row"
}
lookup_member() {
local wanted=$1 row
for row in "${NODES[@]}"; do
parse_node "$row"
if [[ $NODE_MEMBER == "$wanted" ]]; then
MEMBER_HOST=$NODE_HOST
MEMBER_PORT=$NODE_PORT
MEMBER_CFG=$NODE_CFG
MEMBER_SITE=$NODE_SITE
MEMBER_SCOPE=$NODE_SCOPE
return 0
fi
done
die "member not found in inventory: ${wanted}"
}
representative_for_site() {
local wanted=$1 row
for row in "${NODES[@]}"; do
parse_node "$row"
if [[ $NODE_SITE == "$wanted" ]]; then
REP_HOST=$NODE_HOST
REP_CFG=$NODE_CFG
REP_SCOPE=$NODE_SCOPE
return 0
fi
done
die "site not found in inventory: ${wanted}"
}
nodes_in_site() {
local wanted=$1 row
for row in "${NODES[@]}"; do
parse_node "$row"
[[ $NODE_SITE == "$wanted" ]] && printf '%s|%s|%s|%s|%s|%s\n' \
"$NODE_MEMBER" "$NODE_HOST" "$NODE_PORT" "$NODE_CFG" "$NODE_SITE" "$NODE_SCOPE"
done
}
patroni_json() {
local host=$1 cfg=$2 scope=$3 json
json=$(remote "$host" patronictl -c "$cfg" list "$scope" -f json)
[[ -n $json ]] || die "empty patronictl JSON from ${host}"
python3 -c 'import json,sys; json.load(sys.stdin)' <<<"$json" >/dev/null || {
printf '%s\n' "$json" >&2
die "invalid patronictl JSON from ${host} using ${cfg}"
}
printf '%s\n' "$json"
}
cluster_kind_and_leader() {
local host=$1 cfg=$2 scope=$3 json
json=$(patroni_json "$host" "$cfg" "$scope")
python3 -c '
import json, sys
members = json.load(sys.stdin)
leaders = [m for m in members if m.get("Role") in ("Leader", "Standby Leader")]
if len(leaders) != 1:
print("invalid none")
raise SystemExit(0)
leader = leaders[0]
kind = "primary" if leader.get("Role") == "Leader" else "standby"
print(kind, leader.get("Member", "none"))
' <<<"$json"
}
service_active() {
local host=$1
remote "$host" "${REMOTE_SUDO[@]}" systemctl is-active --quiet "$PATRONI_SERVICE"
}
stop_patroni_site() {
local site=$1 member host port cfg node_site scope
while IFS='|' read -r member host port cfg node_site scope; do
log "Stopping ${PATRONI_SERVICE} on ${member} (${host})."
remote "$host" "${REMOTE_SUDO[@]}" systemctl stop "$PATRONI_SERVICE"
done < <(nodes_in_site "$site")
}
start_patroni_site() {
local site=$1 member host port cfg node_site scope
while IFS='|' read -r member host port cfg node_site scope; do
log "Starting ${PATRONI_SERVICE} on ${member} (${host})."
remote "$host" "${REMOTE_SUDO[@]}" systemctl start "$PATRONI_SERVICE"
done < <(nodes_in_site "$site")
}
verify_site_fenced() {
local site=$1 member host port cfg node_site scope status
while IFS='|' read -r member host port cfg node_site scope; do
if service_active "$host"; then
die "Patroni is still active on ${member} (${host})"
fi
status=$(remote "$host" pg_isready -h 127.0.0.1 -p "$port" -t 3 || true)
if [[ $status != *'no response'* ]]; then
die "PostgreSQL may still be reachable on ${member} (${host}:${port}): ${status}"
fi
log "Fenced: ${member} (${host}:${port})"
done < <(nodes_in_site "$site")
}
clear_source_read_only() {
if [[ -n ${SRC_HOST:-} && -n ${SRC_PORT:-} ]]; then
psql_on "$SRC_HOST" "$SRC_PORT" \
"ALTER SYSTEM RESET default_transaction_read_only; SELECT pg_reload_conf();" >/dev/null
log 'Source default_transaction_read_only override cleared.'
fi
}
wait_for_replay_lsn() {
local host=$1 port=$2 target_lsn=$3
local deadline=$((SECONDS + LSN_WAIT_SECONDS)) result replay received paused diff
while (( SECONDS < deadline )); do
result=$(psql_on "$host" "$port" "
SELECT
pg_is_in_recovery()::text || '|' ||
COALESCE(pg_last_wal_receive_lsn()::text, '') || '|' ||
COALESCE(pg_last_wal_replay_lsn()::text, '') || '|' ||
pg_is_wal_replay_paused()::text || '|' ||
CASE
WHEN pg_last_wal_replay_lsn() >= '${target_lsn}'::pg_lsn THEN 't'
ELSE 'f'
END || '|' ||
COALESCE(pg_wal_lsn_diff('${target_lsn}'::pg_lsn, pg_last_wal_replay_lsn())::text, '');")
IFS='|' read -r in_recovery received replay paused reached diff <<<"$result"
[[ $in_recovery == t ]] || die "destination is no longer in recovery before promotion"
[[ $paused == f ]] || die "destination WAL replay is paused"
log "Destination receive=${received:-NULL}, replay=${replay:-NULL}, remaining_bytes=${diff:-unknown}."
if [[ $reached == t ]]; then
DEST_REPLAY_LSN=$replay
return 0
fi
sleep 2
done
die "destination did not replay target LSN ${target_lsn} within ${LSN_WAIT_SECONDS}s"
}
wait_for_role() {
local host=$1 port=$2 expected_recovery=$3 timeout=$4 description=$5
local deadline=$((SECONDS + timeout)) actual
while (( SECONDS < deadline )); do
if actual=$(psql_on "$host" "$port" 'SELECT pg_is_in_recovery();' 2>/dev/null); then
if [[ $actual == "$expected_recovery" ]]; then
log "Role confirmed: ${description}."
return 0
fi
fi
sleep 2
done
die "timed out waiting for ${description}"
}
# -----------------------------------------------------------------------------
# 0. Local and remote preflight
# -----------------------------------------------------------------------------
PHASE='preflight'
for cmd in ssh python3 flock tee date bash; do
require_command "$cmd"
done
log "Run ID: ${RUN_ID}"
log "Log file: ${LOG_FILE}"
for row in "${NODES[@]}"; do
parse_node "$row"
log "Checking SSH and required commands on ${NODE_MEMBER} (${NODE_HOST})."
remote "$NODE_HOST" true
remote "$NODE_HOST" test -r "$NODE_CFG"
remote "$NODE_HOST" command -v patronictl >/dev/null
remote "$NODE_HOST" command -v psql >/dev/null
remote "$NODE_HOST" command -v pg_isready >/dev/null
remote "$NODE_HOST" command -v systemctl >/dev/null
remote "$NODE_HOST" "${REMOTE_SUDO[@]}" -v
done
# -----------------------------------------------------------------------------
# 1. Detect the current topology
# -----------------------------------------------------------------------------
PHASE='topology-detection'
representative_for_site dc1
DC1_REP_HOST=$REP_HOST
DC1_REP_CFG=$REP_CFG
DC1_SCOPE=$REP_SCOPE
representative_for_site dc2
DC2_REP_HOST=$REP_HOST
DC2_REP_CFG=$REP_CFG
DC2_SCOPE=$REP_SCOPE
read -r DC1_KIND DC1_LEADER < <(cluster_kind_and_leader "$DC1_REP_HOST" "$DC1_REP_CFG" "$DC1_SCOPE")
read -r DC2_KIND DC2_LEADER < <(cluster_kind_and_leader "$DC2_REP_HOST" "$DC2_REP_CFG" "$DC2_SCOPE")
log "DC1 kind=${DC1_KIND}, leader=${DC1_LEADER}."
log "DC2 kind=${DC2_KIND}, leader=${DC2_LEADER}."
if [[ $DC1_KIND == primary && $DC2_KIND == standby ]]; then
SRC_SITE=dc1; SRC_LEADER=$DC1_LEADER; SRC_REP_HOST=$DC1_REP_HOST
SRC_REP_CFG=$DC1_REP_CFG; SRC_SCOPE=$DC1_SCOPE
DST_SITE=dc2; DST_LEADER=$DC2_LEADER; DST_REP_HOST=$DC2_REP_HOST
DST_REP_CFG=$DC2_REP_CFG; DST_SCOPE=$DC2_SCOPE
elif [[ $DC2_KIND == primary && $DC1_KIND == standby ]]; then
SRC_SITE=dc2; SRC_LEADER=$DC2_LEADER; SRC_REP_HOST=$DC2_REP_HOST
SRC_REP_CFG=$DC2_REP_CFG; SRC_SCOPE=$DC2_SCOPE
DST_SITE=dc1; DST_LEADER=$DC1_LEADER; DST_REP_HOST=$DC1_REP_HOST
DST_REP_CFG=$DC1_REP_CFG; DST_SCOPE=$DC1_SCOPE
else
die "expected one primary and one standby cluster; DC1=${DC1_KIND}, DC2=${DC2_KIND}"
fi
lookup_member "$SRC_LEADER"
SRC_HOST=$MEMBER_HOST; SRC_PORT=$MEMBER_PORT; SRC_CFG=$MEMBER_CFG
lookup_member "$DST_LEADER"
DST_HOST=$MEMBER_HOST; DST_PORT=$MEMBER_PORT; DST_CFG=$MEMBER_CFG
log "Direction: ${SRC_SITE}/${SRC_LEADER} -> ${DST_SITE}/${DST_LEADER}."
# -----------------------------------------------------------------------------
# 2. Validate identity and replication health
# -----------------------------------------------------------------------------
PHASE='replication-validation'
SRC_SYSID=$(psql_on "$SRC_HOST" "$SRC_PORT" \
"SELECT system_identifier FROM pg_control_system();")
DST_SYSID=$(psql_on "$DST_HOST" "$DST_PORT" \
"SELECT system_identifier FROM pg_control_system();")
[[ -n $SRC_SYSID && $SRC_SYSID == "$DST_SYSID" ]] || \
die "system identifier mismatch: source=${SRC_SYSID:-NULL}, destination=${DST_SYSID:-NULL}"
DST_HEALTH=$(psql_on "$DST_HOST" "$DST_PORT" "
SELECT
pg_is_in_recovery()::text || '|' ||
pg_is_wal_replay_paused()::text || '|' ||
COALESCE((SELECT status FROM pg_stat_wal_receiver LIMIT 1), 'none') || '|' ||
COALESCE(pg_last_wal_receive_lsn()::text, '') || '|' ||
COALESCE(pg_last_wal_replay_lsn()::text, '');")
IFS='|' read -r DST_RECOVERY DST_PAUSED DST_RECEIVER DST_RECEIVE_LSN DST_REPLAY_LSN \
<<<"$DST_HEALTH"
[[ $DST_RECOVERY == t ]] || die 'destination is not in recovery'
[[ $DST_PAUSED == f ]] || die 'destination WAL replay is paused'
[[ $DST_RECEIVER == streaming ]] || \
die "destination WAL receiver status is ${DST_RECEIVER}, expected streaming"
[[ -n $DST_RECEIVE_LSN && -n $DST_REPLAY_LSN ]] || \
die 'destination receive/replay LSN is NULL'
WAL_HINTS=$(psql_on "$SRC_HOST" "$SRC_PORT" "SHOW wal_log_hints;")
CHECKSUMS=$(psql_on "$SRC_HOST" "$SRC_PORT" "SELECT current_setting('data_checksums');")
if [[ $WAL_HINTS != on && $CHECKSUMS != on ]]; then
die 'pg_rewind prerequisite missing: neither wal_log_hints nor data_checksums is enabled'
fi
PREPARED_COUNT=$(psql_on "$SRC_HOST" "$SRC_PORT" \
'SELECT count(*) FROM pg_prepared_xacts;')
[[ $PREPARED_COUNT == 0 ]] || \
die "source has ${PREPARED_COUNT} prepared transactions; resolve them before switchover"
log "System identifier=${SRC_SYSID}; receiver=${DST_RECEIVER}; replay=${DST_REPLAY_LSN}."
# -----------------------------------------------------------------------------
# 3. Quiesce application traffic and database writes
# -----------------------------------------------------------------------------
PHASE='application-quiesce'
run_hook "$APP_QUIESCE_CMD" 'application write quiesce'
APP_QUIESCED=1
psql_on "$SRC_HOST" "$SRC_PORT" "
ALTER SYSTEM SET default_transaction_read_only = on;
SELECT pg_reload_conf();" >/dev/null
READ_ONLY=$(psql_on "$SRC_HOST" "$SRC_PORT" 'SHOW default_transaction_read_only;')
[[ $READ_ONLY == on ]] || die 'failed to enable default_transaction_read_only on source'
psql_on "$SRC_HOST" "$SRC_PORT" "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND backend_type = 'client backend';" >/dev/null
for _ in $(seq 1 30); do
ACTIVE_XACT=$(psql_on "$SRC_HOST" "$SRC_PORT" "
SELECT count(*) FROM pg_stat_activity
WHERE pid <> pg_backend_pid() AND xact_start IS NOT NULL;")
[[ $ACTIVE_XACT == 0 ]] && break
sleep 1
done
[[ $ACTIVE_XACT == 0 ]] || \
die "${ACTIVE_XACT} transactions remain active after quiesce"
PREPARED_COUNT=$(psql_on "$SRC_HOST" "$SRC_PORT" \
'SELECT count(*) FROM pg_prepared_xacts;')
[[ $PREPARED_COUNT == 0 ]] || \
die "${PREPARED_COUNT} prepared transactions appeared during quiesce"
sleep 5
CLIENT_COUNT=$(psql_on "$SRC_HOST" "$SRC_PORT" "
SELECT count(*) FROM pg_stat_activity
WHERE pid <> pg_backend_pid() AND backend_type = 'client backend';")
[[ $CLIENT_COUNT == 0 ]] || \
die "${CLIENT_COUNT} client backends reconnected; application fencing is incomplete"
log 'Application writes quiesced; no active or prepared transactions remain.'
# -----------------------------------------------------------------------------
# 4. Establish and verify the final durable WAL boundary
# -----------------------------------------------------------------------------
PHASE='rpo-zero-gate'
FINAL_SOURCE_LSN=$(psql_on "$SRC_HOST" "$SRC_PORT" "
CHECKPOINT;
SELECT pg_switch_wal();
SELECT pg_current_wal_flush_lsn();" | tail -n 1)
[[ $FINAL_SOURCE_LSN =~ ^[0-9A-F]+/[0-9A-F]+$ ]] || \
die "invalid final source LSN: ${FINAL_SOURCE_LSN:-NULL}"
log "Final durable source LSN=${FINAL_SOURCE_LSN}."
wait_for_replay_lsn "$DST_HOST" "$DST_PORT" "$FINAL_SOURCE_LSN"
log "RPO 0 gate passed: destination replay=${DEST_REPLAY_LSN} >= source flush=${FINAL_SOURCE_LSN}."
ACTIVE_XACT=$(psql_on "$SRC_HOST" "$SRC_PORT" "
SELECT count(*) FROM pg_stat_activity
WHERE pid <> pg_backend_pid() AND xact_start IS NOT NULL;")
[[ $ACTIVE_XACT == 0 ]] || die "a transaction appeared after final LSN capture"
# -----------------------------------------------------------------------------
# 5. Fence the entire old primary site
# -----------------------------------------------------------------------------
PHASE='source-fencing'
stop_patroni_site "$SRC_SITE"
verify_site_fenced "$SRC_SITE"
SOURCE_FENCED=1
log "Source site ${SRC_SITE} is fenced."
# -----------------------------------------------------------------------------
# 6. Promote the destination standby cluster
# -----------------------------------------------------------------------------
PHASE='destination-promotion'
remote "$DST_REP_HOST" patronictl -c "$DST_REP_CFG" \
promote-cluster "$DST_SCOPE" --force
wait_for_role "$DST_HOST" "$DST_PORT" f "$PROMOTION_WAIT_SECONDS" \
"destination ${DST_LEADER} as writable primary"
DESTINATION_PROMOTED=1
psql_on "$DST_HOST" "$DST_PORT" "
ALTER SYSTEM RESET default_transaction_read_only;
SELECT pg_reload_conf();" >/dev/null
NEW_READ_ONLY=$(psql_on "$DST_HOST" "$DST_PORT" 'SHOW default_transaction_read_only;')
[[ $NEW_READ_ONLY == off ]] || die "new primary remains read-only: ${NEW_READ_ONLY}"
NEW_TIMELINE=$(psql_on "$DST_HOST" "$DST_PORT" \
'SELECT timeline_id FROM pg_control_checkpoint();')
NEW_LSN=$(psql_on "$DST_HOST" "$DST_PORT" 'SELECT pg_current_wal_flush_lsn();')
log "Destination promoted: timeline=${NEW_TIMELINE}, flush_lsn=${NEW_LSN}."
# -----------------------------------------------------------------------------
# 7. Controlled database write validation
# -----------------------------------------------------------------------------
PHASE='new-primary-validation'
if (( POST_PROMOTION_WRITE_TEST == 1 )); then
psql_on "$DST_HOST" "$DST_PORT" "
CREATE TABLE IF NOT EXISTS public.patroni_switchover_validation (
run_id text PRIMARY KEY,
written_at timestamptz NOT NULL DEFAULT clock_timestamp(),
server_address inet,
server_port integer,
timeline_id bigint NOT NULL
);
INSERT INTO public.patroni_switchover_validation
(run_id, server_address, server_port, timeline_id)
VALUES
('${RUN_ID}', inet_server_addr(), inet_server_port(),
(SELECT timeline_id FROM pg_control_checkpoint()))
ON CONFLICT (run_id) DO UPDATE
SET written_at = clock_timestamp(),
server_address = excluded.server_address,
server_port = excluded.server_port,
timeline_id = excluded.timeline_id;
SELECT run_id FROM public.patroni_switchover_validation
WHERE run_id = '${RUN_ID}';" | grep -Fx "$RUN_ID" >/dev/null
log 'Controlled write validation succeeded.'
fi
remote "$DST_REP_HOST" patronictl -c "$DST_REP_CFG" list "$DST_SCOPE"
# -----------------------------------------------------------------------------
# 8. Activate application routing to the new primary
# -----------------------------------------------------------------------------
PHASE='application-activation'
run_hook "$APP_ACTIVATE_CMD" 'new-primary application activation'
log 'Application routing activation command completed.'
# -----------------------------------------------------------------------------
# 9. Convert the old primary site into a standby cluster
# -----------------------------------------------------------------------------
PHASE='old-site-demotion'
remote "$SRC_REP_HOST" patronictl -c "$SRC_REP_CFG" edit-config "$SRC_SCOPE" --force \
-s "standby_cluster.host=${ACTIVE_PRIMARY_ENDPOINT}" \
-s "standby_cluster.port=${ACTIVE_PRIMARY_PORT}" \
-s "standby_cluster.primary_slot_name=${PRIMARY_SLOT_NAME}" \
-s 'standby_cluster.create_replica_methods=[basebackup]'
remote "$SRC_REP_HOST" patronictl -c "$SRC_REP_CFG" show-config "$SRC_SCOPE" | \
grep -q '^standby_cluster:' || \
die 'standby_cluster configuration was not committed to the old-site DCS'
lookup_member "$SRC_LEADER"
log "Starting former source leader ${SRC_LEADER} first for controlled rewind/rejoin."
remote "$MEMBER_HOST" "${REMOTE_SUDO[@]}" systemctl start "$PATRONI_SERVICE"
wait_for_role "$MEMBER_HOST" "$MEMBER_PORT" t "$DEMOTION_WAIT_SECONDS" \
"former source leader ${SRC_LEADER} as standby"
while IFS='|' read -r member host port cfg site scope; do
[[ $member == "$SRC_LEADER" ]] && continue
log "Starting old-site member ${member} (${host})."
remote "$host" "${REMOTE_SUDO[@]}" systemctl start "$PATRONI_SERVICE"
done < <(nodes_in_site "$SRC_SITE")
# -----------------------------------------------------------------------------
# 10. Final topology validation
# -----------------------------------------------------------------------------
PHASE='final-validation'
DEADLINE=$((SECONDS + DEMOTION_WAIT_SECONDS))
while (( SECONDS < DEADLINE )); do
read -r SRC_FINAL_KIND SRC_FINAL_LEADER < <(
cluster_kind_and_leader "$SRC_REP_HOST" "$SRC_REP_CFG" "$SRC_SCOPE" \
|| printf 'invalid none\n'
)
[[ $SRC_FINAL_KIND == standby ]] && break
sleep 5
done
[[ ${SRC_FINAL_KIND:-invalid} == standby ]] || \
die "old site did not become a standby cluster within ${DEMOTION_WAIT_SECONDS}s"
read -r DST_FINAL_KIND DST_FINAL_LEADER < <(
cluster_kind_and_leader "$DST_REP_HOST" "$DST_REP_CFG" "$DST_SCOPE"
)
[[ $DST_FINAL_KIND == primary ]] || die "destination no longer reports a primary cluster"
lookup_member "$SRC_FINAL_LEADER"
OLD_SITE_RECOVERY=$(psql_on "$MEMBER_HOST" "$MEMBER_PORT" \
'SELECT pg_is_in_recovery();')
[[ $OLD_SITE_RECOVERY == t ]] || die 'old-site standby leader is not in recovery'
lookup_member "$DST_FINAL_LEADER"
NEW_SITE_RECOVERY=$(psql_on "$MEMBER_HOST" "$MEMBER_PORT" \
'SELECT pg_is_in_recovery();')
[[ $NEW_SITE_RECOVERY == f ]] || die 'new-site leader is unexpectedly in recovery'
remote "$DST_REP_HOST" patronictl -c "$DST_REP_CFG" list "$DST_SCOPE"
remote "$SRC_REP_HOST" patronictl -c "$SRC_REP_CFG" list "$SRC_SCOPE"
PHASE='complete'
log "SUCCESS: ${DST_SITE}/${DST_FINAL_LEADER} is the writable primary cluster."
log "SUCCESS: ${SRC_SITE}/${SRC_FINAL_LEADER} is the standby cluster."
log "Final source LSN before promotion: ${FINAL_SOURCE_LSN}."
log "Destination replay LSN at RPO gate: ${DEST_REPLAY_LSN}."
log "New primary timeline: ${NEW_TIMELINE}."
log "Review application, replication-slot, archive, backup and monitoring status."
Install and validate syntax:
sudo install -o postgres -g postgres -m 0750 \
site-switchover-rpo0.sh \
/etc/patroni/site-switchover-rpo0.sh
sudo -u postgres bash -n /etc/patroni/site-switchover-rpo0.sh
Run:
sudo -u postgres /etc/patroni/site-switchover-rpo0.sh
8. Key script design decisions
No plaintext database password
The script uses ~postgres/.pgpass or certificate/peer authentication. Example .pgpass entry:
127.0.0.1:5432:postgres:postgres:REPLACE_WITH_SECRET
chmod 0600 ~postgres/.pgpass
Final LSN is the durable flush position
The script captures pg_current_wal_flush_lsn() after CHECKPOINT and pg_switch_wal(). This identifies the last WAL position known to be durably flushed on the source. The RPO gate is evaluated with PostgreSQL’s native pg_lsn comparison:
pg_last_wal_replay_lsn() >= '<final-source-lsn>'::pg_lsn
pg_last_wal_replay_lsn() represents WAL already applied during recovery — not just received. receive_lsn >= target is insufficient because WAL buffered in the receiver’s walreceiver process has not necessarily been written to disk or replayed.
Prepared transactions are a hard stop
A two-phase transaction can remain in pg_prepared_xacts after its client session has gone. It may later be committed or rolled back — and that decision affects the correct LSN boundary. The script aborts when prepared transactions exist. The operator must commit or roll them back before proceeding.
The source is stopped before promotion
Systemctl stop succeeds on every source-site member and pg_isready reports no response. Only then does patronictl promote-cluster execute. In higher-assurance environments, replace service stopping with hypervisor power-off, cloud instance stop, SAN fencing, or STONITH/watchdog integration.
Patroni-native cluster conversion
The script writes standby_cluster into the old-site DCS with patronictl edit-config, verifies the configuration is committed, and only then starts an old-site member. This preserves the fencing boundary even if the DCS write completes before the first old-site process starts. patronictl demote-cluster is documented but waits for a running standby leader — it is not appropriate when the old site is already stopped and divergent.
Old site follows a stable endpoint
The old site is demoted to follow postgres-primary.internal.example.com, not the physical hostname of the node that was leader during the switchover. This prevents broken inter-site replication after a subsequent local Patroni switchover on the new active site.
Rollback is phase-sensitive
Before source fencing, the script can reset default_transaction_read_only and invoke the application rollback hook.
After source fencing, automatic rollback is unsafe.
After destination promotion, automatic rollback is prohibited. A new PostgreSQL timeline exists. Keep the former source fenced, validate the new primary, and invoke a separately tested reverse-switchover procedure.
9. Application hook example
#!/usr/bin/env bash
set -Eeuo pipefail
ACTION=${1:?usage: $0 quiesce|activate-new-primary|restore-old-primary}
case "$ACTION" in
quiesce)
/usr/local/bin/lb-control disable-current-writer
/usr/local/bin/app-control stop-writers
;;
activate-new-primary)
/usr/local/bin/lb-control enable-new-writer
/usr/local/bin/app-control start-writers
;;
restore-old-primary)
/usr/local/bin/lb-control enable-original-writer
/usr/local/bin/app-control start-writers
;;
*)
echo "Unsupported action: ${ACTION}" >&2
exit 2
;;
esac
Install as a root-owned executable and allow only the required subcommands through restricted sudo. The hook must be idempotent.
10. Pre-change checklist
Architecture and configuration
- DC1 and DC2 use different Patroni scopes
- All member names are globally unique
- DC1 contains three healthy Patroni members
- DC2 contains one healthy standby leader
- The application uses a managed write endpoint
- The standby-cluster upstream endpoint resolves to the current primary
- WAL retention and archive capacity are sufficient
Replication health
-
pg_stat_wal_receiver.status = 'streaming'on the standby leader -
pg_is_wal_replay_paused() = false - Receive and replay LSNs are advancing
- System identifiers match
-
wal_log_hints = onor data checksums are enabled - A full rebuild procedure has been tested
Access and automation
- SSH works non-interactively from execution node to all members
- PostgreSQL administrative authentication path tested
- Restricted sudo service control tested
- Application quiesce hook tested
- Application activation hook tested
- Application rollback hook tested
- Stable primary endpoint validated from both sites
Operational governance
- Maintenance approval recorded
- Application, network, and database teams present
- Current backup and restore validation exists
- Reverse-switchover procedure is approved
- Operator understands the point after which automatic rollback is prohibited
11. Execution gates
| Gate | Required evidence | Failure action |
|---|---|---|
| Topology | Exactly one Leader cluster and one Standby Leader cluster | Stop |
| Identity | Same PostgreSQL system identifier | Stop |
| Receiver health | WAL receiver is streaming and replay is not paused | Stop |
| Rewind readiness | Data checksums or wal_log_hints=on |
Stop or approve full rebuild |
| Application fence | Writer endpoint disabled and clients cannot reconnect | Stop |
| Transaction drain | No active or prepared transactions | Stop |
| RPO gate | Destination replay LSN >= final source flush LSN | Stop; keep source quiesced |
| Source fence | Patroni stopped and PostgreSQL unreachable on every source member | Stop; do not promote |
| Promotion | Destination is not in recovery and reports Leader | Keep source fenced; investigate |
| Write validation | Controlled transaction commits on new primary | Keep source fenced; investigate |
| Rejoin | Old site reports Standby Leader and remains in recovery | New primary remains authoritative; rebuild old site |
12. Post-switchover validation
Confirm a single writable primary
On the new active site:
SELECT
inet_server_addr(),
inet_server_port(),
pg_is_in_recovery(),
pg_current_wal_flush_lsn();
Expected: pg_is_in_recovery = false
On the old site standby leader:
SELECT
inet_server_addr(),
inet_server_port(),
pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp();
Expected: pg_is_in_recovery = true
Validate Patroni topology
patronictl -c /etc/patroni/dc2-pg4.yml list postgres-dc2
patronictl -c /etc/patroni/dc1-pg1.yml list postgres-dc1
Expected: new site shows one Leader; old site shows one Standby Leader; no node reports an unexpected primary role.
Validate replication from the new primary
SELECT
application_name,
client_addr,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication
ORDER BY application_name;
Validate the application path
Connect through the application endpoint, not the physical node address:
SELECT
inet_server_addr(),
inet_server_port(),
pg_is_in_recovery(),
current_timestamp;
Then perform a controlled application transaction and validate it through the application’s normal read path.
Validate operational services
- Connection pools point to the new site
- Scheduled jobs are enabled only on the intended primary
- ETL and integration writers are active
- Backup jobs target the new primary
- WAL archive status is healthy
- Replication slots are active and not retaining excessive WAL
- Monitoring recognizes the new topology
- Application latency and errors are normal
13. Failure scenarios
Before application quiesce
Impact: none. Correct the preflight problem and rerun.
After quiesce but before source fencing
The old primary is still authoritative. Reset the read-only override, restore the old application route, validate writes, and investigate the failed gate.
Destination does not reach the final source LSN
Do not promote. Investigate on both sides:
-- Source
SELECT * FROM pg_stat_replication;
-- Destination
SELECT * FROM pg_stat_wal_receiver;
SELECT
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_get_wal_replay_pause_state();
Common causes: network interruption, missing WAL, replay pause, recovery conflict, storage latency, broken replication slot.
After source fencing but before destination promotion
Do not automatically restart DC1 and do not blindly promote DC2. Inspect both Patroni scopes, verify DC1 remains stopped, verify the final replay LSN on DC2, then either complete the promotion or formally abort by restarting only the still-authoritative DC1 cluster before any promotion occurred.
After destination promotion
DC2 is authoritative because a new timeline has been created. Keep DC1 fenced. Do not redirect clients back to DC1. Validate and repair DC2. Rejoin DC1 using pg_rewind or a full rebuild. Use a separately controlled reverse switchover to return service later.
pg_rewind fails
Do not start the divergent data directory as a primary. Reinitialize the affected member:
patronictl -c /etc/patroni/dc1-pg1.yml reinit postgres-dc1 pg1 --force --wait
Repeat for each failed old-site member. A full rebuild can consume significant network, storage, and WAL capacity — size and test it before production adoption.
14. Limitations of the 3+1 design
DC2 has no local PostgreSQL HA. With one PostgreSQL node in DC2, a failure of pg4 after promotion causes an outage. Patroni cannot fail over locally. Maintenance on pg4 requires downtime or a site switchback. For production resilience, DC2 should contain at least two PostgreSQL members with a properly designed DCS quorum.
Planned RPO 0 is not disaster RPO 0. The script achieves zero data loss only when the source is healthy enough to stop writes, flush WAL, and allow the destination to replay the final position before fencing. An abrupt DC1 loss can lose transactions committed locally but not yet received at DC2. Eliminating that risk requires synchronous inter-site commit.
Routing is an external dependency. PostgreSQL and Patroni cannot guarantee that every application, job, or DBA tool uses the intended endpoint. The application fencing hook must cover all write paths.
pg_rewind is not guaranteed. Even with checksums or wal_log_hints, rewind can fail due to missing WAL, damaged files, or configuration errors. Full reinitialization remains the fallback.
15. Recommended improvements for stronger designs
- Add a second PostgreSQL node in DC2 for local HA
- Deploy a properly distributed DCS architecture appropriate to the failure domains
- Use hypervisor or cloud fencing rather than relying only on service stops
- Use a stable primary service endpoint with health checks based on Patroni’s primary REST endpoint
- Manage replication slots permanently through Patroni’s
slotsconfiguration - Archive WAL to storage accessible from both sites
- Test
pg_rewindand full reinitialization quarterly - Store switchover evidence centrally: final source LSN, destination replay LSN, source fencing results, old and new timelines, Patroni topology before and after, application test result
- Implement a separately reviewed reverse-switchover script
The two decisive controls
Without both of these, a Patroni site switchover cannot credibly claim RPO 0 or split-brain prevention:
- Destination replay LSN >= final source flush LSN — verified with
pg_lsncomparison before fencing - Old primary site fenced before destination promotion — Patroni stopped and PostgreSQL unreachable on every source-site member
Everything else in the procedure exists to make those two controls reachable, verifiable, and recoverable when they fail.