Skip to content

Commit ca0cd95

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 ca0cd95

11 files changed

Lines changed: 877 additions & 45 deletions

File tree

apisix/balancer.lua

Lines changed: 17 additions & 6 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
@@ -248,7 +248,13 @@ local function pick_server(route, ctx)
248248
local up_conf = ctx.upstream_conf
249249

250250
local nodes_count = #up_conf.nodes
251-
if nodes_count == 1 then
251+
-- least_conn counts the in-flight connections of every request it routes, so it
252+
-- has to see them even while the upstream has a single node: those connections
253+
-- are what tells a later scale out that the node is not empty. Skipping the
254+
-- balancer here would leave it blind to everything routed before the second
255+
-- node showed up, which is the state a k8s deployment or a discovery service
256+
-- starts from. See #12217
257+
if nodes_count == 1 and up_conf.type ~= "least_conn" then
252258
local node = up_conf.nodes[1]
253259
ctx.balancer_ip = node.host
254260
ctx.balancer_port = node.port
@@ -268,7 +274,10 @@ local function pick_server(route, ctx)
268274
ctx.balancer_try_count = (ctx.balancer_try_count or 0) + 1
269275
if ctx.balancer_try_count > 1 then
270276
if ctx.server_picker and ctx.server_picker.after_balance then
271-
ctx.server_picker.after_balance(ctx, true)
277+
-- remembering the server as tried is what keeps the next pick off it, so
278+
-- only do it when there is another one to move to. With a single node the
279+
-- retry has to land on it again, the way the fast path below always did
280+
ctx.server_picker.after_balance(ctx, nodes_count > 1)
272281
end
273282

274283
if checker then
@@ -331,6 +340,9 @@ local function pick_server(route, ctx)
331340
return nil, "failed to find valid upstream server, all upstream servers tried"
332341
end
333342
ctx.balancer_server = server
343+
-- from here on the request holds a server, so the log phase must be able to
344+
-- release it even if we bail out below
345+
ctx.server_picker = server_picker
334346

335347
local domain = server_picker.addr_to_domain[server]
336348
local res, err = lrucache_addr(server, nil, parse_addr, server)
@@ -347,7 +359,6 @@ local function pick_server(route, ctx)
347359
if is_http and ctx.var then
348360
ctx.var.upstream_unresolved_host = ctx.upstream_unresolved_host
349361
end
350-
ctx.server_picker = server_picker
351362
res.upstream_host = parse_server_for_upstream_host(res, ctx.upstream_scheme)
352363

353364
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: 158 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,66 +24,185 @@ 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 values are weak, which is exactly the lifetime this state needs. A picker
43+
-- holds its state, an in-flight request holds the picker it was routed with, and
44+
-- the picker cache holds the current one - so a state survives for as long as
45+
-- anything can still release a connection into it. Once nothing references it
46+
-- there is no connection left to count, and dropping it costs nothing. That also
47+
-- means there is no size to tune and no eviction that could quietly forget a busy
48+
-- upstream, which is what an LRU would do here: it would rank states by how
49+
-- recently they were rebuilt, and a stable upstream holding many long-lived
50+
-- connections is precisely the one that is never rebuilt.
51+
local states = setmetatable({}, {__mode = "v"})
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 info = heap:valueByPayload(server)
98+
if info then
99+
info.effect_weight = effect_weight
100+
else
101+
-- Note: the argument order of insert is different from others
102+
heap:insert({
103+
server = server,
104+
effect_weight = effect_weight,
105+
score = effect_weight,
106+
}, server)
107+
state.members[server] = true
108+
end
109+
-- one place decides what a score is worth
110+
update_score(state, server)
42111
end
112+
end
113+
114+
115+
function _M.new(up_nodes, upstream, priority)
116+
-- resource_key identifies the upstream and is stable across node scaling, unlike
117+
-- the picker version which changes whenever the nodes change. Do not fall back to
118+
-- resource_id: it is a bare id, so a route and an upstream sharing one would land
119+
-- on the same heap and evict each other's nodes
120+
local up_key = upstream.resource_key
121+
local state
122+
if up_key and priority then
123+
-- each priority level owns a disjoint node set, so it needs its own heap.
124+
-- Do not default the priority: a caller that does not name one has a node
125+
-- set we cannot place, and folding it into level 0 would let sync_nodes
126+
-- evict that level's nodes from the heap it shares
127+
local state_key = up_key .. "#" .. priority
128+
state = states[state_key]
129+
if not state then
130+
state = new_state()
131+
states[state_key] = state
132+
end
133+
else
134+
-- no stable identity, fall back to a state private to this picker
135+
state = new_state()
136+
end
137+
138+
sync_nodes(state, up_nodes)
139+
140+
local servers_heap = state.heap
141+
local conns = state.conns
43142

44143
return {
45144
upstream = upstream,
46145
get = function (ctx)
146+
local tried = ctx.balancer_tried_servers
47147
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)
148+
local skipped
149+
150+
while true do
151+
server, info = servers_heap:peek()
152+
-- we need to let the retry > #nodes so this branch can be hit and
153+
-- the request will retry next priority of nodes
154+
if server == nil then
155+
err = "all upstream servers tried"
156+
break
65157
end
66158

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

74179
if not server then
75180
return nil, err
76181
end
77182

78-
info.score = info.score + info.effect_weight
79-
servers_heap:update(server, info)
183+
conns[server] = (conns[server] or 0) + 1
184+
update_score(state, server)
80185
return server
81186
end,
82187
after_balance = function (ctx, before_retry)
188+
-- the release is what makes the request stop holding the server, so drop
189+
-- the reference here instead of leaving every caller to remember. A caller
190+
-- that goes on to retry gets a fresh one from the next pick
83191
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)
192+
ctx.balancer_server = nil
193+
194+
if server then
195+
local count = (conns[server] or 0) - 1
196+
if count < 0 then
197+
-- released more than picked: the score would go below the
198+
-- baseline and, with weight 0, `0 * inf` would poison the heap
199+
-- order with a NaN. Clamp, but do not hide the accounting bug
200+
core.log.error("released a connection never picked on ", server)
201+
count = 0
202+
end
203+
conns[server] = count > 0 and count or nil
204+
update_score(state, server)
205+
end
87206

88207
if not before_retry then
89208
if ctx.balancer_tried_servers then
@@ -94,6 +213,10 @@ function _M.new(up_nodes, upstream)
94213
return nil
95214
end
96215

216+
if not server then
217+
return nil
218+
end
219+
97220
if not ctx.balancer_tried_servers then
98221
ctx.balancer_tried_servers = core.tablepool.fetch("balancer_tried_servers", 0, 2)
99222
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)

0 commit comments

Comments
 (0)