You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(balancer): keep least_conn load state across upstream scaling
When least_conn proxies long-lived connections (WebSocket) and the upstream
is scaled, the load stays skewed on the original nodes: the newly added ones
are not preferred and least_conn degrades to round-robin.
The picker is cached by the upstream version, so it is rebuilt whenever the
upstream changes - and scaling changes it. The binary heap holding the
per-server scores lives inside the picker, so every score is reset to the base
weight on rebuild and the connections already established are forgotten. The
requests that are still in flight keep the picker they were routed with and
release their server on it in the log phase, so their releases land on a heap
nobody reads anymore, while the rebuilt heap never learns about them.
Move both the heap and the in-flight connection counts out of the picker into
a per-worker state keyed by the upstream resource key, which is stable across
scaling (and across health status flips) unlike the picker version. The picker
now reconciles that heap with the current node set instead of rebuilding it:
surviving nodes keep their load, a freshly added node starts empty and is
preferred right away, and a node that leaves keeps its count so its score is
restored if it comes back. Every generation of pickers shares one view of the
load, so a connection established before a rebuild is released against the
heap that is actually in use. Because that heap can hold nodes a picker was
not built with, a picker only ever hands out the nodes of its own conf, which
is what the rest of balancer.lua assumes of the server it gets back. The state
is held weakly, so it lives exactly as long as a picker that can still release
a connection into it.
pick_server skips the balancer entirely when the upstream has a single node.
least_conn cannot afford that: the requests routed while the upstream was alone
are the ones a later scale out needs to know about, and a single node is where
a k8s deployment or a discovery service starts from. Route them through the
balancer so they are counted.
The score is derived from the connection count instead of being accumulated
with +/- effect_weight, which keeps it exact over time.
Now that the state outlives the picker, releasing a server the request no
longer holds is no longer self-healing: it used to be washed away by the next
rebuild, now it is written into shared state for good. Make after_balance own
that: releasing a server clears ctx.balancer_server, so the field always names
the server the request currently holds and a caller that retries simply gets a
fresh one from the next pick. Three release sites relied on the caller to
remember and did not - pick_server on entering a retry and on skipping an
unhealthy node, and ai-proxy-multi's retry_on_error, which releases before it
knows whether it will pick again and returns as-is when the fallback strategy
does not cover the status code. Each of them left the log phase free to release
the same server a second time. Reaching the first one needs nothing unusual:
the retry count is derived from all nodes while only the healthy ones are
picked from, so a single failing health check is enough.
ctx.server_picker was also published only at the very end of pick_server, so a
request that bailed out after picking a server never released it at all.
The priority is part of the state key, since node sets of different priorities
are disjoint and must not share a heap. A node that moves between priorities
leaves its count behind in the old level, where it drains normally.
Also call after_balance in the stream log phase, which it never did: for L4
the count was only ever incremented, so least_conn could not balance TCP long
connections at all and ctx.balancer_tried_servers was leaked.
Note that an upstream declared inline in a traffic-split rule has no stable
resource key, so it keeps the previous behavior.
Fixes#12217
0 commit comments