RinhaDeBackend::Lb
Legacy round-robin TCP→UDS load balancer.
The production LB is now HAProxy (see haproxy.cfg and the lb
service in docker-compose.yml). This Crystal LB is kept in the
tree for reference / fallback and is still built into the runtime
image as /usr/local/bin/rinha_lb, but no compose service invokes
it by default.
Behaviour (preserved as documented for the historical iteration): the challenge rules forbid the LB from inspecting payload or running business logic, so this is a strict bidirectional byte copy between the client (TCP) and the picked upstream (UDS), with round-robin upstream selection at connection-accept time.
Original design rationale (vs. the older nginx LB):
- UDS skips the TCP/IP stack on the LB→API hop entirely (no port allocation, no Nagle, no port reuse pressure under k6 storms).
- The previous nginx allowance was paying for an HTTP-aware proxy
with logging/templates/configuration parsing. The static Crystal
binary is ~3 MB and runs on tens of KB of working set per
connection. HAProxy (in
mode tcp) keeps both wins.
Concurrency model
Built on Fiber::ExecutionContext::Parallel (Crystal 1.20 stdlib,
https://crystal-lang.org/api/1.20.0/Fiber/ExecutionContext/Parallel.html).
All fibers — accept loop, c2s and s2c forwarders — live in the same
parallel context, so they can be resumed by any of N scheduler
threads. Even at the 0.10 CPU cgroup ceiling assigned to the LB,
the parallel context lets a sibling fiber make progress while a
peer is parked inside an epoll_wait — which is essentially the
whole LB workload.
Round-robin selection uses an Atomic(UInt32) counter; with N
parallel schedulers we'd race a plain integer. Atomic increment is
one lock xadd on x86-64.
Lifecycle of one downstream connection
- accept_loop fiber accepts a TCP socket.
- It picks the next upstream UDS path (
@rr.add(1) % N) and opens a new UNIXSocket — one upstream connection per downstream connection. - Spawns a c2s fiber (downstream→upstream copy). The current fiber drives the s2c copy itself; when either side closes, both sockets are closed and both copy fibers unwind.
Constants
16 KiB per direction, stack-allocated. The Rinha hot path request/response pair is well under 1 KiB, so most copy() calls do a single read+write. The headroom matters for the body of a slow client where the kernel may hand us partial reads — we still complete each loop iteration in a single syscall pair.