This is an automated email from the ASF dual-hosted git repository.
nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 2df94e071c fix(upstream): re-ramp a slow start node after any
unavailability (#14188)
2df94e071c is described below
commit 2df94e071c1982b4f63e46936e61169be3fb99db
Author: Nic <[email protected]>
AuthorDate: Thu Nov 12 09:55:36 2026 +0800
fix(upstream): re-ramp a slow start node after any unavailability (#14188)
---
apisix/slow_start.lua | 137 +++++++++---------------
docs/en/latest/admin-api.md | 4 +-
docs/zh/latest/admin-api.md | 4 +-
t/node/upstream-slow-start.t | 246 ++++++++++++++++++++++++++++++++++++++++---
4 files changed, 288 insertions(+), 103 deletions(-)
diff --git a/apisix/slow_start.lua b/apisix/slow_start.lua
index 1ca6e8f1f3..4e7e2d35cd 100644
--- a/apisix/slow_start.lua
+++ b/apisix/slow_start.lua
@@ -29,8 +29,8 @@
--
-- <scope>|# node ids the configuration held at the previous
reconcile
-- <scope>|! aggregate ramp deadline, read on the request hot
path
--- <scope>|<id> ramp start: > 0 ramping, 0 mature, -1 not picked
yet
--- <scope>|<id>|i when a reconcile first saw it configured but
unpickable
+-- <scope>|<id> ramp start: > 0 ramping, 0 mature, < 0 waiting
for a
+-- picker since minus that value
-- <scope>|<id>@<start> the successor a worker elected for that ramp start
--
-- Workers reconcile in parallel, and the shared dict has no compare-and-set,
so
@@ -38,22 +38,17 @@
-- that read a ramp start just before another one changed it would put the old
-- one back. Instead a node's ramp start is only ever created with `add`, only
-- ever replaced through an election on `add` keyed by the value being
replaced,
--- and otherwise kept alive with `expire`, which leaves the value alone. Every
--- worker that decides on a change to the same ramp start therefore ends up
--- writing the same successor.
+-- and otherwise kept alive with `expire`, which leaves the value alone, until
the
+-- node leaves the configuration. Every worker that decides on a change to the
+-- same ramp start therefore ends up writing the same successor.
--
-- Presence and eligibility are tracked separately. Presence comes from the
-- configuration, so every worker sees the same set; eligibility is this
worker's
--- health view and only decides when a ramp starts and when it is interrupted.
--- Tombstoning off the eligible set would let one worker's transient health
--- opinion drop a node the other workers are still serving, and the state of a
--- node that never left could then expire and be ramped again from scratch.
---
--- A node the configuration still holds keeps its entry alive. One that leaves
the
--- configuration keeps it for a further `slow_start_time_seconds` (the
tombstone
--- window) so a short absence resumes the ramp instead of restarting it. Coming
--- back later, or being out of the picker for longer than a full window,
starts a
--- new lifecycle.
+-- health view. Either kind of unavailability ends the ramp or the maturity of
a
+-- node, however short it is: a node a health check takes out of the picker
waits
+-- for its next picker and ramps from there, and one that leaves the
configuration
+-- loses its state, so it ramps from scratch if it comes back. Whatever
answers on
+-- the address afterwards may be a process that just started.
local core = require("apisix.core")
local ipairs = ipairs
local pairs = pairs
@@ -72,19 +67,14 @@ local shdict = ngx.shared["upstream-slow-start"]
local INSTANCE_STARTED_KEY = "@instance_started_at"
local SNAPSHOT_SUFFIX = "|#"
local DEADLINE_SUFFIX = "|!"
-local INELIGIBLE_SUFFIX = "|i"
--- A node that is present but needs no ramp is stored with this ramp start, so
--- that it can still be tombstoned when it disappears: without an entry it
would
--- look new again on its way back.
+-- A node that is present but needs no ramp is stored with this ramp start. One
+-- the configuration holds but that is out of the picker is stored with the
+-- negated time it was first found out of it: that separates "known and mature"
+-- from "known but waiting to ramp", and keeps every such value unique to the
+-- interruption it records, which the election in `replace_start` relies on.
local MATURE = 0
--- And one the configuration holds but that has never reached a picker - an
--- active health check has not cleared it yet - is stored with this one. It is
--- what separates "known and already mature" from "known but has not had its
--- chance to ramp", which otherwise both look like a node with no state.
-local PENDING = -1
-
-- Lifetime of the state of a node that is still eligible. Refreshed on every
-- reconcile, which a busy upstream runs at least as often as the picker LRU
-- expires (300s), so it only ever elapses for an upstream that stopped
receiving
@@ -177,18 +167,19 @@ end
_M.effective_weight = effective_weight
-local function within_startup_grace(conf, now)
+-- Seconds left of `startup_grace_period_seconds`, or 0 once it is over.
+local function startup_grace_left(conf, now)
local grace = conf.startup_grace_period_seconds or 0
if grace <= 0 then
- return false
+ return 0
end
local started_at = shdict:get(INSTANCE_STARTED_KEY)
if not started_at then
- return false
+ return 0
end
- return now < started_at + grace
+ return math_max(started_at + grace - now, 0)
end
@@ -295,9 +286,11 @@ local function reconcile(conf, present_nodes, nodes,
scope, now)
-- The first reconcile of a scope is a baseline: the nodes an upstream is
-- bootstrapped with, and the nodes it already had when `warm_up_conf` was
- -- turned on, are mature. So are nodes observed inside the startup grace
- -- period, which absorbs the ordering differences of a cold restart.
- local baseline = (known == nil) or within_startup_grace(conf, now)
+ -- turned on, are mature. So are nodes observed for the first time inside
the
+ -- startup grace period, which absorbs the ordering differences of a cold
+ -- restart.
+ local grace_left = startup_grace_left(conf, now)
+ local baseline = (known == nil) or grace_left > 0
local first_seen = core.table.new(#nodes, 0)
local deadline = 0
@@ -309,9 +302,7 @@ local function reconcile(conf, present_nodes, nodes, scope,
now)
for i, id in ipairs(ids) do
local key = scope .. "|" .. id
- local ineligible_key = key .. INELIGIBLE_SUFFIX
local start_at = tonumber(shdict:get(key))
- local ineligible_since = tonumber(shdict:get(ineligible_key))
local changed
if not start_at then
@@ -327,22 +318,12 @@ local function reconcile(conf, present_nodes, nodes,
scope, now)
end
end
- elseif start_at == PENDING then
- -- the first picker it can actually be part of: this is where its
- -- window starts, not when the configuration first mentioned it
- start_at, changed = replace_start(key, PENDING, now, window)
- if changed then
- log_began(id, nodes[i].weight)
- end
-
- elseif ineligible_since and now - ineligible_since > window then
- -- observed out of the picker for longer than a full window:
whatever
- -- answers on this address now is not the process that was ramping
- local out_for = now - ineligible_since
+ elseif start_at < MATURE then
+ -- the first picker it can be part of since it was configured or
+ -- came back: this is where its window starts
start_at, changed = replace_start(key, start_at, now, window)
if changed then
- core.log.info("slow start restarted for node ", id, " of
upstream ",
- scope, " after ", out_for, "s out of the picker")
+ log_began(id, nodes[i].weight)
end
elseif start_at > MATURE and now - start_at >= window then
@@ -353,16 +334,9 @@ local function reconcile(conf, present_nodes, nodes,
scope, now)
end
else
- -- refresh the lifetime, which also drops the tombstone window if
the
- -- node came back after having left the configuration
keep_alive(key, STATE_TTL)
end
- if ineligible_since then
- -- back in the picker
- shdict:delete(ineligible_key)
- end
-
first_seen[i] = start_at
if start_at > MATURE then
deadline = math_max(deadline, start_at + window)
@@ -373,49 +347,40 @@ local function reconcile(conf, present_nodes, nodes,
scope, now)
if not eligible[id] then
local key = scope .. "|" .. id
local start_at = tonumber(shdict:get(key))
- if start_at then
- -- still configured, just not pickable here: keep the state
alive
- -- and mark when it dropped out, keeping the earliest mark. The
- -- ramp clock itself runs on, so a short outage resumes where
it
- -- left off
- keep_alive(key, STATE_TTL)
- local ineligible_key = key .. INELIGIBLE_SUFFIX
- if not shdict:add(ineligible_key, now, STATE_TTL) then
- keep_alive(ineligible_key, STATE_TTL)
+ if not start_at then
+ -- its window starts when it becomes usable
+ create_start(key, -now)
+ elseif start_at >= MATURE then
+ -- taken out of the picker by a health check: the ramp or the
+ -- maturity ends here, and it ramps again from its next picker
+ local _, changed = replace_start(key, start_at, -now, window)
+ if changed then
+ core.log.info("slow start interrupted for node ", id,
+ " of upstream ", scope, ", out of the
picker")
end
- elseif baseline or (known and known[id]) then
- -- part of the bootstrap set even though a health check has not
- -- cleared it yet, or state the shared dict evicted: every node
- -- the previous reconcile saw was given state then
- start_at = create_start(key, MATURE)
else
- -- configured but never picked: its window starts when it first
- -- becomes usable
- start_at = create_start(key, PENDING)
- end
-
- -- a ramp this worker cannot pick from is still a ramp other
workers
- -- may be serving, and the deadline is what keeps their picker keys
- -- moving; leaving it out would let this worker publish an early
one
- if start_at > MATURE then
- deadline = math_max(deadline, start_at + window)
+ keep_alive(key, STATE_TTL)
end
end
end
- -- A node that left the configuration keeps its state for one slow start
- -- window and is then forgotten, so that the address coming back later - a
- -- different process behind the same host and port - is warmed up again.
+ -- A node that left the configuration is forgotten, so that coming back -
very
+ -- likely a different process behind the same host and port - ramps it
again.
+ -- Inside the startup grace period it is not forgotten but left waiting
for a
+ -- picker until the period ends, as the grace only covers nodes observed
for
+ -- the first time.
if known then
for id in pairs(known) do
if not present[id] then
local key = scope .. "|" .. id
if shdict:get(key) then
core.log.info("node ", id, " of upstream ", scope,
- " left the upstream, keeping its slow start
state for ",
- window, "s")
- keep_alive(key, window)
- keep_alive(key .. INELIGIBLE_SUFFIX, window)
+ " left the upstream, it ramps again if it
comes back")
+ if grace_left > 0 then
+ store(key, -now, grace_left)
+ else
+ shdict:delete(key)
+ end
end
end
end
diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md
index 39ec14f9ba..9d0e8e81d9 100644
--- a/docs/en/latest/admin-api.md
+++ b/docs/en/latest/admin-api.md
@@ -1028,7 +1028,7 @@ In addition to the equalization algorithm selections,
Upstream also supports pas
| warm_up_conf.min_weight_percent | True, when `warm_up_conf` is set | Integer
| Lowest effective weight during the ramp, as a percentage of the configured
weight, from 1 to 100. | 1 |
| warm_up_conf.interval | False | Integer | Seconds between two effective
weight refreshes. Defaults to `1`, and cannot be greater than
`slow_start_time_seconds`. | 1 |
| warm_up_conf.aggression | False | Number | Shape of the ramp. `1` (default)
is linear, above `1` ramps up faster at the beginning, below `1` slower. At
least `0.01`. | 1 |
-| warm_up_conf.startup_grace_period_seconds | False | Integer | Seconds after
the gateway starts during which a node observed for the first time is treated
as already warmed up, so that a restart does not ramp the whole node set again.
Defaults to `0`. | 180 |
+| warm_up_conf.startup_grace_period_seconds | False | Integer | Seconds after
the gateway starts during which a node observed for the first time is treated
as already warmed up, so that a restart does not ramp the whole node set again.
A node that was already observed and then becomes unavailable still ramps when
it comes back, also during this period. Defaults to `0`. | 180 |
An Upstream can be one of the following `types`:
@@ -1081,7 +1081,7 @@ To allow Upstream to have a separate connection pool, use
`keepalive_pool`. It c
- The node set an Upstream has when the gateway first builds a load balancer
for it is treated as warmed up. So is the node set it already has when
`warm_up_conf` is turned on.
- A node added afterwards ramps from `min_weight_percent` back to its
configured weight over `slow_start_time_seconds`. A node held out of the load
balancer by a health check starts its ramp when it first becomes available.
-- A node that leaves the Upstream and comes back within
`slow_start_time_seconds` resumes its ramp. One that comes back later, or that
a health check kept out for longer than that, ramps again from the start.
+- A node that becomes unavailable, either because a health check takes it out
of the load balancer or because it leaves the Upstream, ends its ramp, or its
warmed-up state. When it is available again it ramps from `min_weight_percent`
from the start, however short the unavailability was, because the process
answering on that address may have just restarted. Changing the weight of a
node, including to `0`, does not count as unavailability.
- Every APISIX instance ramps independently, from the moment it observed the
node.
`warm_up_conf` is only supported by `roundrobin` Upstreams whose nodes share a
single priority, and it is rejected in the Upstreams of the `traffic-split`
Plugin, which are rebuilt per request. Like the other Upstream fields that only
apply to HTTP, it is ignored when the Upstream is used by a stream route. A
ramp only shifts traffic between nodes: a single-node Upstream, or one whose
nodes are all new, keeps sending every request to them.
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md
index c44aa6cba0..7bb6528cc1 100644
--- a/docs/zh/latest/admin-api.md
+++ b/docs/zh/latest/admin-api.md
@@ -1036,7 +1036,7 @@ APISIX 的 Upstream 除了基本的负载均衡算法选择外,还支持对上
|warm_up_conf.min_weight_percent | 设置 `warm_up_conf` 时必填 | 整型 |
爬坡期间有效权重的下限,以配置权重的百分比表示,取值范围 1 到 100。 |
|warm_up_conf.interval | 否 | 整型 | 两次刷新有效权重之间的间隔,单位为秒。默认为 `1`,不能大于
`slow_start_time_seconds`。 |
|warm_up_conf.aggression | 否 | 数值 | 爬坡曲线。`1`(默认)为线性,大于 `1` 前期增长更快,小于 `1`
前期增长更慢。最小值为 `0.01`。 |
-|warm_up_conf.startup_grace_period_seconds | 否 | 整型 |
网关启动后的宽限时间,单位为秒。在此期间首次观察到的节点直接视为已完成预热,避免重启后整组节点重新爬坡。默认为 `0`。 |
+|warm_up_conf.startup_grace_period_seconds | 否 | 整型 |
网关启动后的宽限时间,单位为秒。在此期间首次观察到的节点直接视为已完成预热,避免重启后整组节点重新爬坡。已经观察到的节点变为不可用后再回来,即使在此期间内也会重新爬坡。默认为
`0`。 |
`type` 详细信息如下:
@@ -1083,7 +1083,7 @@ APISIX 支持两种不同的方式来代理 WebSocket 连接,二者不能混
- `warm_up_conf` 为 `roundrobin` 类型的 Upstream
开启节点慢启动。节点是否为新节点由网关根据自己观察到的节点集合判断,每次爬坡的起点记录在本地的 `upstream-slow-start` 共享字典中:
- 网关首次为 Upstream 构建负载均衡器时已有的节点视为已完成预热,开启 `warm_up_conf` 时 Upstream 已有的节点同样如此。
- 之后新增的节点从 `min_weight_percent` 开始,在 `slow_start_time_seconds`
内逐步恢复到配置的权重。被健康检查挡在负载均衡器之外的节点,从它首次可用时开始爬坡。
- - 节点离开 Upstream 后在 `slow_start_time_seconds`
内回来,会继续原来的爬坡;更晚回来,或被健康检查排除超过该时长,会重新开始爬坡。
+ - 节点一旦不可用,无论是被健康检查排除出负载均衡器,还是离开了
Upstream,其爬坡或已完成预热的状态都会结束。再次可用时,无论不可用的时间多短,都会从 `min_weight_percent`
重新开始爬坡,因为该地址上响应的进程可能刚刚重启。修改节点权重(包括改为 `0`)不算不可用。
- 每个 APISIX 实例独立计时,起点为该实例观察到节点的时刻。
- `warm_up_conf` 仅支持节点优先级一致的 `roundrobin` 类型 Upstream,`traffic-split` 插件中的
Upstream 每请求重建,不允许配置。与其他只对 HTTP 生效的 Upstream 字段一样,该字段在 stream route 使用的
Upstream 上会被忽略。爬坡只在节点之间调整流量:单节点 Upstream,或所有节点都是新节点时,请求仍会全部发往这些节点。
diff --git a/t/node/upstream-slow-start.t b/t/node/upstream-slow-start.t
index 4fa385b473..5d8a01ee94 100644
--- a/t/node/upstream-slow-start.t
+++ b/t/node/upstream-slow-start.t
@@ -270,7 +270,7 @@ slow start finished for node 127.0.0.1:1981
-=== TEST 5: a node that comes back inside the tombstone window keeps its
progress
+=== TEST 5: a node that leaves mid-ramp and comes back inside the window ramps
again
--- timeout: 30
--- config
location /t {
@@ -286,9 +286,9 @@ slow start finished for node 127.0.0.1:1981
"type": "roundrobin",
"nodes": ]] .. nodes .. [[,
"warm_up_conf": {
- "slow_start_time_seconds": 10,
+ "slow_start_time_seconds": 20,
"min_weight_percent": 1,
- "interval": 10
+ "interval": 20
}
}]]
)
@@ -342,7 +342,7 @@ slow start finished for node 127.0.0.1:1981
ngx.sleep(0.5)
split(1)
- -- it leaves, and comes back well inside the 10s tombstone window
+ -- it leaves, and comes back well inside the 20s window
set_upstream(one)
ngx.sleep(0.5)
split(1)
@@ -356,12 +356,12 @@ slow start finished for node 127.0.0.1:1981
return
end
- -- the ramp continued while it was away, so 1981 is about half way
- -- through the window: a real share of the traffic, still below
1980.
- -- Restarting the ramp would have put it back at a tenth of the
weight
+ -- whatever answers on 1981 now may have just started, so it ramps
+ -- from the first bucket again, a twentieth of the weight. Resuming
+ -- the ramp it had would have given it about a quarter
local mature, warming = ports["1980"] or 0, ports["1981"] or 0
- ngx.log(ngx.WARN, "tombstone split: 1980=", mature, " 1981=",
warming)
- if warming >= 15 and warming < mature then
+ ngx.log(ngx.WARN, "re-added split: 1980=", mature, " 1981=",
warming)
+ if warming >= 1 and warming <= 10 then
ngx.say("passed")
else
ngx.say("failed: 1980=", mature, " 1981=", warming)
@@ -374,6 +374,7 @@ passed
qr/slow start began for node \S+/
--- grep_error_log_out
slow start began for node 127.0.0.1:1981
+slow start began for node 127.0.0.1:1981
@@ -775,14 +776,15 @@ slow start began for node 10.0.0.2:8080
slow_start.effective_weights(up_conf, {n1})
up_conf.nodes = {n1, n2}
slow_start.effective_weights(up_conf, {n1})
- ngx.say("pending: ", dict:get(key))
+ local pending = dict:get(key)
+ ngx.say("pending: ", pending < 0)
-- both workers see n2 enter the picker; the other one elects first
local elected = ngx.now() - 50
local weights
run(function()
race(key, function()
- assert(dict:add(key .. "@-1", elected, 10))
+ assert(dict:add(key .. "@" .. pending, elected, 10))
assert(dict:set(key, elected))
end)
weights = slow_start.effective_weights(up_conf, {n1, n2})
@@ -803,13 +805,231 @@ slow start began for node 10.0.0.2:8080
}
}
--- response_body
-pending: -1
+pending: true
adopted the elected start: true, weight 50
refresh left the change alone: 0
-=== TEST 12: clean up
+=== TEST 12: a node taken out by a health check ramps again however short the
outage
+--- config
+ location /t {
+ content_by_lua_block {
+ local slow_start = require("apisix.slow_start")
+ local dict = ngx.shared["upstream-slow-start"]
+ local n1 = {host = "10.0.2.1", port = 80, weight = 100, priority =
0}
+ local n2 = {host = "10.0.2.2", port = 80, weight = 100, priority =
0}
+ local function upstream(scope)
+ return {
+ resource_key = scope,
+ type = "roundrobin",
+ nodes = {n1, n2},
+ warm_up_conf = {
+ slow_start_time_seconds = 100,
+ min_weight_percent = 1,
+ interval = 1,
+ aggression = 1,
+ },
+ }
+ end
+
+ -- a mature node
+ local up_conf = upstream("/upstreams/unhealthy-mature")
+ local weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("mature: ", weights[2])
+ slow_start.effective_weights(up_conf, {n1})
+ weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("mature, back: ", weights[2])
+
+ -- a node half way through its ramp
+ up_conf = upstream("/upstreams/unhealthy-ramping")
+ up_conf.nodes = {n1}
+ slow_start.effective_weights(up_conf, {n1})
+ up_conf.nodes = {n1, n2}
+ slow_start.effective_weights(up_conf, {n1, n2})
+ dict:set(up_conf.resource_key .. "|10.0.2.2:80", ngx.now() - 50)
+ weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("ramping: ", weights[2])
+ slow_start.effective_weights(up_conf, {n1})
+ weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("ramping, back: ", weights[2])
+ }
+ }
+--- response_body
+mature: 100
+mature, back: 1
+ramping: 50
+ramping, back: 1
+--- error_log
+slow start interrupted for node 10.0.2.2:80 of upstream
/upstreams/unhealthy-mature
+slow start interrupted for node 10.0.2.2:80 of upstream
/upstreams/unhealthy-ramping
+
+
+
+=== TEST 13: a node removed and added back ramps again however short the
absence
+--- config
+ location /t {
+ content_by_lua_block {
+ local slow_start = require("apisix.slow_start")
+ local dict = ngx.shared["upstream-slow-start"]
+ local n1 = {host = "10.0.3.1", port = 80, weight = 100, priority =
0}
+ local n2 = {host = "10.0.3.2", port = 80, weight = 100, priority =
0}
+ local function upstream(scope)
+ return {
+ resource_key = scope,
+ type = "roundrobin",
+ nodes = {n1, n2},
+ warm_up_conf = {
+ slow_start_time_seconds = 100,
+ min_weight_percent = 1,
+ interval = 1,
+ aggression = 1,
+ },
+ }
+ end
+
+ local function remove_and_add_back(up_conf)
+ up_conf.nodes = {n1}
+ slow_start.effective_weights(up_conf, {n1})
+ up_conf.nodes = {n1, n2}
+ return slow_start.effective_weights(up_conf, {n1, n2})
+ end
+
+ -- a mature node
+ local up_conf = upstream("/upstreams/removed-mature")
+ local weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("mature: ", weights[2])
+ weights = remove_and_add_back(up_conf)
+ ngx.say("mature, back: ", weights[2])
+
+ -- a node half way through its ramp
+ up_conf = upstream("/upstreams/removed-ramping")
+ up_conf.nodes = {n1}
+ slow_start.effective_weights(up_conf, {n1})
+ up_conf.nodes = {n1, n2}
+ slow_start.effective_weights(up_conf, {n1, n2})
+ dict:set(up_conf.resource_key .. "|10.0.3.2:80", ngx.now() - 50)
+ weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("ramping: ", weights[2])
+ weights = remove_and_add_back(up_conf)
+ ngx.say("ramping, back: ", weights[2])
+
+ -- nothing of a node that left lingers in the shared dict
+ up_conf.nodes = {n1}
+ slow_start.effective_weights(up_conf, {n1})
+ ngx.say("forgotten: ", dict:get(up_conf.resource_key ..
"|10.0.3.2:80") == nil)
+ }
+ }
+--- response_body
+mature: 100
+mature, back: 1
+ramping: 50
+ramping, back: 1
+forgotten: true
+
+
+
+=== TEST 14: inside the startup grace period only a node seen for the first
time is mature
+--- config
+ location /t {
+ content_by_lua_block {
+ local slow_start = require("apisix.slow_start")
+ local n1 = {host = "10.0.4.1", port = 80, weight = 100, priority =
0}
+ local n2 = {host = "10.0.4.2", port = 80, weight = 100, priority =
0}
+ local n3 = {host = "10.0.4.3", port = 80, weight = 100, priority =
0}
+ local up_conf = {
+ resource_key = "/upstreams/grace",
+ type = "roundrobin",
+ nodes = {n1, n2},
+ warm_up_conf = {
+ slow_start_time_seconds = 100,
+ min_weight_percent = 1,
+ interval = 1,
+ aggression = 1,
+ startup_grace_period_seconds = 3600,
+ },
+ }
+
+ slow_start.effective_weights(up_conf, {n1, n2})
+
+ -- observed for the first time: mature
+ up_conf.nodes = {n1, n2, n3}
+ local weights = slow_start.effective_weights(up_conf, {n1, n2, n3})
+ ngx.say("first seen: ", weights[3])
+
+ -- seen, then removed, then back: ramps
+ up_conf.nodes = {n1, n2}
+ slow_start.effective_weights(up_conf, {n1, n2})
+ up_conf.nodes = {n1, n2, n3}
+ weights = slow_start.effective_weights(up_conf, {n1, n2, n3})
+ ngx.say("removed, back: ", weights[3])
+
+ -- seen, then unhealthy, then back: ramps
+ slow_start.effective_weights(up_conf, {n1, n3})
+ weights = slow_start.effective_weights(up_conf, {n1, n2, n3})
+ ngx.say("unhealthy, back: ", weights[2])
+ }
+ }
+--- response_body
+first seen: 100
+removed, back: 1
+unhealthy, back: 1
+
+
+
+=== TEST 15: a node away for longer than a window ramps from when it is back,
not from when it left
+--- timeout: 15
+--- config
+ location /t {
+ content_by_lua_block {
+ local slow_start = require("apisix.slow_start")
+ local n1 = {host = "10.0.5.1", port = 80, weight = 100, priority =
0}
+ local n2 = {host = "10.0.5.2", port = 80, weight = 100, priority =
0}
+ local function upstream(scope)
+ -- the floor is above the first bucket of a 2s window, so a
ramp
+ -- that started now reads exactly the floor
+ return {
+ resource_key = scope,
+ type = "roundrobin",
+ nodes = {n1, n2},
+ warm_up_conf = {
+ slow_start_time_seconds = 2,
+ min_weight_percent = 60,
+ interval = 1,
+ aggression = 1,
+ },
+ }
+ end
+
+ local function away_longer_than_the_window()
+ ngx.sleep(2.5)
+ ngx.update_time()
+ end
+
+ local up_conf = upstream("/upstreams/excluded-long")
+ slow_start.effective_weights(up_conf, {n1, n2})
+ slow_start.effective_weights(up_conf, {n1})
+ away_longer_than_the_window()
+ local weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("excluded, back: ", weights[2])
+
+ up_conf = upstream("/upstreams/removed-long")
+ slow_start.effective_weights(up_conf, {n1, n2})
+ up_conf.nodes = {n1}
+ slow_start.effective_weights(up_conf, {n1})
+ away_longer_than_the_window()
+ up_conf.nodes = {n1, n2}
+ weights = slow_start.effective_weights(up_conf, {n1, n2})
+ ngx.say("removed, back: ", weights[2])
+ }
+ }
+--- response_body
+excluded, back: 60
+removed, back: 60
+
+
+
+=== TEST 16: clean up
--- config
location /t {
content_by_lua_block {