┌────────────────────────────┐ │PostgreSQL Failover: Hunting│ │the Dual-Writer Split-Brain │ │ Window │ │ 2026-08-18 │ │ │ ├────────────────────────────┤ │ << Back to Blog │ └────────────────────────────┘
╔══════════════════════════════════════╗ ║ PostgreSQL Failover: Hunting the ║ ║ Dual-Writer Split-Brain Window ║ ║ 2026-08-18 ║ ║ ║ ╠══════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════╝
╔══════════════════════════════════════════════════════════╗ ║ PostgreSQL Failover: Hunting the Dual-Writer Split-Brain ║ ║ Window ║ ║ 2026-08-18 ║ ║ ║ ╠══════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════════════════╗ ║ PostgreSQL Failover: Hunting the Dual-Writer Split-Brain Window ║ ║ 2026-08-18 ║ ║ ║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════════════════════════╝
PostgreSQL Failover: Hunting the Dual-Writer Split-Brain Window
Table of Contents
- The Context / The Problem
- The Deep-Dive / Root Cause Analysis
- The Implementation / Architecture
- Lessons Learned & Best Practices
- References
The Context / The Problem
During an automated maintenance failover between physical bare-metal nodes, an unexpected container networking race condition opened an eighty-three-second dual-writer window across our primary and standby database instances. Writes continued landing on the supposedly demoted primary while the newly promoted replica minted a divergent timeline, plunging our core PostgreSQL cluster into a textbook split-brain crisis.
In our autonomous system architecture, stateful services run inside lightweight Incus system containers spread across physical hosts in Kansas City (pve-kc-01) and New York (pve-ny-01). The database cluster powers our custom API backends, subscriber billing ledgers, and authoritative DNS record state. Application services communicate with the database via a floating overlay service IP (10.99.0.50) exposed through container IP aliasing and routed across our internal WireGuard mesh.
On August 18th, we scheduled a rolling kernel upgrade on the Kansas City host. The plan called for an automated failover: promote the New York standby container (db-standby-ny), reassign the overlay service IP, and then reboot pve-kc-01.
The failover automation triggered as expected:
- The New York standby issued
pg_ctl promote. - BIRD on
pve-ny-01announced the overlay service IP10.99.0.50/32across the internal iBGP mesh. - The host reboot command was issued to
pve-kc-01.
However, the shutdown sequence on pve-kc-01 encountered a delayed systemd unmount on a separate storage pool. During this delayed shutdown, the old primary container (db-primary-kc) remained alive and accessible to local backend workers running on the same host. Because those workers communicated over a local bridge interface (incusbr0) rather than traversing the external BGP mesh, they continued executing INSERT and UPDATE transactions directly against db-primary-kc.
Simultaneously, edge API proxies in Frankfurt and New York updated their routing tables to point at the newly promoted db-standby-ny, successfully executing write queries against it.
For eighty-three agonizing seconds, two PostgreSQL instances acted as writable primaries simultaneously. When pve-kc-01 finally completed its reboot cycle and attempted to reconnect db-primary-kc as a standby to New York, PostgreSQL aborted with a catastrophic error:
2026-08-18T04:12:31.402Z [41829] FATAL: timeline 2 is not a child of timeline 1 2026-08-18T04:12:31.403Z [41829] LOG: could not receive data from WAL stream: FATAL: requested starting point 0/1F900000 is on different timeline 2026-08-18T04:12:31.404Z [41829] LOG: startup process exited with exit code 1 2026-08-18T04:12:31.405Z [41828] LOG: aborting startup due to startup process failure
The database had branched into two conflicting universes.
The Deep-Dive / Root Cause Analysis
To resolve the divergence without losing customer transactions, we had to dissect PostgreSQL's write-ahead log (WAL) timeline architecture and understand how container network namespaces masked the split-brain.
The Anatomy of a Timeline Fork
PostgreSQL enforces data integrity using Timelines. Whenever a standby is promoted to a read-write primary, it increments its timeline counter (from Timeline 1 to Timeline 2) and records the Log Sequence Number (LSN) where the fork occurred in a history file (00000002.history):
Primary KC (Timeline 1) ... ---> [LSN: 0/1F8A9200] ----> [LSN: 0/1F8BA410] ----> [LSN: 0/1F900000] (14 Local Writes) | | Standby Promoted (pg_ctl promote) v Standby NY (Timeline 2) [LSN: 0/1F8A9200] ----> [LSN: 0/1F8CA000] ----> [LSN: 0/1F954000] (312 Edge Writes)
At LSN 0/1F8A9200, New York severed replication and became Timeline 2. But Kansas City was never fenced! During the 83-second shutdown delay:
- Kansas City generated 14 transactions on Timeline 1 past the fork point.
- New York generated 312 transactions on Timeline 2 past the fork point.
Because Timeline 1 had written data past 0/1F8A9200, Kansas City could not simply rewind its clock or become a streaming replica. PostgreSQL's storage engine guarantees write atomicity by refusing to overwrite pages if the local timeline contains uncommitted or conflicting WAL records.
Inspecting Divergent Records with pg_waldump
We dumped the WAL segments on Kansas City starting from the branch point to discover what transactions were committed in isolation:
pg_waldump -p /var/lib/postgresql/16/main/pg_wal \ -s 0/1F8A9200 -e 0/1F900000 00000001000000000000001F
The output revealed the exact nature of the 14 orphaned transactions:
rmgr: Heap len(rec): 78, tg: 0, desc: INSERT ... rel 16384/16385/16410 (billing_events) rmgr: Transaction len(rec): 34, tg: 0, desc: COMMIT 2026-08-18 04:11:15.112 UTC rmgr: Heap len(rec): 82, tg: 0, desc: UPDATE ... rel 16384/16385/16422 (auth_tokens) rmgr: Transaction len(rec): 34, tg: 0, desc: COMMIT 2026-08-18 04:11:18.420 UTC
Twelve of the transactions were ephemeral session token updates, but two were critical automated billing ledger entries. If we had blindly wiped Kansas City's data directory with a fresh pg_basebackup, those financial transactions would have vanished without a trace.
The Container IP Aliasing Trap
Why did Kansas City receive writes after New York took over the service IP?
- The service IP
10.99.0.50was configured as a secondary IP on the container'seth0viasystemd-networkd. - When BIRD on New York announced
10.99.0.50/32via BGP, the global mesh updated properly. - However, on host
pve-kc-01, local routing table lookup rules prioritizelocalinterface table lookups over external WireGuard interface tables. - As long as
db-primary-kc's virtual interface was physically up, any service residing onpve-kc-01routed packets directly into the local container, completely ignoring the BGP withdrawal!
The Implementation / Architecture
Resolving this incident required a precise surgical procedure: extracting the orphaned transactions from the old primary, rolling its data directory back to the exact point of divergence using pg_rewind, replaying the extracted data onto the active primary, and implementing a hard STONITH fencing mechanism.
+---------------------------------------------------------------------------------------+ | FENCING & PROMOTION FLOW | +---------------------------------------------------------------------------------------+ [Failover Coordinator] | | 1. Hard Fence (Incus Force Stop + Null Route) v +----------------------+ | Old Primary (kc) | ----> Container FROZEN / Network CUT +----------------------+ | | 2. Verify Zero Traffic & Standby Catchup v +----------------------+ | New Primary (ny) | ----> pg_ctl promote (Timeline 2 Minted) +----------------------+ | | 3. pg_rewind & Re-attach v +----------------------+ | Rebuilt Standby (kc) | <---- Streaming replication on Timeline 2 +----------------------+
1. Data Recovery and Timeline Reconciliation
First, we prevented any further corruption by stopping the Kansas City instance:
incus exec db-primary-kc -- systemctl stop postgresql
Next, we brought up Kansas City on an isolated loopback port (5433) in read-only mode to extract the orphaned billing records using custom SQL:
-- Extracted from Kansas City (Timeline 1) COPY ( SELECT id, account_id, amount_cents, currency, event_type, created_at FROM billing_events WHERE created_at >= '2026-08-18 04:11:00' ) TO '/tmp/orphaned_billing_events.csv' WITH CSV HEADER;
We verified that the IDs did not collide with any records inserted on New York, and inserted them directly into New York (db-standby-ny).
2. Rewinding Timeline 1 with pg_rewind
Instead of transferring 400 GB of base backup across the transatlantic WAN link, we used PostgreSQL's built-in pg_rewind utility. pg_rewind finds the divergence point (0/1F8A9200), copies only the changed blocks from the source server (New York), and aligns the WAL control data to Timeline 2:
incus exec db-primary-kc -- sudo -u postgres pg_rewind \ --target-pgdata=/var/lib/postgresql/16/main \ --source-server="host=10.200.0.20 port=5432 user=replicator password=REDACTED_SECRET dbname=postgres sslmode=require" \ --progress
The output confirmed clean synchronization:
pg_rewind: connected to server pg_rewind: servers diverged at WAL location 0/1F8A9200 on timeline 1 pg_rewind: rewinding from last common checkpoint at 0/1F8A9150 on timeline 1 pg_rewind: reading source file list pg_rewind: reading target file list pg_rewind: need to copy 84 MB of data 84/84 MB (100%) copied pg_rewind: creating backup label and setting timeline to 2 pg_rewind: SUCCESS
Next, we signaled PostgreSQL to start as a standby by generating the signal file and configuring primary_conninfo:
incus exec db-primary-kc -- bash -c " touch /var/lib/postgresql/16/main/standby.signal cat <<EOF >> /var/lib/postgresql/16/main/postgresql.auto.conf primary_conninfo = 'host=10.200.0.20 port=5432 user=replicator password=REDACTED_SECRET application_name=db_kc sslmode=require' primary_slot_name = 'standby_kc_slot' EOF chown postgres:postgres /var/lib/postgresql/16/main/standby.signal systemctl start postgresql "
Within seconds, Kansas City attached to New York, replayed WAL along Timeline 2, and achieved zero replication lag.
3. Bulletproof STONITH Fencing Script for Incus
To guarantee that a dual-writer condition can never reoccur, we created a strict fencing and promotion orchestrator (pg-fence-and-promote.sh). Standbys are strictly forbidden from promoting until the old primary has been physically cut off:
#!/usr/bin/env bash set -euo pipefail # pg-fence-and-promote.sh # Hard-fencing STONITH failover for Incus PostgreSQL clusters OLD_PRIMARY_HOST="pve-kc-01" OLD_CONTAINER="db-primary-kc" NEW_PRIMARY_CONTAINER="db-standby-ny" OVERLAY_IP="10.99.0.50" echo "=== [Phase 1] Hard Fencing Old Primary ===" echo "Issuing forced container stop on ${OLD_PRIMARY_HOST}:${OLD_CONTAINER}..." # Force terminate container execution immediately ssh -o ConnectTimeout=3 "root@${OLD_PRIMARY_HOST}" \ "incus stop ${OLD_CONTAINER} --force || true" # Null-route the container IP on the host bridge as a secondary safety net ssh -o ConnectTimeout=3 "root@${OLD_PRIMARY_HOST}" \ "ip route replace blackhole ${OVERLAY_IP}/32 || true" echo "Fencing verification: confirming container state is STOPPED..." CONTAINER_STATE=$(ssh "root@${OLD_PRIMARY_HOST}" \ "incus info ${OLD_CONTAINER} | grep -E '^Status:' | awk '{print \$2}'") if [[ "${CONTAINER_STATE}" != "STOPPED" ]]; then echo "CRITICAL: Fencing failed! Container status is ${CONTAINER_STATE}. Aborting promotion!" >&2 exit 1 fi echo "Old primary successfully fenced and offline." echo "" echo "=== [Phase 2] Promoting Standby to Read-Write Primary ===" incus exec "${NEW_PRIMARY_CONTAINER}" -- sudo -u postgres pg_ctl promote -D /var/lib/postgresql/16/main echo "Verifying read-write status on ${NEW_PRIMARY_CONTAINER}..." IS_STANDBY=$(incus exec "${NEW_PRIMARY_CONTAINER}" -- sudo -u postgres psql -t -A -c "SELECT pg_is_in_recovery();") if [[ "${IS_STANDBY}" != "f" ]]; then echo "CRITICAL: Promotion failed! Database is still in recovery." >&2 exit 1 fi echo "Standby successfully promoted to Primary (pg_is_in_recovery = false)." echo "" echo "=== [Phase 3] Re-binding Overlay Service IP ===" incus exec "${NEW_PRIMARY_CONTAINER}" -- ip addr add "${OVERLAY_IP}/32" dev eth0 || true birdc configure echo "BGP route converged. Failover completed safely without split-brain."
4. Systemd Pre-Start Lease Protection
To protect against inadvertent reboot restarts before fencing is cleared, we added a drop-in override (/etc/systemd/system/postgresql@.service.d/override.conf):
[Service] # Ensure node cannot start without verifying it is not a stale unassigned primary ExecStartPre=/usr/local/bin/pg-verify-cluster-lease.sh
Where pg-verify-cluster-lease.sh validates that if standby.signal is absent, the host must possess the active leadership lock on our consensus ledger.
Lessons Learned & Best Practices
- Never Promote Without a Hard Fence (STONITH): Hope is not an architectural strategy. Standbys must never promote based solely on a lost heartbeat. The coordinator must actively kill the old primary (
incus stop --force) and verify confirmation before promoting. - Local Kernel Routing Bypasses BGP Withdrawals: If client applications run on the same physical host as a container, local kernel routing tables take precedence over overlay BGP mesh routes. Withdrawing a BGP route does not stop local intra-host traffic.
- Master
pg_rewindBefore Disaster Strikes: When a split-brain happens,pg_rewindis the difference between a 30-second resync and hours of downtime streaming a massive multi-terabytepg_basebackup. - Audit WAL Timelines Immediately: When divergence occurs, never panic and never discard WAL logs. Use
pg_waldumpto isolate the divergence point and check for orphan committed transactions before re-aligning timelines.