|
| 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