gw-05 step 03 — Graceful drain and avoiding the reconnect storm
Goal
Deploy a node holding many live connections without dropping messages and without a reconnect stampede onto the rest of the fleet. This is the hardest operational problem in the phase and a guaranteed interview topic.
Code — drain with a reconnect signal
package pushy
import (
"context"
"encoding/json"
"time"
)
// Drain stops new connections, asks every connected device to reconnect
// (elsewhere) after a JITTERED delay, and waits for connections to bleed
// off — up to a long deadline.
func (n *Node) Drain(ctx context.Context, deadline time.Duration) {
// 1. (Caller already flipped readiness so the LB sends no NEW conns.)
// 2. Tell every device to reconnect after a random delay so they
// don't all hit the next node at the same instant.
n.mu.RLock()
conns := make([]*deviceConn, 0, len(n.conns))
for _, dc := range n.conns {
conns = append(conns, dc)
}
n.mu.RUnlock()
for _, dc := range conns {
// Full jitter: each client waits random(0, reconnectWindow).
delayMs := jitterMillis(30_000) // spread reconnects over 30s
msg, _ := json.Marshal(map[string]any{
"type": "reconnect", "afterMs": delayMs,
})
dc.enqueue(msg) // client schedules reconnect after delayMs + its own backoff
}
// 3. Wait for connections to drain, bounded by the deadline.
end := time.Now().Add(deadline)
for time.Now().Before(end) {
n.mu.RLock()
remaining := len(n.conns)
n.mu.RUnlock()
if remaining == 0 {
return
}
time.Sleep(500 * time.Millisecond)
}
// 4. Deadline hit: force-close stragglers (they reconnect via backoff).
n.closeAll()
}
Code — full jitter (the storm-prevention primitive)
import "math/rand"
// jitterMillis returns a uniformly random delay in [0, max) — "full
// jitter" from the AWS builders' library. This is what turns a
// synchronized stampede into a smooth ramp.
func jitterMillis(max int) int { return rand.Intn(max) }
// Client-side reconnect backoff (for reference): exponential with full
// jitter, capped. Used after an UNREQUESTED disconnect too.
func reconnectDelay(attempt int) time.Duration {
const base, cap = 500 * time.Millisecond, 30 * time.Second
d := base << attempt
if d > cap {
d = cap
}
return time.Duration(rand.Int63n(int64(d))) // full jitter
}
The experiment — see the storm, then prevent it
Simulate a fleet (a few nodes, N simulated devices) and a metric of "reconnects per second arriving at the rest of the fleet" during a drain.
A) Drain with NO jitter (all devices reconnect immediately):
reconnect arrivals spike to ~N in one tick → the next node's accept
queue overflows (gw-01) → some reconnects fail → clients retry →
the spike echoes. A self-inflicted DDoS.
B) Drain WITH full jitter over a 30s window:
reconnect arrivals are a flat ~N/30 per second → every node absorbs
its share → no accept-queue overflow → smooth migration.
Plot both. The difference is the entire lesson.
At-least-once + idempotency on reconnect
When a device reconnects mid-drain, an in-flight message may be redelivered (the registry pointed at the old node when it was published). Make delivery safe:
- every PushEvent carries MsgID
- the client tracks recently-seen MsgIDs and suppresses duplicates
- the server may redeliver freely (at-least-once) — dedupe makes it
effectively-once from the user's view
Tasks
- Implement
Drainwith full jitter and a long deadline; a simulated client that honors thereconnectmessage (waitsafterMs, then reconnects to another node with its own backoff+jitter). - Run experiment A vs B; plot reconnect arrivals/sec at the rest of the fleet. Capture the spike vs the flat ramp.
- Show a redelivered message during drain and the client suppressing
the duplicate via
MsgID— demonstrating at-least-once + idempotency. - Tie drain to Kubernetes: set a long
terminationGracePeriodSecondsand apreStophook that triggersDrain; explain why a default 30s grace period would force-close 200k connections (gw-09).
Acceptance
- A drain that migrates all devices off a node with no message loss and no reconnect spike (flat arrivals with jitter; a clear spike without it).
- A demonstrated duplicate suppressed by
MsgID. - A correct statement of the Kubernetes grace-period requirement for a high-density node.
Discussion prompts
- Why "full jitter" rather than "exponential backoff" alone? (Backoff spreads retries of one client; jitter de-synchronizes many clients. You need both.)
- Draining 200k connections over 30s = ~6.7k reconnects/sec leaving this node. Is that absorbable by a fleet of M nodes? Do the math and decide the right reconnect window.
- Scale-out adds nodes but existing connections don't move. So how do you actually cool a hot node — and why is that the same machinery as drain?