Skip to content

Commit e0abca3

Browse files
docs(raft): add failover walkthrough (docs/42) (#610)
Step-by-step operational walkthrough using the deploy/docker-compose/raft-cluster/ template: setup, leader/follower write paths, the 307 redirect on follower POSTs, killing the leader, the election timeline with library defaults, observing the new leader, restarting the dead node, and the failure modes raft does not recover from automatically. Companion to 40-raft-replication.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 52e246b commit e0abca3

1 file changed

Lines changed: 360 additions & 0 deletions

File tree

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
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

Comments
 (0)