PG Resilience Lab
An HA/DR lab that breaks a PostgreSQL cluster in ten ways and measures RTO, RPO and ledger integrity
What this project proves
- Ten failures × 3 runs: 0 of 91,700 acknowledged transfers lost, 0 applied twice, ledger always balanced
- Measured that cancelling a commit waiting for sync replication reports success and loses it (72 rows); fencing switched to terminate, 0 lost
- Two 17 → 18 upgrade paths compared: pg_upgrade --link 22.6 s write gap, logical replication + HAProxy cutover 2.4 s
- Recovered an accidental DROP TABLE with PITR to the xid just before it, found with pg_waldump; median 32 s
- Role
- Solo: cluster design, fault injection and measurement automation, analysis, six ADRs and a Korean runbook
- Period
- 2026.10 – 2026.10
- Stack
- PostgreSQL
- repmgr
- pgBackRest
- HAProxy
- Docker
- k6
- Bash
- Python
- NestJS
Key numbers
| Item | Value | Type | Evidence |
|---|---|---|---|
| Transfers lost after a success reply (30 runs) | 0 / 91,700 | Measured | View evidence |
| Median RTO · primary kill -9 | 7.07 s | Measured | View evidence |
| Median RTO · 45 s network isolation | 27.91 s | Measured | View evidence |
| Median RTO · split brain | 28.2 s | Measured | View evidence |
| Median RTO · DROP TABLE → PITR | 32.03 s | Measured | View evidence |
| 17 → 18 logical replication cutover write gap | 2.43 s | Measured | View evidence |
| 17 → 18 pg_upgrade --link write gap | 22.6 s | Measured | View evidence |
| Commits released by cancel, reported as success and lost | 72 / 620 | Measured | View evidence |
| Security checks passed | 18 / 18 | Measured | View evidence |
The problem
Most HA setups stop at 'it is configured'; nobody measures how many seconds it takes for writes to return when the primary really dies, or whether an acknowledged transaction can vanish. This lab sends 20 LedgerCore transfers per second while injecting ten failures three times each: primary stop, kill -9, server shutdown and restart, network isolation, a full WAL disk, a deleted data directory, a lost table file, an accidental DROP TABLE and split brain. Every run computes RTO, transfers lost after a success reply (RPO), double applications and the double-entry invariant.
Architecture
Three PostgreSQL 17 nodes (repmgr 5.5, one primary and two standbys) and a witness sit on the replication network; the LedgerCore API and HAProxy sit on the app network, so cutting only the replication network reproduces split brain with clients still reaching the old primary. synchronous_standby_names is 'ANY 1 (pg1, pg2, pg3)'. A health agent on each node (:8008) answers 200 on /primary only when the node is not in recovery, is not fenced and has a synchronous standby that replied within 3 s; HAProxy checks it every second. An isolated primary turns read-only and terminates sessions. Backups go to a dedicated pgBackRest repository host in TLS server mode (asynchronous WAL archiving, backups taken from a standby), and every connection is verify-full TLS from a lab CA. A runner container drives the k6 load, fault injection, healing and analysis.
Data model
Key constraints and indexes
- synchronous_standby_names = 'ANY 1 (pg1, pg2, pg3)': writes continue when one standby is lost
- HAProxy :5000 only routes to a primary with a live synchronous quorum
- Every connection is hostssl with verify-full and scram-sha-256; network superuser logins are rejected; credcheck requires 16 characters
- ledger_app role: statement_timeout 5 s · lock_timeout 2 s · idle_in_transaction_session_timeout 10 s
Key design decisions
ADR-0001 repmgr + witness
- Context
- Three nodes needed automatic failover, and I wanted to work with the mechanism directly using the tooling from my course.
- Decision
- repmgr 5.5 with a witness, primary_visibility_consensus, standby_disconnect_on_failover, and fencing the primary when its children disconnect
- Trade-off
- No DCS lock, so split-brain protection has to come from quorum routing and fencing
ADR-0002 Sync quorum ANY 1
- Context
- Naming a single synchronous standby, as in FIRST 1 (pg2), stops every write when pg2 stops.
- Decision
- synchronous_standby_names = 'ANY 1 (pg1, pg2, pg3)'
- Trade-off
- With pg2 stopped for 25 s, FIRST 1 produced 12,119 failed attempts and 213 abandoned transfers; ANY 1 produced none. Writes still stop if both standbys are gone
ADR-0003 Quorum-aware HAProxy health check
- Context
- In the first runs an isolated primary that came back was advertised to HAProxy for about 18 s before it fenced itself.
- Decision
- /primary returns 200 only when the node is not in recovery, not fenced and has a synchronous standby that replied within 3 s
- Trade-off
- If every standby is slow, a healthy primary also leaves the write pool; synchronous replication would be blocking its commits anyway
ADR-0006 Terminate, not cancel, during a sync wait
- Context
- Sessions waiting for a synchronous standby on a partitioned primary have to be cleared, and statement_timeout does not interrupt that wait.
- Decision
- Fencing uses only pg_terminate_backend; the runbook forbids cancel on SyncRep waits
- Trade-off
- Clients get a connection error and must retry; LedgerCore retries the same transfer safely with its idempotency key
Before and after
- What changed
- Releasing commits that wait for synchronous replication with pg_cancel_backend during a partition makes PostgreSQL report them as successful, and the rows are gone after failover. Fencing now uses only pg_terminate_backend and the runbook forbids cancel (ADR-0006).
Incident write-up
- Symptom
- During the ack-leak experiment both standbys failed to promote, logging recovery ended before configured recovery target was reached.
- Root cause
- The PITR in scenario 09 had left recovery_target_xid in postgresql.auto.conf, and every standby cloned afterwards copied it, so each promotion waited for a recovery target that had already passed.
- Fix
- ALTER SYSTEM RESET recovery_target_* now runs right after PITR, and clone and rejoin defensively strip recovery_target settings.
- Prevention
- Scenario 09's heal step now takes a fresh full backup after PITR, and the failure did not come back in the 30-run campaign after the fix, which included three PITRs.
Tests and quality
- Tests
- —
- Coverage
- —
- CI
- —
- Test tools
- 장애 시나리오 10종 × 3회, 실험 8종, 보안 점검 18개(security/run.sh), bash -n, LedgerCore 단위 테스트 3개(연결 끊김 수정)
Deployment and operations
- Deployment
- Docker Compose on one host. On Windows, .\lab.ps1 init writes a .env with random passwords and .\lab.ps1 up builds the images, issues certificates, bootstraps the cluster, takes the first full backup and installs the pg_cron jobs in one go.
- Backups
- A dedicated pgBackRest repository host (TLS server mode, zstd), asynchronous WAL archiving, full weekly, differential daily and incremental every 4 hours, taken from a standby. Every day the latest backup is restored to a scratch directory and checked for ledger integrity and checksums (restore 1.8 s + recovery 1.4 s, 0 violations). An S3 repo2 (aes-256-cbc) is configured but not measured.
- Rollback
- If pg_upgrade fails, the old 17 data directory is brought back automatically. .\lab.ps1 baseline rejoins or rebuilds broken nodes and switches pg1 back to primary.
- Monitoring
- A health agent per node (:8008), HAProxy stats (:8404), and repmgr events from every node collected into a timeline relative to the injection. PG Insight was used to watch the sync quorum, WAL archiving failures and table bloat.
Screenshots
Limits and retrospective
Current limits
- Every container runs on one host, so the lab validates the HA mechanisms (detection, election, fencing, routing, recovery) rather than survival of a real machine failure; synchronous replication also looks cheaper than it is.
- The numbers were measured in a 2 vCPU Linux sandbox with Ubuntu-based substitute images of the same packages; the repository Dockerfile uses postgres:17-bookworm and PGDG packages.
- repmgr has no DCS leader lock, so split-brain protection is the combination of a sync quorum, quorum-aware routing and a soft fence; Patroni + etcd gives stronger guarantees.
- The S3 repository (repo2) is configured but was not part of the measurements; there is no PgBouncer.
If I built it again
- Run the same scenarios on Patroni + etcd and compare RTO and split-brain behaviour with repmgr.
- Run it across at least two machines to measure the real network cost of synchronous replication.
Key code
node/bin/pg-health-agentAnswers 200 on /primary only for a primary with a quorum
docker/haproxy/haproxy.cfg1 s checks, fall 2, sessions shut down on a dead server
node/bin/fence-selfRead-only plus terminate (never cancel)
scenarios/09-accidental-drop-pitr.shFind the xid with pg_waldump → PITR → delta-rebuild the standbys
measure/analyze.pyComputes RTO, RPO, duplicates and ledger integrity
experiments/ack-leak.shMeasures losses under cancel and terminate
upgrade/logical.sh17 → 18 logical replication, sequence sync, HAProxy cutover
docs/runbook.ko.mdPer-failure response procedures (Korean)
Current status and next steps
Across 30 runs, 91,700 transfers received a success reply; none were lost, none were applied twice and the ledger never went out of balance. Median RTO was 6.7 to 7.5 s for process failures (stop, kill -9, restart, full disk, lost file), 24.7 s for a server shutdown, 27.9 s for network isolation, 28.2 s for split brain, 32.0 s for PITR and 35.9 s for a deleted data directory. The first runs exposed twelve problems that put data at risk or kept the cluster from healing itself, all fixed, including a LedgerCore bug where losing the database connection crashed the whole API process. For 17 → 18, pg_upgrade --link left a 22.6 s write gap and logical replication with a HAProxy cutover 2.4 s, both with nothing lost.