Skip to content

Commit 0bedce8

Browse files
committed
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
1 parent 2790b2f commit 0bedce8

11 files changed

Lines changed: 818 additions & 43 deletions

File tree

apisix/balancer.lua

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ local function create_server_picker(upstream, checker)
174174
return server_picker
175175
end
176176

177-
core.log.info("upstream nodes: ",
178-
core.json.delay_encode(up_nodes[up_nodes._priority_index[1]]))
179-
local server_picker = picker.new(up_nodes[up_nodes._priority_index[1]], upstream)
177+
local priority = up_nodes._priority_index[1]
178+
core.log.info("upstream nodes: ", core.json.delay_encode(up_nodes[priority]))
179+
local server_picker = picker.new(up_nodes[priority], upstream, priority)
180180
server_picker.addr_to_domain = addr_to_domain
181181
return server_picker
182182
end
@@ -331,6 +331,9 @@ local function pick_server(route, ctx)
331331
return nil, "failed to find valid upstream server, all upstream servers tried"
332332
end
333333
ctx.balancer_server = server
334+
-- from here on the request holds a server, so the log phase must be able to
335+
-- release it even if we bail out below
336+
ctx.server_picker = server_picker
334337

335338
local domain = server_picker.addr_to_domain[server]
336339
local res, err = lrucache_addr(server, nil, parse_addr, server)
@@ -347,7 +350,6 @@ local function pick_server(route, ctx)
347350
if is_http and ctx.var then
348351
ctx.var.upstream_unresolved_host = ctx.upstream_unresolved_host
349352
end
350-
ctx.server_picker = server_picker
351353
res.upstream_host = parse_server_for_upstream_host(res, ctx.upstream_scheme)
352354

353355
return res

apisix/balancer/chash.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ function _M.new(up_nodes, upstream)
123123
return servers[id]
124124
end,
125125
after_balance = function (ctx, before_retry)
126+
-- the release is what makes the request stop holding the server, so drop
127+
-- the reference here instead of leaving every caller to remember
128+
local server = ctx.balancer_server
129+
ctx.balancer_server = nil
130+
126131
if not before_retry then
127132
if ctx.balancer_tried_servers then
128133
core.tablepool.release("balancer_tried_servers", ctx.balancer_tried_servers)
@@ -132,11 +137,15 @@ function _M.new(up_nodes, upstream)
132137
return nil
133138
end
134139

140+
if not server then
141+
return nil
142+
end
143+
135144
if not ctx.balancer_tried_servers then
136145
ctx.balancer_tried_servers = core.tablepool.fetch("balancer_tried_servers", 0, 2)
137146
end
138147

139-
ctx.balancer_tried_servers[ctx.balancer_server] = true
148+
ctx.balancer_tried_servers[server] = true
140149
ctx.balancer_tried_servers_count = (ctx.balancer_tried_servers_count or 0) + 1
141150
end,
142151
before_retry_next_priority = function (ctx)

apisix/balancer/ewma.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,21 @@ local function _ewma_find(ctx, up_nodes)
187187
end
188188

189189
local function _ewma_after_balance(ctx, before_retry)
190+
-- the release is what makes the request stop holding the server, so drop the
191+
-- reference here instead of leaving every caller to remember
192+
local server = ctx.balancer_server
193+
ctx.balancer_server = nil
194+
190195
if before_retry then
196+
if not server then
197+
return nil
198+
end
199+
191200
if not ctx.balancer_tried_servers then
192201
ctx.balancer_tried_servers = core.tablepool.fetch("balancer_tried_servers", 0, 2)
193202
end
194203

195-
ctx.balancer_tried_servers[ctx.balancer_server] = true
204+
ctx.balancer_tried_servers[server] = true
196205
ctx.balancer_tried_servers_count = (ctx.balancer_tried_servers_count or 0) + 1
197206

198207
return nil

apisix/balancer/least_conn.lua

Lines changed: 154 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,66 +24,181 @@ local pairs = pairs
2424
local _M = {}
2525

2626

27+
-- Per-worker balancing state, shared by every picker built for the same upstream.
28+
--
29+
-- A picker is cached by the upstream version, so it is thrown away whenever the
30+
-- upstream changes: scaling, a config update, or a health status flip. The
31+
-- requests that are still in flight keep the picker they were routed with, and
32+
-- release their server on that picker in the log phase. If the heap lived inside
33+
-- the picker, the rebuilt one would never see those releases: the connections
34+
-- established before the rebuild would be forgotten on creation and then, once
35+
-- they closed, decremented on a heap nobody reads anymore. Long-lived
36+
-- connections (WebSocket) would keep the load skewed on the original nodes and
37+
-- least_conn would degrade to round-robin. See #12217.
38+
--
39+
-- Keeping the heap and the in-flight counts here, keyed by something stable
40+
-- across scaling, gives every generation of pickers a single view of the load.
41+
--
42+
-- The ttl does not drop the state: the version is constant, so an expired entry
43+
-- is reused and refreshed instead of rebuilt. The count is what bounds it, and
44+
-- it is the price of not leaking a state per upstream that is ever deleted: a
45+
-- worker balancing more than `count` (upstream, priority) pairs evicts the least
46+
-- recently rebuilt one, which degrades that upstream to the pre-fix behavior
47+
-- (its in-flight connections are forgotten on the next picker rebuild). Note that
48+
-- the picker cache counts upstreams while this one counts priority levels, so an
49+
-- upstream with several of them consumes several entries.
50+
local STATE_VER = "least_conn"
51+
local states = core.lrucache.new({ttl = 300, count = 512})
52+
53+
2754
local function least_score(a, b)
2855
return a.score < b.score
2956
end
3057

3158

32-
function _M.new(up_nodes, upstream)
33-
local servers_heap = binaryHeap.minUnique(least_score)
59+
local function new_state()
60+
return {
61+
heap = binaryHeap.minUnique(least_score),
62+
-- server -> in-flight connections, only holds positive counts
63+
conns = {},
64+
-- server -> true, mirrors the payloads currently in the heap
65+
members = {},
66+
}
67+
end
68+
69+
70+
local function update_score(state, server)
71+
local info = state.heap:valueByPayload(server)
72+
-- the server may have left the upstream while it still held connections
73+
if not info then
74+
return
75+
end
76+
77+
info.score = (1 + (state.conns[server] or 0)) * info.effect_weight
78+
state.heap:update(server, info)
79+
end
80+
81+
82+
-- Align the long-lived heap with the current node set, keeping the in-flight
83+
-- counts of the nodes that survive. A node that is added back later (scaled in
84+
-- again, or reported healthy again) gets its score restored from `conns`.
85+
local function sync_nodes(state, up_nodes)
86+
local heap = state.heap
87+
88+
for server in pairs(state.members) do
89+
if not up_nodes[server] then
90+
heap:remove(server)
91+
state.members[server] = nil
92+
end
93+
end
94+
3495
for server, weight in pairs(up_nodes) do
35-
local score = 1 / weight
36-
-- Note: the argument order of insert is different from others
37-
servers_heap:insert({
38-
server = server,
39-
effect_weight = 1 / weight,
40-
score = score,
41-
}, server)
96+
local effect_weight = 1 / weight
97+
local score = (1 + (state.conns[server] or 0)) * effect_weight
98+
local info = heap:valueByPayload(server)
99+
if info then
100+
info.effect_weight = effect_weight
101+
info.score = score
102+
heap:update(server, info)
103+
else
104+
-- Note: the argument order of insert is different from others
105+
heap:insert({
106+
server = server,
107+
effect_weight = effect_weight,
108+
score = score,
109+
}, server)
110+
state.members[server] = true
111+
end
42112
end
113+
end
114+
115+
116+
function _M.new(up_nodes, upstream, priority)
117+
-- resource_key identifies the upstream and is stable across node scaling, unlike
118+
-- the picker version which changes whenever the nodes change. Do not fall back to
119+
-- resource_id: it is a bare id, so a route and an upstream sharing one would land
120+
-- on the same heap and evict each other's nodes
121+
local up_key = upstream.resource_key
122+
local state
123+
if up_key and priority then
124+
-- each priority level owns a disjoint node set, so it needs its own heap.
125+
-- Do not default the priority: a caller that does not name one has a node
126+
-- set we cannot place, and folding it into level 0 would let sync_nodes
127+
-- evict that level's nodes from the heap it shares
128+
state = states(up_key .. "#" .. priority, STATE_VER, new_state)
129+
else
130+
-- no stable identity, fall back to a state private to this picker
131+
state = new_state()
132+
end
133+
134+
sync_nodes(state, up_nodes)
135+
136+
local servers_heap = state.heap
137+
local conns = state.conns
43138

44139
return {
45140
upstream = upstream,
46141
get = function (ctx)
142+
local tried = ctx.balancer_tried_servers
47143
local server, info, err
48-
if ctx.balancer_tried_servers then
49-
local tried_server_list = {}
50-
while true do
51-
server, info = servers_heap:peek()
52-
-- we need to let the retry > #nodes so this branch can be hit and
53-
-- the request will retry next priority of nodes
54-
if server == nil then
55-
err = "all upstream servers tried"
56-
break
57-
end
58-
59-
if not ctx.balancer_tried_servers[server] then
60-
break
61-
end
62-
63-
servers_heap:pop()
64-
core.table.insert(tried_server_list, info)
144+
local skipped
145+
146+
while true do
147+
server, info = servers_heap:peek()
148+
-- we need to let the retry > #nodes so this branch can be hit and
149+
-- the request will retry next priority of nodes
150+
if server == nil then
151+
err = "all upstream servers tried"
152+
break
65153
end
66154

67-
for _, info in ipairs(tried_server_list) do
68-
servers_heap:insert(info, info.server)
155+
-- the heap is shared with the pickers built for later versions of
156+
-- the upstream, so it can hold nodes this request's conf does not
157+
-- know about. Only hand out the ones it does
158+
if up_nodes[server] and not (tried and tried[server]) then
159+
break
160+
end
161+
162+
servers_heap:pop()
163+
if not skipped then
164+
skipped = {}
165+
end
166+
core.table.insert(skipped, info)
167+
end
168+
169+
if skipped then
170+
for _, skipped_info in ipairs(skipped) do
171+
servers_heap:insert(skipped_info, skipped_info.server)
69172
end
70-
else
71-
server, info = servers_heap:peek()
72173
end
73174

74175
if not server then
75176
return nil, err
76177
end
77178

78-
info.score = info.score + info.effect_weight
79-
servers_heap:update(server, info)
179+
conns[server] = (conns[server] or 0) + 1
180+
update_score(state, server)
80181
return server
81182
end,
82183
after_balance = function (ctx, before_retry)
184+
-- the release is what makes the request stop holding the server, so drop
185+
-- the reference here instead of leaving every caller to remember. A caller
186+
-- that goes on to retry gets a fresh one from the next pick
83187
local server = ctx.balancer_server
84-
local info = servers_heap:valueByPayload(server)
85-
info.score = info.score - info.effect_weight
86-
servers_heap:update(server, info)
188+
ctx.balancer_server = nil
189+
190+
if server then
191+
local count = (conns[server] or 0) - 1
192+
if count < 0 then
193+
-- released more than picked: the score would go below the
194+
-- baseline and, with weight 0, `0 * inf` would poison the heap
195+
-- order with a NaN. Clamp, but do not hide the accounting bug
196+
core.log.error("released a connection never picked on ", server)
197+
count = 0
198+
end
199+
conns[server] = count > 0 and count or nil
200+
update_score(state, server)
201+
end
87202

88203
if not before_retry then
89204
if ctx.balancer_tried_servers then
@@ -94,6 +209,10 @@ function _M.new(up_nodes, upstream)
94209
return nil
95210
end
96211

212+
if not server then
213+
return nil
214+
end
215+
97216
if not ctx.balancer_tried_servers then
98217
ctx.balancer_tried_servers = core.tablepool.fetch("balancer_tried_servers", 0, 2)
99218
end

apisix/balancer/priority.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ function _M.new(up_nodes, upstream, picker_mod)
3333

3434
local pickers = core.table.new(#priority_index, 0)
3535
for i, priority in ipairs(priority_index) do
36-
local picker, err = picker_mod.new(up_nodes[priority], upstream)
36+
-- the priority is part of the picker's identity: node sets of different
37+
-- priorities are disjoint and must not share balancing state
38+
local picker, err = picker_mod.new(up_nodes[priority], upstream, priority)
3739
if not picker then
3840
return nil, "failed to create picker with priority " .. priority .. ": " .. err
3941
end

apisix/balancer/roundrobin.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ function _M.new(up_nodes, upstream)
5858
return server
5959
end,
6060
after_balance = function (ctx, before_retry)
61+
-- the release is what makes the request stop holding the server, so drop
62+
-- the reference here instead of leaving every caller to remember
63+
local server = ctx.balancer_server
64+
ctx.balancer_server = nil
65+
6166
if not before_retry then
6267
if ctx.balancer_tried_servers then
6368
core.tablepool.release("balancer_tried_servers", ctx.balancer_tried_servers)
@@ -67,11 +72,15 @@ function _M.new(up_nodes, upstream)
6772
return nil
6873
end
6974

75+
if not server then
76+
return nil
77+
end
78+
7079
if not ctx.balancer_tried_servers then
7180
ctx.balancer_tried_servers = core.tablepool.fetch("balancer_tried_servers", 0, 2)
7281
end
7382

74-
ctx.balancer_tried_servers[ctx.balancer_server] = true
83+
ctx.balancer_tried_servers[server] = true
7584
ctx.balancer_tried_servers_count = (ctx.balancer_tried_servers_count or 0) + 1
7685
end,
7786
before_retry_next_priority = function (ctx)

apisix/init.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,10 @@ function _M.stream_log_phase()
15411541

15421542
healthcheck_passive(api_ctx)
15431543

1544+
if api_ctx.server_picker and api_ctx.server_picker.after_balance then
1545+
api_ctx.server_picker.after_balance(api_ctx, false)
1546+
end
1547+
15441548
core.ctx.release_vars(api_ctx)
15451549
if api_ctx.plugins then
15461550
core.tablepool.release("plugins", api_ctx.plugins)

t/lib/server.lua

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,29 @@ end
386386
_M.websocket_handshake_route = _M.websocket_handshake
387387

388388

389+
-- keep the session open until the client closes it, so that the request stays
390+
-- in flight in the balancer the way a real WebSocket session does
391+
function _M.websocket_hold()
392+
local websocket = require "resty.websocket.server"
393+
local wb, err = websocket:new({timeout = 30000})
394+
if not wb then
395+
ngx.log(ngx.ERR, "failed to new websocket: ", err)
396+
return ngx.exit(400)
397+
end
398+
399+
while true do
400+
local _, typ, err = wb:recv_frame()
401+
if not typ then
402+
ngx.log(ngx.ERR, "failed to receive websocket frame: ", err)
403+
return
404+
end
405+
if typ == "close" then
406+
return
407+
end
408+
end
409+
end
410+
411+
389412
function _M.api_breaker()
390413
ngx.exit(tonumber(ngx.var.arg_code))
391414
end

0 commit comments

Comments
 (0)