Skip to content

Commit f2ae489

Browse files
h4x3rotabclaude
andcommitted
docs(consul-postgres-ha): stage 4 failover demo recipe + measured RTO
Soft-kill leader-failover walkthrough verified end-to-end on the live cluster: Patroni elects via Consul KV, worker-4 promotes, writes resume, worker-3 rejoins as a streaming replica without pg_basebackup. Measured RTO ~24s (kill → first successful write on new leader), well within Patroni's default ttl=30s. Captures the reproducible recipe, a measured timeline, knobs for the RTO/availability tradeoff, and what's still untested (hard CVM kill, network partition, disk-loss rejoin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e724eba commit f2ae489

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Stage 4 — failover demo
2+
3+
A reproducible recipe for the soft-kill leader-failover scenario, plus the
4+
measured timeline from a real run on the live cluster (2026-05-03).
5+
This demonstrates that stage 4's HA story is end-to-end working: Patroni
6+
elects via Consul KV when the leader's lock expires, a replica is
7+
promoted, writes resume on the new leader, and the old leader rejoins
8+
cheaply (WAL replay + streaming, no full pg_basebackup) once it comes
9+
back.
10+
11+
## What gets exercised
12+
13+
1. Patroni leader-election via Consul KV (TTL-driven lock expiry).
14+
2. Replica promotion + timeline bump.
15+
3. Streaming replication on the new leader.
16+
4. Old leader's cheap rejoin path (no full re-bootstrap through mesh-conn).
17+
18+
## Recipe
19+
20+
Set up env (cluster IDs from `RESUME.md`):
21+
22+
```bash
23+
GW=dstack-pha-prod5.phala.network
24+
W1=eb94f7cd4f726ea3e90380e9043ed15c1f9e67e9 # current leader (worker-3)
25+
W2=0e51c005457fbe994b55480aab06dfaf6c7f89b1 # worker-4
26+
W3=0889166bf09d84ea06e132c4b3cc7e2e7db586e0 # worker-5
27+
PW=$(ssh ... root@${W1}-22.${GW} "cat /tmp/dstack-runtime/secrets/patroni-superuser")
28+
```
29+
30+
### 1. Snapshot pre-state + mark a "before" row
31+
32+
```bash
33+
ssh ... root@${W1}-22.${GW} \
34+
"docker exec dstack-tester-1 sh -c 'curl -s http://127.0.0.1:18803/cluster' | jq"
35+
36+
ssh ... root@${W1}-22.${GW} "PGPASSWORD='$PW' docker exec -e PGPASSWORD dstack-patroni-1 \
37+
psql -h 127.0.0.1 -p 18703 -U postgres -d postgres \
38+
-c \"INSERT INTO demo(msg) VALUES ('before failover') RETURNING id, msg;\""
39+
```
40+
41+
Expected: `worker-3` leader, `worker-4` + `worker-5` replicas streaming with lag=0,
42+
timeline=15. Default Patroni config: `ttl=30, loop_wait=10, retry_timeout=10`.
43+
44+
### 2. Soft-kill the leader
45+
46+
```bash
47+
T_kill=$(date -u +%H:%M:%S.%N)
48+
ssh ... root@${W1}-22.${GW} "docker stop -t 0 dstack-patroni-1"
49+
```
50+
51+
### 3. Watch the election + first write on the new leader
52+
53+
```bash
54+
# Poll W4's /cluster endpoint every ~1s; promotion shows when the
55+
# leader-key expires from Consul KV (TTL=30s) and a replica wins.
56+
while ! curl -s http://127.0.0.1:18804/cluster | jq -e '.members[]|select(.role=="leader" and .name!="worker-3")' >/dev/null; do
57+
sleep 1
58+
done
59+
60+
# Try to write on whichever replica got promoted.
61+
ssh ... root@${W2}-22.${GW} "PGPASSWORD='$PW' docker exec -e PGPASSWORD dstack-patroni-1 \
62+
psql -h 127.0.0.1 -p 18704 -U postgres -d postgres \
63+
-c \"INSERT INTO demo(msg) VALUES ('after failover') RETURNING id;\""
64+
```
65+
66+
### 4. Bring the old leader back
67+
68+
```bash
69+
ssh ... root@${W1}-22.${GW} "docker start dstack-patroni-1"
70+
# Watch /cluster until worker-3 reports state=streaming, lag=0.
71+
```
72+
73+
### 5. Confirm cheap-rejoin (no pg_basebackup)
74+
75+
```bash
76+
ssh ... root@${W1}-22.${GW} \
77+
"docker logs --tail 40 dstack-patroni-1 2>&1 | grep -iE 'pg_basebackup|recovery|streaming|timeline'"
78+
```
79+
80+
Expected log lines (no `pg_basebackup`, just WAL replay + streaming):
81+
82+
```
83+
starting backup recovery with redo LSN 0/... checkpoint LSN 0/..., on timeline ID 15
84+
completed backup recovery with redo LSN 0/... and end LSN 0/...
85+
consistent recovery state reached at 0/...
86+
started streaming WAL from primary at 0/... on timeline 16
87+
```
88+
89+
## Measured timeline (run from 2026-05-03)
90+
91+
```
92+
T_kill 05:02:28.028 docker stop dstack-patroni-1 on worker-3
93+
T_new_leader 05:02:49.994 worker-4 promoted (timeline 15 → 16) +22s
94+
T_first_write 05:02:52.313 INSERT succeeds on worker-4 +24s ← RTO
95+
T_restart_W3 05:03:39.704 docker start dstack-patroni-1
96+
T_W3_rejoined 05:04:10.377 worker-3 streaming, lag=0 +31s
97+
```
98+
99+
**RTO (Recovery Time Objective): ~24 seconds.** That's the wall time
100+
from leader process death to first successful write on the new leader,
101+
sitting comfortably inside the default Patroni `ttl=30`.
102+
103+
## Tunables for the RTO/availability tradeoff
104+
105+
If 24s is too long for your workload, lower the Patroni dynamic config in
106+
Consul KV:
107+
108+
| Knob | Default | Effect of lowering |
109+
|---|---|---|
110+
| `ttl` | 30 | Faster TTL expiry → faster election; risk of false-positive failover under transient network blips |
111+
| `loop_wait` | 10 | Faster Patroni heartbeat loop on each peer |
112+
| `retry_timeout` | 10 | How long Patroni tolerates a flaky DCS before giving up |
113+
114+
A common production setting is `ttl=20, loop_wait=5, retry_timeout=5`
115+
for ~10–15s RTO. Don't go below `ttl >= 2 * loop_wait` (Patroni rejects).
116+
117+
## What this demo does NOT cover
118+
119+
* **Hard CVM kill** (whole VM down, not just the patroni container).
120+
RTO should be similar but exercises dstack/CVM-level recovery; mesh-conn
121+
ICE will need to redial when the old CVM comes back.
122+
* **Network partition**: split-brain isolation between coordinators
123+
vs workers. Patroni + Consul should handle it, but worth a separate
124+
test before claiming partition-tolerance.
125+
* **Disk loss on rejoin**: if the ex-leader's pgdata is wiped, rejoin
126+
WILL trigger a full pg_basebackup through mesh-conn. The
127+
~25 MB/s throughput and the QUIC transport mean that even a 10 GB
128+
rebuild takes ~7 minutes (acceptable), but it's a different code path.

0 commit comments

Comments
 (0)