10 KiB
Multi-pod Redis adapter — postmortem
Local reproduction in minikube against the full broken stack
(888b87a3 + b42b9c45 + 9e9e5f4f, restored in working tree, NOT
on origin/main).
Setup
- 2 happy-server replicas behind a
LoadBalancerservice viaminikube tunnel - Single Redis (
@socket.io/redis-streams-adapter) - All tests use
transports: ['websocket'](matches prod client) - Test harnesses:
deploy/hammer.mjs,deploy/network-loss.mjs,deploy/test-rpc-cross-replica.mjs,deploy/test-multiprocess.mjs
What works (steady state)
| Test | Result | Notes |
|---|---|---|
| Cross-replica broadcast | ✅ 20/20 | Pod kill, fan-out, all 8 surviving clients undisrupted |
| Cross-pod RPC (steady) | ✅ 50/50 ×5 | Repeated; once warm, parallel + sequential RPCs all pass |
| LB cross-pod routing | ✅ | k8s service distributes new sockets across both pods |
The original 9e9e5f4f claim "Tested locally: 10/10 cross-replica RPC
calls pass" is true — in steady state, with no churn, no kills, no
network jitter, and no waiting longer than the TTL.
What's broken (4 reproduced bugs)
Bug #1: in-flight RPC eats the full 30s timeout when target pod dies
Repro: node deploy/hammer.mjs pod-kill-mid-rpc
[+ 1.85s] firing rpc-call (will block 5s in handler)
[+ 1.89s] daemon got rpc-request, sleeping 5s
[+ 2.85s] killing daemon pod handy-server-67b86c7b7c-2bc6f
[+ 2.94s] socket disconnect: transport close
[+ 3.27s] socket reconnected
[+31.85s] rpc-call result: ok=false latency=30002ms err=operation has timed out
- Daemon's pod is killed mid-call. Daemon socket transport-closes within 90ms.
- Daemon reconnects in 0.4s on a different pod.
- Caller's
rpc-callhangs for the full 30 seconds then times out.
Root cause:
rpcHandler.ts:159 does
io.to(targetSocketId).timeout(30000).emitWithAck('rpc-request', ...).
The streams adapter broadcasts the request through Redis. No replica
has the dead targetSocketId. No socket replies. emitWithAck waits
the full timeout. There is no fast-fail path for "the target socketId
no longer exists anywhere in the cluster."
The dead Redis key is not cleaned up either — the daemon's pod was SIGKILL'd so its disconnect handler never ran. Even on graceful shutdown the cleanup is best-effort.
Production manifestation: every pod recycle, every daemon reconnect → every concurrent web RPC eats 30s. Multiple retries from the client compound this into multi-minute hangs. This explains the user's "say lol after running ls 3 times took 3 minutes" report.
Bug #2: reconnect-storm race ⇒ ~6% RPC failures
Repro: node deploy/hammer.mjs reconnect-storm
results: success=178 fail=12
err: RPC method not available ×7 ← Redis key was deleted, not yet recreated
err: RPC target not reachable ×5 ← Redis key still pointed at dead socketId
- Daemon reconnects 5×, callers fire RPCs at 200ms intervals throughout.
- ~150ms reconnect window per cycle = ~1 RPC-window per reconnect per caller.
- 5 cycles × 5 callers ≈ 25 vulnerable calls, 12 fail.
Root cause: the daemon's disconnect handler runs a Lua CAS that
deletes the Redis key. The reconnect's rpc-register runs a fresh
SET. Between those two events any cross-pod caller sees one of:
- key absent →
RPC method not available - key present pointing at the old socketId (not yet cleaned up
because the daemon's disconnect handler ran on a different pod and
raced with the new register) →
RPC target not reachable
There is no atomic "swap" semantic. The design fundamentally cannot maintain RPC availability across a daemon socket transition.
Production manifestation: the prod web client logs showed 🔌
"Socket reconnected" followed by repeated fetchMessages → 0 messages
churn. Every reconnect = a small but real burst of RPC failures, which
cascade into the sync layer's invalidate loop.
Bug #3 (smoking gun): silent TTL expiry while daemon is connected
Repro: node deploy/hammer.mjs ttl-expiry, OR
node deploy/network-loss.mjs — both reproduce this at the +60s mark
without any network manipulation.
[+ 5.19s] t=+5s rpc: ok=true
[+ 30.25s] t=+30s rpc: ok=true
[+ 55.35s] t=+55s rpc: ok=true
[+ 65.35s] t=+65s rpc: ok=false err=RPC method not available
[+ 75.36s] t=+75s rpc: ok=false err=RPC method not available
- Daemon stays connected the whole time.
- After exactly 60 seconds the Redis key expires (
RPC_TTL_SECONDS = 60,rpcHandler.ts:6). - The daemon never knows. No code path on the daemon side detects the expiration and re-registers. The state stays broken until the daemon reconnects (which it might not do for hours).
Root cause: TTL refresh only happens inside machine-alive and
session-alive handlers (machineUpdateHandler.ts:53,
sessionUpdateHandler.ts:185). If for any reason the daemon
doesn't fire a keep-alive event in the 60-second window — slow
network, blocked event loop on either side, dropped UDP, paused
process, GC pause, anything — the registration vaporizes silently.
There is no:
- monitoring on the server to detect expired-but-still-connected sockets
- monitoring on the daemon to detect "my registration is gone"
- background re-registration timer on the daemon
Production manifestation: explains the cases where RPCs fail with "method not available" even though the daemon is "online" in the UI. Once it happens, it stays broken.
Bug #4: streams adapter unbounded-ish growth
Observation: kubectl exec happy-redis-0 -- redis-cli XINFO STREAM socket.io
length 4946
groups 0
recorded-first-entry-id 1775994568700-0 (~70 minutes ago)
last-generated-id 1775998585928-0 (now)
entries-added 4946
- The
socket.iostream has been growing for ~70 minutes. maxLen: 50000was configured (socket.ts:40) — we are not at it yet, but with enough activity we will be.groups: 0— the adapter does not use Redis consumer groups, just in-memory cursors per replica. After a pod restart, the new pod resumes from$(latest), losing whatever was written during the restart window.
This is not an immediate-fire bug but it (a) means events written during a pod restart are lost cross-replica, and (b) at higher load will trigger XADD trimming and increase Redis pressure.
What I could not reproduce locally
transport closestorms with no apparent pod activity (the user's prod symptom). My local minikube only producestransport closefrom explicitkubectl delete pod. Possible prod sources I didn't get to:- Cluster ingress / LB idle-timeout closing long-lived websockets
- K8s liveness probe killing pods on slow
/health(not seen here —/healthis fast) - OOMKill from Redis adapter buffering or pino logging
- Server-side ping timeout because event loop is blocked by the
refreshRpcRegistrationsSCAN+pipeline path on heavy users
- Network-blackout effects:
iptables -I OUTPUT -d <redis>applied viakubectl debug --profile=netadmin --target=handydid not visibly affect ongoing RPCs in my run. Either kube-proxy rewrites the destination before my rule, or conntrack ESTABLISHED state is bypassing the drop, or the streams adapter buffers gracefully through brief outages. Needs deeper packet capture work.
Why the original commits believed it worked
9e9e5f4f's test plan was steady-state cross-pod RPC with both
sockets connected and no churn. That's exactly the slice that does
work. The bugs all live in transitions: pod kill, reconnect, TTL roll.
None of them appear in a 10-second smoke test of "send RPC, get reply."
888b87a3's "tested on real LB, 216 events/sec, 49ms disconnect
detection" tests broadcast fan-out only — never RPC routing. That
is also the slice that works.
The fixes were shipped in sequence each catching the previous bug's shape, but none of them stress-tested the actual problem class: RPC routing identity is tied to a transient socketId, and there is no atomic update path across daemon socket transitions.
Minimum-viable fix candidates
I am NOT writing code yet — these are sketches for the next conversation.
-
Re-register on every reconnect (client side, daemon)
- Already done by happy-cli for fresh connects but the
reconnect path may not hit register again. Verify
rpcRegistryre-fires onsocket.connectafter disconnect. - Cheap, doesn't fix in-flight calls but stops bug #3 from sticking.
- Already done by happy-cli for fresh connects but the
reconnect path may not hit register again. Verify
-
Index registrations by
socketId → [methods]on each pod- Rebuild authoritative cleanup on disconnect without depending on TTL.
- Drop the 60s TTL entirely, or raise it to e.g. 1 hour and treat as a janitor-only safety net.
-
Fast-fail RPC when target socket is gone
- Before
io.to(socketId).emitWithAck, do a cheap presence check via the adapter. If no pod claims the socketId, return'RPC target not reachable'immediately instead of waiting 30s. - The adapter exposes
fetchSockets({rooms: [socketId]})for this. Costs one Redis round trip per call.
- Before
-
Stop routing by socketId; route by stable method-name channel
- Daemon subscribes to
rpc:method:<userId>:<method>Redis pub/sub channel. - Caller publishes a request envelope
{requestId, params, replyTo}. - Daemon publishes response to
replyTo. - Decouples from socketId entirely. Reconnects don't lose RPC identity. This is the "clean" fix; it's a bigger refactor.
- Daemon subscribes to
-
Sticky daemon-aware routing
- Route any user's RPC calls to the pod that hosts that user's
daemon. k8s
ServicewithsessionAffinity: ClientIPper-user hash. Means cross-pod RPC ≈ never happens. Smaller blast radius without the protocol redesign.
- Route any user's RPC calls to the pod that hosts that user's
daemon. k8s
Repro inventory (in deploy/)
| File | Purpose |
|---|---|
local.sh |
Bring up minikube + 2-replica stack |
test-multiprocess.mjs |
Broadcast fan-out + pod-kill recovery |
test-rpc-cross-replica.mjs |
Steady-state cross-pod RPC |
hammer.mjs |
Bug repros: pod-kill / reconnect-storm / ttl-expiry |
network-loss.mjs |
Long-running RPC loop with summary, used with iptables |
probe-rpc.mjs |
Direct rpc-register + Redis-key inspector |
POSTMORTEM.md |
This file |