98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Waiting to run
CLI Smoke Test / smoke-test-linux (24) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (20) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (24) (push) Waiting to run
Expo App TypeScript typecheck / typecheck (push) Waiting to run
331 lines
14 KiB
Markdown
331 lines
14 KiB
Markdown
# Multi-process happy-server
|
||
|
||
How handy-server runs across multiple Kubernetes replicas: socket distribution,
|
||
room-based RPC routing, broadcast fan-out, daemon lifecycle, and what happens
|
||
during the messy cases (pod kill, brief reconnect, network partition).
|
||
|
||
For the shorter high-level control-flow doc, see `realtime-sync-and-rpc.md`.
|
||
|
||
> **Status:** the code in this doc is on `main` but `handy.yaml` ships
|
||
> `replicas: 1`. Flipping prod to multi-replica is a separate decision.
|
||
|
||
## TL;DR
|
||
|
||
handy-server uses the **Socket.IO Redis streams adapter** to forward
|
||
`io.to(...).emit(...)` between replicas through a single Redis stream. RPC
|
||
routing (web → daemon) goes through **Socket.IO rooms** named
|
||
`rpc:<userId>:<method>`. The server resolves the daemon socket via
|
||
`io.in(room).fetchSockets()` (the cluster-adapter primitive that works
|
||
cross-replica), and sends the request to a single RemoteSocket. There is **no
|
||
Redis key, no TTL, no Lua-CAS cleanup, no keep-alive refresh path** —
|
||
membership is standard Socket.IO room state, cleaned up automatically on
|
||
disconnect.
|
||
|
||
If the daemon is briefly offline at call time (k8s pod cycling, transient
|
||
network drop), the server **waits up to 10 seconds** for it to reappear before
|
||
failing. If the daemon is in flight when its socket dies, a **presence poll**
|
||
aborts the call within ~1 second instead of waiting the full 30s
|
||
emit-with-ack timeout.
|
||
|
||
`connectionStateRecovery` is **commented out** in `socket.ts`. The streams
|
||
adapter supports it (verified working) but we ship parity with the
|
||
pre-multi-process behavior first; clients still do a full REST re-fetch on
|
||
every reconnect via `apiSocket.onReconnected`.
|
||
|
||
## What an rpc-call does (control flow)
|
||
|
||
```
|
||
rpc-call from web client
|
||
.
|
||
├── input validation
|
||
│ └── method name → invalid → callback({ok:false, error:'Invalid parameters'})
|
||
│
|
||
├── 1. resolve target via cluster adapter
|
||
│ └── fetchRoomSockets(io, 'rpc:<userId>:<method>')
|
||
│ ├── io.in(room).timeout(500ms).fetchSockets()
|
||
│ ├── on success → returns [...]
|
||
│ └── on failure (peer replica unresponsive, fast adapter timeout)
|
||
│ └── log + return [] (treat as "nobody here")
|
||
│ │
|
||
│ ├── returns [target] → go to step 2
|
||
│ └── returns [] → go to wait-for-reconnect
|
||
│
|
||
├── wait-for-reconnect grace (only when no target found)
|
||
│ └── waitForRoomMember(io, room, 10_000ms)
|
||
│ └── poll every 200ms via fetchRoomSockets:
|
||
│ ├── room gained a member → return [target]
|
||
│ └── deadline reached → return []
|
||
│ │
|
||
│ ├── grace produced [target] → go to step 2
|
||
│ └── grace produced []
|
||
│ └── callback({ok:false, error:'RPC method not available'})
|
||
│
|
||
├── 2. sanity checks on resolved target
|
||
│ ├── multiple sockets in room → log warn, use first
|
||
│ └── target.id === socket.id → callback({ok:false, error:'same socket'})
|
||
│
|
||
├── 3. fire emit + race a presence poll
|
||
│ ├── ackPromise = target.timeout(30_000).emitWithAck('rpc-request', ...)
|
||
│ │ (cluster adapter routes cross-replica via Redis stream)
|
||
│ │
|
||
│ └── presencePoll = while (alive)
|
||
│ └── sleep 1s, fetchRoomSockets again
|
||
│ ├── target still in room → keep watching
|
||
│ └── target absent → throw 'RPC target disconnected'
|
||
│
|
||
├── Promise.race(ackPromise, presencePoll)
|
||
│ ├── ackPromise resolves → callback({ok:true, result})
|
||
│ ├── ackPromise throws (timeout / err) → callback({ok:false, error: msg})
|
||
│ └── presencePoll throws → callback({ok:false, error:'RPC target disconnected'})
|
||
│
|
||
└── finally
|
||
└── presenceAlive = false (stops the poll cleanly on success or failure)
|
||
```
|
||
|
||
## What a daemon does (lifecycle)
|
||
|
||
```
|
||
daemon (machine-scoped or session-scoped)
|
||
.
|
||
├── connect to handy-server
|
||
│ └── server: socket.handshake.auth.token → auth.verifyToken
|
||
│ └── attaches rpcHandler / *UpdateHandler / etc
|
||
│
|
||
├── emit('rpc-register', { method })
|
||
│ └── server: socket.join('rpc:<userId>:<method>')
|
||
│ └── ack: emit('rpc-registered', { method })
|
||
│ (Socket.IO room state, NO Redis key, NO TTL)
|
||
│
|
||
├── on('rpc-request', (data, cb) => …)
|
||
│ └── handler runs, cb(result) returns the value via the cluster adapter
|
||
│
|
||
├── disconnect (any reason)
|
||
│ └── Socket.IO automatically removes the socket from all rooms
|
||
│ (cluster adapter syncs via heartbeat; no manual cleanup needed)
|
||
│
|
||
└── auto-reconnect
|
||
└── on 'connect': re-emit rpc-register
|
||
(the only client-side responsibility)
|
||
```
|
||
|
||
## What a broadcast does (event emission)
|
||
|
||
```
|
||
eventRouter.emitUpdate / emitEphemeral
|
||
.
|
||
└── io.to(rooms).emit('update' | 'ephemeral', payload)
|
||
├── streams adapter: XADD on the 'socket.io' Redis stream
|
||
│ (MAXLEN ~ 50000, auto-trimmed by Redis)
|
||
└── every replica's XREAD loop picks up the entry
|
||
└── delivers to its local sockets that match the room set
|
||
(sockets that disconnected before the emit miss it; client
|
||
falls through to apiSocket onReconnected → REST refetch)
|
||
```
|
||
|
||
Rooms used by `eventRouter`:
|
||
|
||
```
|
||
.
|
||
├── user:<userId> all of a user's sockets
|
||
├── user:<userId>:user-scoped only the web/desktop clients
|
||
├── user:<userId>:session:<sessionId> session-scoped subscribers
|
||
└── user:<userId>:machine:<machineId> one specific machine
|
||
```
|
||
|
||
## Where the code lives
|
||
|
||
```
|
||
.
|
||
├── packages/happy-server/sources/app/
|
||
│ ├── api/socket.ts io.Server setup, attaches the
|
||
│ │ streams adapter when REDIS_URL
|
||
│ │ is set, commented-out
|
||
│ │ connectionStateRecovery
|
||
│ ├── api/socket/rpcHandler.ts the entire RPC routing layer
|
||
│ │ (~180 lines, single code path)
|
||
│ ├── api/socket/machineUpdateHandler.ts no longer touches RPC state
|
||
│ ├── api/socket/sessionUpdateHandler.ts no longer touches RPC state
|
||
│ └── events/eventRouter.ts broadcast emission via rooms
|
||
│
|
||
└── packages/happy-server/deploy/handy.yaml k8s Deployment + Service
|
||
(replicas: 1 in this PR)
|
||
```
|
||
|
||
## What was wrong before (the four bugs)
|
||
|
||
The previous attempt stored RPC routing state as `rpc:user:<u>:method:<m>` →
|
||
socketId Redis keys with a 60-second TTL refreshed by `machine-alive` /
|
||
`session-alive` heartbeats. This had three killer bugs (smoking gun was #3):
|
||
|
||
```
|
||
.
|
||
├── #1 In-flight RPC eats the full 30s timeout when the target pod dies
|
||
│ io.to(deadSocketId).emitWithAck() has no fast-fail.
|
||
│ FIX: presence poll aborts within ~1s
|
||
│
|
||
├── #2 Reconnect race
|
||
│ Between the daemon's disconnect cleanup and re-register, ~5–7% of
|
||
│ cross-pod RPCs fail with either "method not available" (key
|
||
│ deleted) or "target not reachable" (key still pointed at dead
|
||
│ socketId).
|
||
│ FIX: atomic socket.join / auto-leave on disconnect, no race window
|
||
│
|
||
├── #3 Silent TTL expiry
|
||
│ Daemon stays connected, registration vanishes after 60s if the
|
||
│ keep-alive event was missed for any reason. Daemon never knows;
|
||
│ stays broken until reconnect.
|
||
│ FIX: no TTL exists anymore
|
||
│
|
||
└── #4 Streams adapter "unbounded growth"
|
||
FALSE ALARM. The adapter trims with MAXLEN ~ on every XADD. Capped
|
||
at ~50k entries. Crossing this off the list.
|
||
```
|
||
|
||
The full postmortem with reproduction commands is at
|
||
`deploy/integration-tests/POSTMORTEM.md`.
|
||
|
||
## How we tested it
|
||
|
||
Local minikube with a 2-replica handy-server, Redis, Postgres, exposed as a
|
||
real `LoadBalancer` service via `minikube tunnel`. All harnesses live in
|
||
`deploy/integration-tests/`.
|
||
|
||
```
|
||
.
|
||
├── test-rpc-cross-replica.mjs steady-state cross-pod RPC
|
||
│ (50 parallel + 20 sequential)
|
||
├── test-multiprocess.mjs broadcast fan-out + pod-kill recovery
|
||
├── hammer.mjs <scenario> pod-kill-mid-rpc, reconnect-storm,
|
||
│ ttl-expiry, brief-disconnect,
|
||
│ long-disconnect
|
||
├── network-loss.mjs long-running RPC loop with summary,
|
||
│ usable with iptables blackouts
|
||
├── missed-events.mjs brief disconnect → triggered broadcast →
|
||
│ reconnect; verifies missed-events
|
||
│ behavior matches main (lost from socket,
|
||
│ recovered via REST refetch)
|
||
├── probe-rpc.mjs direct rpc-register sanity probe +
|
||
│ Redis key inspector
|
||
├── probe-fetchsockets.mjs fetchSockets latency probe
|
||
├── POSTMORTEM.md full bug-by-bug
|
||
└── ../local.sh bring up the whole minikube stack
|
||
```
|
||
|
||
To bring up the test environment from scratch:
|
||
|
||
```bash
|
||
deploy/local.sh # provisions stack
|
||
kubectl get pods -l app=handy-server # confirm 2 replicas
|
||
kubectl patch svc handy-server -p '{"spec":{"type":"LoadBalancer"}}'
|
||
minikube tunnel & # exposes :3000
|
||
node deploy/integration-tests/test-rpc-cross-replica.mjs
|
||
```
|
||
|
||
Final gauntlet result against the fix:
|
||
|
||
```
|
||
.
|
||
├── steady-state cross-pod RPC 50/50 + 20/20 ✅ (after ~5s warmup)
|
||
├── pod-kill-mid-rpc 1612ms fast-fail ✅ (was 30000ms)
|
||
├── brief-disconnect SUCCESS in 2011ms ✅
|
||
├── long-disconnect bounded 10542ms ✅ (10s grace + ~0.5s)
|
||
├── ttl-expiry (smoking gun) ALL 5 calls pass through +75s ✅
|
||
├── reconnect-storm (5 cycles) 96–97% success ✅ (only inherent
|
||
│ in-flight failures, ~3%)
|
||
├── broadcast multi-process 20/20 fan-out, 5/5 unaffected ✅
|
||
├── network-loss 60s loop 85/85 zero failures ✅
|
||
└── missed-events parity event lost via socket, in DB,
|
||
recovered=undefined ✅ (matches main)
|
||
```
|
||
|
||
## Tunable constants
|
||
|
||
```
|
||
RPC_RECONNECT_GRACE_MS 10_000 wait-for-reconnect window (2× heartbeat)
|
||
RPC_RECONNECT_POLL_MS 200 poll cadence inside the grace
|
||
RPC_PRESENCE_POLL_MS 1_000 presence-poll cadence during in-flight
|
||
RPC_PRESENCE_FETCH_TIMEOUT_MS 500 per-call cross-replica fetchSockets cap
|
||
RPC_CALL_TIMEOUT_MS 30_000 upper bound on emitWithAck — same as main
|
||
(no support for >30s RPCs in either)
|
||
```
|
||
|
||
## Adapter details and limits worth knowing
|
||
|
||
```
|
||
.
|
||
├── streams adapter discovery
|
||
│ ~5s after a pod starts, the adapter's heartbeat exchange means
|
||
│ cross-replica fetchSockets() may not see all rooms. First few RPCs
|
||
│ immediately after a fresh rollout can hit the wait-for-reconnect
|
||
│ grace; we sized RPC_RECONNECT_GRACE_MS at 10s to cover 2 heartbeat
|
||
│ cycles.
|
||
│
|
||
├── MAXLEN ~ 50000
|
||
│ configured in socket.ts. Auto-trims on every XADD, no cleanup needed.
|
||
│
|
||
├── fetchSockets() cross-replica
|
||
│ defaults to a 5-second timeout per request. We pass timeout(500) for
|
||
│ our presence polls so a single unresponsive replica doesn't stall
|
||
│ every poll for 5s.
|
||
│
|
||
├── emitWithAck from a RemoteSocket
|
||
│ works cross-replica through the cluster adapter (the streams adapter
|
||
│ inherits ClusterAdapterWithHeartbeat which implements BROADCAST_ACK
|
||
│ and FETCH_SOCKETS_RESPONSE).
|
||
│
|
||
└── multiple sockets in the same RPC room
|
||
shouldn't happen in practice (one daemon per machine, one method
|
||
registration). If it does, we log a warn and pick targets[0]. Same
|
||
blast radius as the previous Redis last-write-wins behavior.
|
||
```
|
||
|
||
## What we still don't do (intentional, deferred)
|
||
|
||
```
|
||
.
|
||
├── connectionStateRecovery
|
||
│ Commented out in socket.ts. Enabling it would let brief disconnects
|
||
│ skip the heavy REST refetch (events replay through the streams
|
||
│ adapter via restoreSession). Verified working — not shipped to
|
||
│ preserve parity with main on this dimension.
|
||
│
|
||
├── In-flight RPC continuity across daemon reconnect
|
||
│ Coupled to the above. With connectionStateRecovery enabled AND a
|
||
│ recovery-aware presence poll (i.e. "wait N seconds for the same
|
||
│ socketId to come back before failing"), an in-flight RPC could
|
||
│ survive a brief network blip on the daemon: the daemon's handler
|
||
│ keeps running, the ack packet sits in the client's sendBuffer,
|
||
│ reconnect flushes it, the caller gets its result. Today the presence
|
||
│ poll fast-fails the call as soon as the room is empty, which kills
|
||
│ this case. Out of scope for this PR.
|
||
│
|
||
├── User-affinity routing at the LB
|
||
│ Cross-pod RPC overhead is ~3–6ms via the streams adapter. JWT-aware
|
||
│ routing (Envoy / Istio / nginx-lua) would be a bigger infra change
|
||
│ than the fix itself. Tracked as future-work.
|
||
│
|
||
├── UI "reconnecting…" indicator
|
||
│ Server now waits 10s for daemons. Client doesn't yet show that wait
|
||
│ in the UI. apiSocket-side change, separate from this PR.
|
||
│
|
||
├── Tuning the adapter discovery window
|
||
│ 5s is the streams adapter's default heartbeatInterval. Lowering it
|
||
│ would reduce the fresh-pod-startup race but increase Redis chatter.
|
||
│
|
||
└── Long-running RPCs (> 30s)
|
||
Not supported on either main or this PR. Bash command in the CLI has
|
||
its own 30s cap that races dead-even with the server's 30s emit
|
||
timeout. Bumping requires both server and (possibly added) client
|
||
timeouts.
|
||
```
|
||
|
||
## Reference
|
||
|
||
- Socket.IO rooms: <https://socket.io/docs/v4/rooms/>
|
||
- `fetchSockets()`: <https://socket.io/docs/v4/server-api/#serverfetchsockets>
|
||
- Broadcasting events: <https://socket.io/docs/v4/broadcasting-events/>
|
||
- Memory usage: <https://socket.io/docs/v4/memory-usage/>
|
||
- Streams adapter source: <https://github.com/socketio/socket.io-redis-streams-adapter>
|
||
- Connection state recovery: <https://socket.io/docs/v4/connection-state-recovery>
|
||
- Discussion #5062 (broadcast emitWithAck waits for all): <https://github.com/socketio/socket.io/discussions/5062>
|