|
| 1 | +# Raft failover walkthrough |
| 2 | + |
| 3 | +This is the operational companion to [`40-raft-replication.md`](./40-raft-replication.md). |
| 4 | +That doc describes the architecture; this one shows what failover |
| 5 | +actually looks like from outside the cluster — the commands you type, |
| 6 | +the JSON you see, the seconds you wait. The scenario is the textbook |
| 7 | +one: the raft leader dies, the surviving followers run an election, |
| 8 | +clients keep submitting tasks. |
| 9 | + |
| 10 | +We use the `deploy/docker-compose/raft-cluster/` template so the steps |
| 11 | +are reproducible on any developer box. All ports below are container |
| 12 | +host-mapped: HTTP on `:8080`, `:8081`, `:8082`; raft mux on `:7000` |
| 13 | +inside each container (not published — peers talk on the docker bridge |
| 14 | +network). |
| 15 | + |
| 16 | +## 1. Setup |
| 17 | + |
| 18 | +```bash |
| 19 | +# Build the image (once). |
| 20 | +docker build -f deploy/docker-compose/cluster/Dockerfile -t codeq-service:cluster . |
| 21 | + |
| 22 | +# Bring the cluster up. |
| 23 | +docker compose -f deploy/docker-compose/raft-cluster/compose.yaml up -d |
| 24 | +``` |
| 25 | + |
| 26 | +Three codeq processes start: `node-a`, `node-b`, `node-c`. Only |
| 27 | +`node-a` has `RAFT_BOOTSTRAP=true` — the other two come up with the |
| 28 | +flag false and wait for AppendEntries from a leader. The template |
| 29 | +defaults to a single Pebble shard, so there is exactly one raft group |
| 30 | +covering the whole keyspace; with `numShards: N` in |
| 31 | +`PERSISTENCE_CONFIG` you'd see N groups, each with its own leader. |
| 32 | + |
| 33 | +After ~2 seconds, `node-a` has bootstrapped and won the initial |
| 34 | +election. Query any node's status endpoint to confirm: |
| 35 | + |
| 36 | +```bash |
| 37 | +curl -s http://localhost:8080/v1/codeq/raft/status | jq . |
| 38 | +``` |
| 39 | + |
| 40 | +```json |
| 41 | +{ |
| 42 | + "enabled": true, |
| 43 | + "numGroups": 1, |
| 44 | + "groups": [ |
| 45 | + { |
| 46 | + "shardIdx": 0, |
| 47 | + "isLeader": true, |
| 48 | + "selfId": "node-a", |
| 49 | + "selfAddr": "node-a:7000", |
| 50 | + "leaderId": "node-a", |
| 51 | + "leaderAddr": "node-a:7000", |
| 52 | + "leaderHTTPAddr": "http://node-a:8080", |
| 53 | + "hasLeader": true |
| 54 | + } |
| 55 | + ] |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +The `leaderHTTPAddr` comes from `RAFT_PEER_HTTP_ADDRS` in the compose |
| 60 | +file. It's how followers know where to redirect writes. Implementation: |
| 61 | +`pkg/config/config.go` (`RaftConfig.PeerHTTPAddrs`) and |
| 62 | +`internal/controllers/raft_status_controller.go:18`. |
| 63 | + |
| 64 | +## 2. Submit a task — leader path |
| 65 | + |
| 66 | +Send a write to the leader (`:8080`): |
| 67 | + |
| 68 | +```bash |
| 69 | +curl -s -X POST http://localhost:8080/v1/codeq/tasks \ |
| 70 | + -H 'Authorization: Bearer dev-token' \ |
| 71 | + -H 'Content-Type: application/json' \ |
| 72 | + -d '{"command":"GENERATE_MASTER","payload":{"k":"v"},"priority":5}' | jq . |
| 73 | +``` |
| 74 | + |
| 75 | +```json |
| 76 | +{ |
| 77 | + "id": "7e84c2c0-fc9f-4cbd-9e2a-1a0b1c2d3e4f", |
| 78 | + "status": "PENDING", |
| 79 | + "command": "GENERATE_MASTER", |
| 80 | + "priority": 5, |
| 81 | + "attempts": 0 |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +The write went through `raft.Apply` on `node-a`, was replicated to |
| 86 | +`node-b` and `node-c` via AppendEntries, and committed when a majority |
| 87 | +(2 of 3) acked. Now read the task back from a follower: |
| 88 | + |
| 89 | +```bash |
| 90 | +curl -s http://localhost:8081/v1/codeq/tasks/7e84c2c0-fc9f-4cbd-9e2a-1a0b1c2d3e4f \ |
| 91 | + -H 'Authorization: Bearer dev-token' | jq .id |
| 92 | +``` |
| 93 | + |
| 94 | +```json |
| 95 | +"7e84c2c0-fc9f-4cbd-9e2a-1a0b1c2d3e4f" |
| 96 | +``` |
| 97 | + |
| 98 | +The follower returns the task without contacting the leader — reads |
| 99 | +are local on every node, served straight out of that node's Pebble. |
| 100 | +That's the design point in `40-raft-replication.md` § Leadership: |
| 101 | +"Reads are local on every node, with no consensus round; followers may |
| 102 | +serve stale data." Stale-tolerant reads scale linearly with replicas. |
| 103 | + |
| 104 | +## 3. Submit a task — follower path with 307 redirect |
| 105 | + |
| 106 | +Now try a write against a follower (`:8081`). Use `-i` to see the |
| 107 | +response headers: |
| 108 | + |
| 109 | +```bash |
| 110 | +curl -i -s -X POST http://localhost:8081/v1/codeq/tasks \ |
| 111 | + -H 'Authorization: Bearer dev-token' \ |
| 112 | + -H 'Content-Type: application/json' \ |
| 113 | + -d '{"command":"GENERATE_MASTER","payload":{"who":"follower"}}' |
| 114 | +``` |
| 115 | + |
| 116 | +``` |
| 117 | +HTTP/1.1 307 Temporary Redirect |
| 118 | +Location: http://node-a:8080/v1/codeq/tasks |
| 119 | +Content-Type: application/json |
| 120 | +
|
| 121 | +{"error":"not leader","leader":"http://node-a:8080"} |
| 122 | +``` |
| 123 | + |
| 124 | +The follower didn't try to forward the write. It returned `307` with |
| 125 | +`Location` pointing at the leader's HTTP base URL. Per RFC 7231, |
| 126 | +clients re-send the same method and body to the new URL (unlike the |
| 127 | +older 302 which historically degraded POST to GET). Go's `net/http` |
| 128 | +follows automatically when the request body is replayable |
| 129 | +(`bytes.Reader`, `strings.Reader`, or `GetBody`); curl follows with |
| 130 | +`-L`. |
| 131 | + |
| 132 | +Implementation: |
| 133 | +- `internal/controllers/respond.go:34` (`maybeRedirectLeader`) |
| 134 | +- `pkg/domain/errors.go:11` (`LeaderHint` interface, satisfied by raft |
| 135 | + errors that carry a leader URL hint) |
| 136 | + |
| 137 | +With `-L`, curl follows the redirect transparently: |
| 138 | + |
| 139 | +```bash |
| 140 | +curl -L -s -X POST http://localhost:8081/v1/codeq/tasks \ |
| 141 | + -H 'Authorization: Bearer dev-token' \ |
| 142 | + -H 'Content-Type: application/json' \ |
| 143 | + -d '{"command":"GENERATE_MASTER","payload":{"who":"follower"}}' | jq .id |
| 144 | +``` |
| 145 | + |
| 146 | +```json |
| 147 | +"3d51f8ea-1234-4abc-9def-0123456789ab" |
| 148 | +``` |
| 149 | + |
| 150 | +Note: the compose template's `Location` URL uses the container |
| 151 | +hostname (`node-a:8080`), only resolvable from inside the docker |
| 152 | +bridge. From the host curl sees the redirect but can't reach it — a |
| 153 | +deploy artifact, not a protocol issue. Real deployments set |
| 154 | +`RAFT_PEER_HTTP_ADDRS` to externally-reachable URLs. |
| 155 | + |
| 156 | +## 4. Kill the leader |
| 157 | + |
| 158 | +Identify the current leader: |
| 159 | + |
| 160 | +```bash |
| 161 | +curl -s http://localhost:8080/v1/codeq/raft/status \ |
| 162 | + | jq -r '.groups[] | select(.isLeader) | .selfId' |
| 163 | +# node-a |
| 164 | +``` |
| 165 | + |
| 166 | +Stop it: |
| 167 | + |
| 168 | +```bash |
| 169 | +docker compose -f deploy/docker-compose/raft-cluster/compose.yaml stop node-a |
| 170 | +``` |
| 171 | + |
| 172 | +The two survivors form a majority of the 3-node cluster, and raft |
| 173 | +guarantees one of them holds the most up-to-date log — so one will |
| 174 | +win the next election. A partitioned (rather than killed) ex-leader |
| 175 | +would discover on rejoin that the cluster moved to a higher term and |
| 176 | +step down: no split-brain. |
| 177 | + |
| 178 | +## 5. Election timeline |
| 179 | + |
| 180 | +With the library defaults from `internal/raft/db.go:53-97` |
| 181 | +(`HeartbeatMS=1000`, `ElectionMS=1000`, `LeaderLeaseMS=500`), the |
| 182 | +sequence of events after killing the leader is: |
| 183 | + |
| 184 | +| Time | Event | |
| 185 | +|---|---| |
| 186 | +| `t = 0.0s` | `node-a` process killed. Followers still consider it leader (lease held). | |
| 187 | +| `0 → heartbeatMS` | Followers stop receiving AppendEntries. Heartbeat timer ticks toward expiry. | |
| 188 | +| `~heartbeatMS` | Heartbeat timeout fires on followers. Leader lease (`LeaderLeaseMS = 500ms`) expires. | |
| 189 | +| `~heartbeatMS + jitter` | One follower transitions Follower → Candidate, increments `currentTerm`, votes for itself, broadcasts `RequestVote` RPCs. The jitter (random fraction of `ElectionMS`) prevents two followers from becoming candidates simultaneously and split-voting. | |
| 190 | +| `~heartbeatMS + RTT` | Other follower grants the vote (it has no higher-term candidate to prefer). Candidate has 2 votes ≥ majority of 3 → wins. | |
| 191 | +| `~heartbeatMS + RTT` | Winner transitions Candidate → Leader, broadcasts an empty `AppendEntries` (heartbeat) to claim leadership. | |
| 192 | +| `~heartbeatMS + RTT + small` | Clients hitting any node now see the new `leaderId` in `/v1/codeq/raft/status`. Writes against the old leader's URL fail (process down); writes against followers get a 307 to the new leader. | |
| 193 | + |
| 194 | +End-to-end with defaults: ~1-3 seconds of write unavailability. For |
| 195 | +faster failover in tests, drop the values to e.g. 200ms/200ms via |
| 196 | +`RAFT_HEARTBEAT_MS` / `RAFT_ELECTION_MS`. Trade-off: tighter timeouts |
| 197 | +trigger more spurious elections under transient network hiccups, so |
| 198 | +production usually keeps the 1-second defaults and budgets the wait |
| 199 | +on the client side. |
| 200 | + |
| 201 | +Reads are unaffected throughout this window — `node-b` and `node-c` |
| 202 | +keep serving GETs from their local Pebble. |
| 203 | + |
| 204 | +## 6. Observe the new leader |
| 205 | + |
| 206 | +```bash |
| 207 | +curl -s http://localhost:8081/v1/codeq/raft/status \ |
| 208 | + | jq '.groups[] | {selfId, isLeader, leaderId, leaderHTTPAddr}' |
| 209 | +``` |
| 210 | + |
| 211 | +```json |
| 212 | +{ |
| 213 | + "selfId": "node-b", |
| 214 | + "isLeader": true, |
| 215 | + "leaderId": "node-b", |
| 216 | + "leaderHTTPAddr": "" |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +(`leaderHTTPAddr` is empty on a node that is *itself* the leader — the |
| 221 | +field is for redirect hints to other peers.) |
| 222 | + |
| 223 | +Submit a fresh write to confirm the cluster is writable again: |
| 224 | + |
| 225 | +```bash |
| 226 | +curl -s -X POST http://localhost:8081/v1/codeq/tasks \ |
| 227 | + -H 'Authorization: Bearer dev-token' \ |
| 228 | + -H 'Content-Type: application/json' \ |
| 229 | + -d '{"command":"GENERATE_MASTER","payload":{"after":"failover"}}' | jq .id |
| 230 | +``` |
| 231 | + |
| 232 | +```json |
| 233 | +"a1b2c3d4-5678-49ab-bcde-f01234567890" |
| 234 | +``` |
| 235 | + |
| 236 | +`node-c` is still a follower: |
| 237 | + |
| 238 | +```bash |
| 239 | +curl -s http://localhost:8082/v1/codeq/raft/status \ |
| 240 | + | jq '.groups[] | {selfId, isLeader, leaderId}' |
| 241 | +``` |
| 242 | + |
| 243 | +```json |
| 244 | +{ |
| 245 | + "selfId": "node-c", |
| 246 | + "isLeader": false, |
| 247 | + "leaderId": "node-b" |
| 248 | +} |
| 249 | +``` |
| 250 | + |
| 251 | +If you POST to `:8082` now, you'll get a 307 to `http://node-b:8080`. |
| 252 | + |
| 253 | +## 7. Restart the dead node |
| 254 | + |
| 255 | +```bash |
| 256 | +docker compose -f deploy/docker-compose/raft-cluster/compose.yaml start node-a |
| 257 | +``` |
| 258 | + |
| 259 | +What happens internally: |
| 260 | + |
| 261 | +1. `node-a` starts; raft reads its persisted log + stable state from |
| 262 | + the Pebble store under `/var/lib/codeq/pebble/` (logs under the |
| 263 | + `raft/log/` key prefix, see `internal/raft/log_store.go`). |
| 264 | +2. It tries to resume as leader at its old term. The first |
| 265 | + `AppendEntries` from `node-b` (the current leader) carries a higher |
| 266 | + term, so `node-a` immediately steps down to follower and adopts the |
| 267 | + new term. |
| 268 | +3. Raft replays any log entries `node-a` missed during downtime. Short |
| 269 | + downtime → entries shipped via `AppendEntries`. Long downtime |
| 270 | + beyond the leader's log retention → leader sends `InstallSnapshot` |
| 271 | + to ship a Pebble snapshot of the FSM state (see |
| 272 | + `internal/raft/snapshot.go`, snapshot threshold is `SnapshotEntries |
| 273 | + = 8192` by default — `internal/raft/db.go:85-90`). |
| 274 | +4. Once `node-a`'s `commitIndex` matches the leader's, it's a healthy |
| 275 | + follower again — serving local reads, voting in future elections. |
| 276 | + |
| 277 | +Verify: |
| 278 | + |
| 279 | +```bash |
| 280 | +curl -s http://localhost:8080/v1/codeq/raft/status \ |
| 281 | + | jq '.groups[] | {selfId, isLeader, leaderId}' |
| 282 | +``` |
| 283 | + |
| 284 | +```json |
| 285 | +{ |
| 286 | + "selfId": "node-a", |
| 287 | + "isLeader": false, |
| 288 | + "leaderId": "node-b" |
| 289 | +} |
| 290 | +``` |
| 291 | + |
| 292 | +`node-a` is back, sees `node-b` as leader, no longer claims |
| 293 | +leadership. The cluster is back to 3 healthy replicas. |
| 294 | + |
| 295 | +## 8. Failure modes that don't recover automatically |
| 296 | + |
| 297 | +Honest accounting: |
| 298 | + |
| 299 | +- **Two nodes down (loss of majority quorum)**. The survivor cannot |
| 300 | + elect itself — raft requires `⌈N/2⌉+1 = 2` of 3. Writes fail until |
| 301 | + one of the others restarts. Reads still work on the survivor (stale- |
| 302 | + tolerant). Canonical raft availability trade for CP behavior. |
| 303 | + |
| 304 | +- **Symmetric network partition (1 vs 2)**. The lone node can't reach |
| 305 | + a quorum, so its election attempts fail. The 2-node side elects a |
| 306 | + leader and accepts writes. On heal, the isolated node receives a |
| 307 | + higher-term `AppendEntries`, drops any uncommitted entries, and |
| 308 | + catches up. Majority quorum is what prevents split-brain. |
| 309 | + |
| 310 | +- **All three nodes down**. Data is durable on every node's Pebble |
| 311 | + store. Starting one node back gives a 1-node cluster with no leader. |
| 312 | + Once a second comes up they elect together; the third joins on |
| 313 | + restart. No data-loss path unless disks are also lost. |
| 314 | + |
| 315 | +- **Disk loss on one node**. The damaged node's raft log + Pebble |
| 316 | + state are gone. Fix: wipe its data directory |
| 317 | + (`docker volume rm codeq-raft_node-a-data`) and restart with |
| 318 | + `RAFT_BOOTSTRAP=false`. Raft sees an empty log and receives |
| 319 | + `InstallSnapshot` from the leader. |
| 320 | + |
| 321 | +- **All disks lost**. No recovery — Pebble is the source of truth. |
| 322 | + Backups (Pebble checkpoint API, filesystem snapshots) are an |
| 323 | + operator concern; codeq does not currently ship a managed backup |
| 324 | + tool. |
| 325 | + |
| 326 | +## 9. What clients should do |
| 327 | + |
| 328 | +**HTTP clients**: |
| 329 | +- Follow 3xx redirects. Go's `http.Client` does this by default (up to |
| 330 | + 10 hops); pass `-L` to curl. Redirect is 307 so POST bodies replay |
| 331 | + per RFC 7231. |
| 332 | +- Optionally cache the resolved leader URL between calls to skip the |
| 333 | + redirect round-trip. |
| 334 | + |
| 335 | +**gRPC streaming clients** (`pkg/producerclient`, `pkg/workerclient`): |
| 336 | +- gRPC has no 307 equivalent. A stream against a non-leader returns |
| 337 | + `pebble: not leader` at the application layer. |
| 338 | +- On `not leader`: close the stream, reconnect to a different node |
| 339 | + from the member list, retry with the project's standard backoff. In |
| 340 | + multi-shard raft a single node leads only some shards, so the same- |
| 341 | + tenant traffic naturally fans out. |
| 342 | +- Under multi-shard + high concurrency, `not leader` is a normal |
| 343 | + background condition — a routing hint, not a failure. |
| 344 | + |
| 345 | +Server-side gRPC forwarding (where a follower transparently proxies |
| 346 | +the request to the leader) is on the roadmap but not yet implemented; |
| 347 | +see [`40-raft-replication.md`](./40-raft-replication.md) § |
| 348 | +"What's NOT covered yet". |
| 349 | + |
| 350 | +## See also |
| 351 | + |
| 352 | +- [`40-raft-replication.md`](./40-raft-replication.md) — architecture, |
| 353 | + config knobs, mux transport, mutual exclusion with the legacy |
| 354 | + cluster mode |
| 355 | +- [`29-operational-runbooks.md`](./29-operational-runbooks.md) — other |
| 356 | + ops procedures (backups, snapshots, capacity) |
| 357 | +- [`05-cluster-architecture.md`](./05-cluster-architecture.md) — the |
| 358 | + legacy static-ring mode raft replaces |
| 359 | +- [`deploy/docker-compose/raft-cluster/README.md`](../deploy/docker-compose/raft-cluster/README.md) |
| 360 | + — the compose template used in this walkthrough |
0 commit comments