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 {

Reply via email to