This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 36e5ef1294 fix(healthcheck): reuse the checker on node-only upstream
changes instead of destroy-and-rebuild (#13629)
36e5ef1294 is described below
commit 36e5ef1294cdd363beb55ada8b58e3778d4707e0
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 15 08:46:29 2026 +0800
fix(healthcheck): reuse the checker on node-only upstream changes instead
of destroy-and-rebuild (#13629)
---
apisix/healthcheck_manager.lua | 266 +++++++++-
t/node/healthcheck-discovery.t | 7 +-
t/node/healthcheck-dns.t | 3 +-
t/node/healthcheck-incremental-update.t | 788 ++++++++++++++++++++++++++++
t/node/healthcheck-leak-bugfix.t | 10 +-
t/node/healthcheck-multiworker-reconcile.t | 221 ++++++++
t/node/healthcheck-service-discovery.t | 3 +-
t/node/healthcheck-stop-checker.t | 8 +-
t/node/healthchecker-independent-upstream.t | 4 -
9 files changed, 1267 insertions(+), 43 deletions(-)
diff --git a/apisix/healthcheck_manager.lua b/apisix/healthcheck_manager.lua
index f2750ff49b..14d1a966d8 100644
--- a/apisix/healthcheck_manager.lua
+++ b/apisix/healthcheck_manager.lua
@@ -31,7 +31,8 @@ local jp = require("jsonpath")
local config_util = require("apisix.core.config_util")
local _M = {}
-local working_pool = {} -- resource_path -> {version = ver, checker =
checker}
+-- resource_path -> {version = ver, checker = checker, checks = checks}
+local working_pool = {}
local waiting_pool = {} -- resource_path -> resource_ver
local DELAYED_CLEAR_TIMEOUT = 10
@@ -44,6 +45,40 @@ end
_M.get_healthchecker_name = get_healthchecker_name
+-- Compute the desired set of health-check targets for an upstream config.
+-- Returns an ordered array preserving up_conf.nodes order so that targets are
+-- always added to a checker deterministically; each entry also carries a
+-- "host:port:hostname:hostheader" key so the working set can be diffed cheaply
+-- against a checker's current targets. The key mirrors resty.healthcheck's
target
+-- identity (ip+port+hostname) plus the Host header, so a checks.active.host
change
+-- (which changes the hostname) is treated as a different target and its stale
shm
+-- entry is removed instead of colliding on the same key.
+local function compute_targets(up_conf)
+ local host = up_conf.checks and up_conf.checks.active and
up_conf.checks.active.host
+ local port = up_conf.checks and up_conf.checks.active and
up_conf.checks.active.port
+ local up_hdr = up_conf.pass_host == "rewrite" and up_conf.upstream_host
+ local use_node_hdr = up_conf.pass_host == "node" or nil
+
+ local targets = {}
+ for _, node in ipairs(up_conf.nodes) do
+ local host_hdr = up_hdr or (use_node_hdr and node.domain) or nil
+ local target_port = port or node.port
+ -- add_target defaults the hostname to the ip when checks.active.host
is
+ -- unset, so mirror that here to match the shm entry's stored hostname
+ local hostname = host or node.host
+ targets[#targets + 1] = {
+ host = node.host,
+ port = target_port,
+ check_host = host,
+ host_hdr = host_hdr,
+ key = node.host .. ":" .. tostring(target_port) .. ":" ..
tostring(hostname)
+ .. ":" .. tostring(host_hdr or ""),
+ }
+ end
+ return targets
+end
+
+
local function create_checker(up_conf)
if not up_conf.checks then
return nil
@@ -70,19 +105,42 @@ local function create_checker(up_conf)
return nil
end
- -- Add target nodes
- local host = up_conf.checks and up_conf.checks.active and
up_conf.checks.active.host
- local port = up_conf.checks and up_conf.checks.active and
up_conf.checks.active.port
- local up_hdr = up_conf.pass_host == "rewrite" and up_conf.upstream_host
- local use_node_hdr = up_conf.pass_host == "node" or nil
+ local targets = compute_targets(up_conf)
+ local desired = {}
+ for _, target in ipairs(targets) do
+ desired[target.key] = true
+ end
- for _, node in ipairs(up_conf.nodes) do
- local host_hdr = up_hdr or (use_node_hdr and node.domain)
- local ok, err = checker:add_target(node.host, port or node.port, host,
- true, host_hdr)
+ -- Remove stale targets from the shared shm BEFORE adding, mirroring
+ -- sync_checker_targets. resty.healthcheck keys a target by
ip+port+hostname
+ -- (the Host header is not part of that identity), so a Host-header-only
change
+ -- must free the old identity first -- otherwise add_target is a no-op on
the
+ -- existing identity and the following remove_target then wipes the
+ -- still-desired target. The shm may also hold nodes another worker
created and
+ -- this config later dropped (apache/apisix#13282, multi-worker).
+ local target_list =
healthcheck.get_target_list(get_healthchecker_name(up_conf),
+ healthcheck_shdict_name)
or {}
+ for _, t in ipairs(target_list) do
+ local key = t.ip .. ":" .. tostring(t.port) .. ":" ..
tostring(t.hostname)
+ .. ":" .. tostring(t.hostheader or "")
+ if not desired[key] then
+ local ok, err = checker:remove_target(t.ip, t.port, t.hostname)
+ if not ok then
+ core.log.error("failed to remove healthcheck target: ", t.ip,
":",
+ t.port, " err: ", err)
+ end
+ end
+ end
+
+ -- Add all desired nodes, in node order. Re-adding an already-present
target is
+ -- a no-op except that it clears any pending purge_time, which is what
un-marks
+ -- surviving targets after a delayed_clear() on a checks-config rebuild.
+ for _, target in ipairs(targets) do
+ local ok, err = checker:add_target(target.host, target.port,
target.check_host,
+ true, target.host_hdr)
if not ok then
- core.log.error("failed to add healthcheck target: ", node.host,
":",
- port or node.port, " err: ", err)
+ core.log.error("failed to add healthcheck target: ", target.host,
":",
+ target.port, " err: ", err)
end
end
@@ -90,19 +148,100 @@ local function create_checker(up_conf)
end
+-- Incrementally reconcile an existing checker's targets to match up_conf.
+-- Used when only the upstream nodes changed but the `checks` config did not,
+-- so the checker can keep running (and keep its accumulated health state)
+-- instead of being destroyed and rebuilt.
+-- Returns true only if every add/remove succeeded; on a partial failure the
+-- caller must not treat the checker as reconciled for this version.
+local function sync_checker_targets(checker, up_conf)
+ -- index the desired targets by key so they can be diffed against current;
+ -- keep the ordered list too so adds preserve node order (deterministic)
+ local targets = compute_targets(up_conf)
+ local desired = {}
+ for _, target in ipairs(targets) do
+ desired[target.key] = target
+ end
+
+ -- index current targets the same way as desired. Read the authoritative
+ -- shm target list (the per-worker checker.targets array can lag behind a
+ -- recent add/remove event).
+ if not healthcheck then
+ healthcheck = require("resty.healthcheck")
+ end
+ local current = {}
+ local target_list =
healthcheck.get_target_list(get_healthchecker_name(up_conf),
+ healthcheck_shdict_name)
or {}
+ for _, t in ipairs(target_list) do
+ -- target_list entries carry hostheader; map it back to our key shape
+ local key = t.ip .. ":" .. tostring(t.port) .. ":" ..
tostring(t.hostname)
+ .. ":" .. tostring(t.hostheader or "")
+ current[key] = t
+ end
+
+ local synced = true
+
+ -- Remove stale targets BEFORE adding new ones. resty.healthcheck
identifies a
+ -- target by ip+port+hostname; the Host header is not part of that
identity. A
+ -- Host-header-only change (e.g. pass_host/upstream_host) therefore
produces a
+ -- removal of the old key and an addition of the new key for the same
identity.
+ -- Removing first frees that identity so the following add_target actually
+ -- applies the new Host header instead of being a no-op on an existing
target.
+ for key, t in pairs(current) do
+ if not desired[key] then
+ local ok, err = checker:remove_target(t.ip, t.port, t.hostname)
+ if not ok then
+ synced = false
+ core.log.error("failed to remove healthcheck target: ", t.ip,
":",
+ t.port, " err: ", err)
+ end
+ end
+ end
+
+ -- add targets that are desired but not present, in node order. Unlike
+ -- create_checker this does not re-add already-present targets, so it
cannot
+ -- un-mark a pending purge_time. That is safe here only because sync runs
on a
+ -- reuse-eligible checker (checks unchanged), which is never
delayed_clear'd --
+ -- delayed_clear only happens on a checks-change rebuild or a destroy.
+ for _, target in ipairs(targets) do
+ if not current[target.key] then
+ local ok, err = checker:add_target(target.host, target.port,
target.check_host,
+ true, target.host_hdr)
+ if not ok then
+ synced = false
+ core.log.error("failed to add healthcheck target: ",
target.host, ":",
+ target.port, " err: ", err)
+ end
+ end
+ end
+
+ return synced
+end
+
+
function _M.fetch_checker(resource_path, resource_ver)
local working_item = working_pool[resource_path]
if working_item and working_item.version == resource_ver then
return working_item.checker
end
- if waiting_pool[resource_path] == resource_ver then
- return nil
+ -- The requested version differs from the working checker -- e.g. a
+ -- discovery/DNS change bumped _nodes_ver. Enqueue the new version so
+ -- timer_create_checker reconciles (or rebuilds) it, but keep returning the
+ -- existing live checker in the meantime: its accumulated health state is
+ -- still valid, so requests during the ~1s transition keep filtering
+ -- unhealthy nodes instead of falling back to "all nodes available", which
+ -- would let a node already known to be unhealthy receive traffic
+ -- (apache/apisix#13282).
+ if waiting_pool[resource_path] ~= resource_ver then
+ core.log.info("adding ", resource_path, " to waiting pool with
version: ", resource_ver)
+ waiting_pool[resource_path] = resource_ver
+ end
+
+ if working_item and working_item.checker and not working_item.checker.dead
then
+ return working_item.checker
end
- -- Add to waiting pool with version
- core.log.info("adding ", resource_path, " to waiting pool with version: ",
resource_ver)
- waiting_pool[resource_path] = resource_ver
return nil
end
@@ -130,10 +269,11 @@ function _M.fetch_node_status(checker, ip, port, hostname)
end
-local function add_working_pool(resource_path, resource_ver, checker)
+local function add_working_pool(resource_path, resource_ver, checker, checks)
working_pool[resource_path] = {
version = resource_ver,
- checker = checker
+ checker = checker,
+ checks = checks,
}
end
@@ -209,22 +349,75 @@ local function timer_create_checker()
goto continue
end
- -- if a checker exists then delete it before creating a new one
+ -- No nodes means there is nothing to health-check. Don't build (or
+ -- rebuild into) an empty checker here; leave any teardown to
+ -- timer_working_pool_check, which destroys the checker when the
node
+ -- count drops to 0, so the two timers stay consistent.
+ if not upstream.nodes or #upstream.nodes == 0 then
+ goto continue
+ end
+
+ -- Reuse path: if a checker exists and the `checks` config is
unchanged
+ -- (only the nodes changed), reconcile its targets in place
instead of
+ -- rebuilding. Rebuilding leaves fetch_checker with no checker for
the
+ -- rebuild window (traffic then skips health filtering) and
discards the
+ -- accumulated health state. sync_checker_targets is the last
condition so
+ -- it runs only when the checker is reuse-eligible; a partial
failure makes
+ -- the guard false and falls through to the full rebuild below.
+ -- upstream.nodes is non-empty here (guaranteed by the 0-node
guard above).
local existing_checker = working_pool[resource_path]
+ if existing_checker and existing_checker.checker
+ and not existing_checker.checker.dead
+ and upstream.checks
+ and core.table.deep_eq(existing_checker.checks, upstream.checks)
+ and sync_checker_targets(existing_checker.checker, upstream)
then
+ add_working_pool(resource_path, resource_ver,
existing_checker.checker,
+ upstream.checks)
+ core.log.info("reused checker with incremental targets: ",
+ tostring(existing_checker.checker), " for
resource: ",
+ resource_path, " and version: ", resource_ver)
+ goto continue
+ end
+
+ -- Rebuild path: checks changed (or no checker exists).
delayed_clear()
+ -- MUST run before create_checker() re-adds the targets -- the new
checker
+ -- shares the same shm target list, and add_target() only un-marks
a
+ -- target's purge_time when re-added *after* it was marked.
Clearing first
+ -- lets surviving targets get un-marked on re-add while genuinely
dropped
+ -- targets keep their purge_time; clearing after would leave live
targets
+ -- marked and purge them later. The old checker is stopped only
after the
+ -- new one is published, so this path never exposes a nil checker.
if existing_checker then
existing_checker.checker:delayed_clear(DELAYED_CLEAR_TIMEOUT)
- existing_checker.checker:stop()
- core.log.info("releasing existing checker: ",
tostring(existing_checker.checker),
- " for resource: ", resource_path, " and version:
",
- existing_checker.version)
end
local checker = create_checker(upstream)
if not checker then
+ -- create_checker failed (upstream healthcheck disabled or
+ -- healthcheck.new errored). The old checker's shm targets were
+ -- already delayed_clear'd above, so it can no longer
health-check
+ -- reliably; tear it down and drop it from the working pool
instead
+ -- of leaving a stopped/cleared checker that fetch_checker
would
+ -- still hand out (it only checks .dead).
+ if existing_checker then
+ core.log.warn("releasing existing checker after create
failed: ",
+ tostring(existing_checker.checker), " for
resource: ",
+ resource_path, " and version: ",
existing_checker.version)
+ existing_checker.checker.dead = true
+ existing_checker.checker:stop()
+ working_pool[resource_path] = nil
+ end
goto continue
end
core.log.info("create new checker: ", tostring(checker), " for
resource: ",
resource_path, " and version: ", resource_ver)
- add_working_pool(resource_path, resource_ver, checker)
+ add_working_pool(resource_path, resource_ver, checker,
upstream.checks)
+ if existing_checker then
+ existing_checker.checker.dead = true
+ existing_checker.checker:stop()
+ core.log.info("releasing existing checker: ",
tostring(existing_checker.checker),
+ " for resource: ", resource_path, " and version:
",
+ existing_checker.version)
+ end
end
::continue::
@@ -284,11 +477,34 @@ local function timer_working_pool_check()
" current version: ", current_ver, " item version:
", item.version)
if item.version == current_ver then
need_destroy = false
+ elseif upstream.checks and upstream.nodes and #upstream.nodes
> 0 then
+ -- The version changed but the upstream still defines
checks and
+ -- keeps at least one node, so a same-name checker must
stay alive.
+ -- Do NOT destroy here (whether the change is nodes-only
or a checks
+ -- change): keep this checker and let timer_create_checker
transition
+ -- it -- a nodes-only change is reconciled incrementally,
a checks
+ -- change is rebuilt there by building the new checker
first and only
+ -- then stopping the old one. Destroying here would blank
+ -- working_pool until the next timer_create_checker tick,
reopening
+ -- the nil window (fetch_checker returns nil -> health
filtering
+ -- bypassed) this PR closes; on multi-worker it would also
clear the
+ -- shared shm and purge a peer worker's live targets
+ -- (apache/apisix#13282). Enqueue the rebuild so it runs
on this
+ -- timer even without traffic. When the node count drops
to 0 we fall
+ -- through to destroy, matching the original behaviour.
+ need_destroy = false
+ if waiting_pool[resource_path] ~= current_ver then
+ waiting_pool[resource_path] = current_ver
+ end
end
end
end
if need_destroy then
+ -- Reached only when no same-name checker will own the shared shm
target
+ -- list: the resource was deleted, or the new config has no
checks/nodes.
+ -- (A version change that still has checks and nodes is handled
above by
+ -- keeping the checker alive, so it never clears a peer's live
targets.)
working_pool[resource_path] = nil
item.checker.dead = true
item.checker:delayed_clear(DELAYED_CLEAR_TIMEOUT)
diff --git a/t/node/healthcheck-discovery.t b/t/node/healthcheck-discovery.t
index 9978bba93f..57a832bb32 100644
--- a/t/node/healthcheck-discovery.t
+++ b/t/node/healthcheck-discovery.t
@@ -108,7 +108,7 @@ unhealthy TCP increment (1/2) for '127.0.0.1(0.0.0.0:1988)'
-=== TEST 2: create new checker when nodes change
+=== TEST 2: reuse checker incrementally when nodes change
--- apisix_yaml
routes:
-
@@ -150,11 +150,10 @@ routes:
}
}
--- grep_error_log eval
-qr/(create new checker|releasing existing checker): table/
+qr/(create new checker|reused checker with incremental targets): table/
--- grep_error_log_out
create new checker: table
-releasing existing checker: table
-create new checker: table
+reused checker with incremental targets: table
--- timeout: 30
diff --git a/t/node/healthcheck-dns.t b/t/node/healthcheck-dns.t
index d0a76dbb63..cc433e81a1 100644
--- a/t/node/healthcheck-dns.t
+++ b/t/node/healthcheck-dns.t
@@ -140,6 +140,5 @@ First request status: 200
Second request status: 200
--- error_log
create new checker
-releasing existing checker
-create new checker
+reused checker with incremental targets
--- timeout: 10
diff --git a/t/node/healthcheck-incremental-update.t
b/t/node/healthcheck-incremental-update.t
new file mode 100644
index 0000000000..445f930bf9
--- /dev/null
+++ b/t/node/healthcheck-incremental-update.t
@@ -0,0 +1,788 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('warn');
+no_root_location();
+no_shuffle();
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: node-only change reuses the checker (no destroy-and-rebuild)
+--- extra_init_worker_by_lua
+ local healthcheck = require("resty.healthcheck")
+ local new = healthcheck.new
+ healthcheck.new = function(...)
+ ngx.log(ngx.WARN, "create new checker")
+ local obj = new(...)
+ local clear = obj.delayed_clear
+ obj.delayed_clear = function(...)
+ ngx.log(ngx.WARN, "clear checker")
+ return clear(...)
+ end
+ return obj
+ end
+
+--- config
+location /t {
+ content_by_lua_block {
+ local checks = [[{
+ "active":{
+ "http_path":"/hello",
+ "timeout":1,
+ "type":"http",
+ "healthy":{ "interval":1, "successes":1 },
+ "unhealthy":{ "interval":1, "http_failures":2 }
+ }
+ }]]
+ local function cfg(nodes)
+ return [[{
+ "upstream": {
+ "nodes": ]] .. nodes .. [[,
+ "type": "roundrobin",
+ "checks": ]] .. checks .. [[
+ },
+ "uri": "/hello"
+ }]]
+ end
+
+ local t = require("lib.test_admin").test
+ -- initial config: one node -> creates the checker
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1}')) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- node-only change (checks unchanged): should reconcile in place,
+ -- NOT create a new checker nor delayed_clear the old one
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1981": 1}')) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+ ngx.say("done")
+ }
+}
+
+--- request
+GET /t
+--- response_body
+done
+--- no_error_log
+clear checker
+--- error_log
+create new checker
+--- timeout: 8
+
+
+
+=== TEST 2: checks-config change still rebuilds the checker
+--- extra_init_worker_by_lua
+ local healthcheck = require("resty.healthcheck")
+ local new = healthcheck.new
+ healthcheck.new = function(...)
+ local obj = new(...)
+ local clear = obj.delayed_clear
+ obj.delayed_clear = function(...)
+ ngx.log(ngx.WARN, "clear checker")
+ return clear(...)
+ end
+ return obj
+ end
+
+--- config
+location /t {
+ content_by_lua_block {
+ local function cfg(interval)
+ return [[{
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin",
+ "checks": {
+ "active":{
+ "http_path":"/hello",
+ "timeout":1,
+ "type":"http",
+ "healthy":{ "interval":]] .. interval .. [[,
"successes":1 },
+ "unhealthy":{ "interval":1, "http_failures":2 }
+ }
+ }
+ },
+ "uri": "/hello"
+ }]]
+ end
+
+ local t = require("lib.test_admin").test
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(1)) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+ -- change the checks config -> must rebuild (delayed_clear old checker)
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(2)) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+ ngx.say("done")
+ }
+}
+
+--- request
+GET /t
+--- response_body
+done
+--- error_log
+clear checker
+--- timeout: 8
+
+
+
+=== TEST 3: surviving targets are not purged after a checks-config rebuild
+# Changing the checks config rebuilds the checker, which delayed_clear()s the
old
+# one. Because the new checker shares the same shm target list, the surviving
+# nodes must keep being health-checked: they must NOT be purged once the
+# delayed-clear window elapses. A wrong rebuild order (clear after re-add)
would
+# leave the live checker's targets marked and purge them here.
+--- config
+location /t {
+ content_by_lua_block {
+ local json = require("apisix.core.json")
+ local t = require("lib.test_admin").test
+ local function cfg(interval)
+ return [[{
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1, "127.0.0.1:1981": 1},
+ "type": "roundrobin",
+ "checks": {
+ "active":{
+ "http_path":"/hello",
+ "type":"http",
+ "healthy":{ "interval":]] .. interval .. [[,
"successes":1 },
+ "unhealthy":{ "interval":1, "http_failures":2 }
+ }
+ }
+ },
+ "uri": "/hello"
+ }]]
+ end
+ local function count_nodes()
+ local _, _, res = t('/v1/healthcheck', ngx.HTTP_GET)
+ local n = 0
+ for _, info in ipairs(json.decode(res)) do
+ n = n + #(info.nodes or {})
+ end
+ return n
+ end
+
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(1)) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- change the checks config (interval 1 -> 2) while keeping both nodes:
+ -- this rebuilds the checker through the delayed_clear path
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(2)) < 300)
+ t('/hello', ngx.HTTP_GET)
+
+ -- wait past DELAYED_CLEAR_TIMEOUT (10s) plus a cleanup window
+ ngx.sleep(15)
+
+ -- both surviving nodes must still be present in the live checker
+ ngx.say("nodes_after: ", count_nodes())
+ }
+}
+--- request
+GET /t
+--- response_body
+nodes_after: 2
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 30
+
+
+
+=== TEST 4: a node-only update keeps filtering an already-unhealthy node
during the transition
+# Before the fetch_checker fix, a node-only version change made fetch_checker()
+# return nil until the 1s timer reconciled, so api_ctx.up_checker was nil and
the
+# balancer fell back to all nodes -- a node already known unhealthy could take
+# traffic during the transition (apache/apisix#13282 health-filter bypass
window).
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local function put(nodes)
+ return t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "retries": 0,
+ "nodes": ]] .. nodes .. [[,
+ "checks": {
+ "active": {
+ "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 }
+ }
+ }
+ }
+ }]])
+ end
+
+ -- start with only the healthy node so the checker is created without
the
+ -- dead node ever being in the picker yet
+ assert(put('{"127.0.0.1:1980": 1}') < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(1)
+
+ -- add the dead node (node-only change) and let active checks mark it
+ -- unhealthy; a request is needed to enqueue the reconcile
+ assert(put('{"127.0.0.1:1980": 1, "127.0.0.1:1970": 1}') < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(3)
+
+ -- another node-only update opens a fresh version-transition window;
+ -- immediately burst requests before timer_create_checker reconciles
+ assert(put('{"127.0.0.1:1980": 1, "127.0.0.1:1970": 1}') < 300)
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+ local errors = 0
+ for _ = 1, 20 do
+ local httpc = http.new()
+ local res = httpc:request_uri(uri, { method = "GET", keepalive =
false })
+ if not res or res.status ~= 200 then
+ errors = errors + 1
+ end
+ end
+ -- the already-unhealthy dead node must stay filtered throughout
+ ngx.say("errors: ", errors)
+ }
+}
+--- request
+GET /t
+--- response_body
+errors: 0
+--- error_log
+unhealthy TCP increment
+--- timeout: 15
+
+
+
+=== TEST 5: create_checker removes targets left stale in the shm by another
worker
+# Multi-worker: a peer worker created the checker with a node that was later
+# removed, leaving it in the shared shm. A worker that never had the checker
+# reaches create_checker(), which must reconcile the shm (not just add) so the
+# stale node stops being probed and reported by /v1/healthcheck
+# (apache/apisix#13282, multi-worker).
+--- config
+location /t {
+ content_by_lua_block {
+ local json = require("apisix.core.json")
+ local t = require("lib.test_admin").test
+
+ -- simulate a peer worker: seed route 1's checker shm target list with
a
+ -- node (1970) that the config below will not contain
+ local healthcheck = require("resty.healthcheck")
+ local seed = healthcheck.new({
+ name = "upstream#/apisix/routes/1",
+ shm_name = "upstream-healthcheck",
+ events_module = "resty.events",
+ checks = { active = { type = "tcp",
+ healthy = { interval = 100, successes = 1 },
+ unhealthy = { interval = 100, tcp_failures = 1 } } },
+ })
+ seed:add_target("127.0.0.1", 1970, nil, true)
+
+ -- this worker has no checker in its working pool, so the first request
+ -- goes through create_checker() for the current config {1980}
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- create_checker's reconcile must have removed the stale 1970
+ local _, _, res = t('/v1/healthcheck', ngx.HTTP_GET)
+ local has_1970 = false
+ for _, info in ipairs(json.decode(res)) do
+ for _, node in ipairs(info.nodes or {}) do
+ if node.port == 1970 then has_1970 = true end
+ end
+ end
+ ngx.say("stale_1970: ", tostring(has_1970))
+ }
+}
+--- request
+GET /t
+--- response_body
+stale_1970: false
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 8
+
+
+
+=== TEST 6: destroying a stale local checker does not purge a peer worker's
live shm targets
+# Multi-worker: after a checks-config change, a worker that did NOT serve
traffic
+# keeps its old-version checker in working_pool. timer_working_pool_check then
+# destroys that stale local handle. Because the checker's shm target list is
+# shared by name, a peer worker's live checker (built for the new config) owns
it.
+# The destroy path must NOT delayed_clear() that shm, or the peer's live
targets
+# are purged on every worker once the delayed-clear window elapses
+# (apache/apisix#13282, multi-worker).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ -- peer worker (worker A): a live, running checker for the same
resource
+ -- that owns the shared shm target list and holds node 1980
+ local peer = healthcheck.new({
+ name = NAME, shm_name = SHM, events_module = "resty.events",
+ checks = { active = { type = "tcp",
+ healthy = { interval = 1, successes = 1 },
+ unhealthy = { interval = 1, tcp_failures = 1 } } },
+ })
+ peer:add_target("127.0.0.1", 1980, nil, true)
+
+ local function cfg(interval)
+ return [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": ]] .. interval .. [[,
"successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]
+ end
+
+ -- this worker builds its own checker at checks-interval 1 (serves
once)
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(1)) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- change the checks config but send NO request to route 1: this worker
+ -- never rebuilds, so working_pool keeps the old-version checker.
+ -- timer_working_pool_check sees the checks change and destroys it.
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg(2)) < 300)
+
+ -- wait past DELAYED_CLEAR_TIMEOUT (10s) + a cleanup window
+ ngx.sleep(15)
+
+ -- the peer's live target must survive in the shared shm
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local live_1980 = false
+ for _, tg in ipairs(list) do
+ if tg.port == 1980 then live_1980 = true end
+ end
+ ngx.say("live_1980: ", tostring(live_1980))
+ }
+}
+--- request
+GET /t
+--- response_body
+live_1980: true
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 30
+
+
+
+=== TEST 7: recreating the same upstream id during the delayed-clear window
keeps its targets
+# Deleting an upstream schedules a delayed_clear() of its shm target list. If
the
+# same id is recreated within that window and served, create_checker() re-adds
the
+# targets, which must un-mark the pending purge_time so the recreated node is
NOT
+# purged when the window elapses (apache/apisix#13282).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ local cfg = [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]
+
+ -- create + build checker
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- delete: timer_working_pool_check destroys and delayed_clear()s the
shm
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_DELETE) < 300)
+ ngx.sleep(2) -- let the destroy fire, still within the 10s clear
window
+
+ -- recreate the SAME id within the window and serve it ->
create_checker re-adds
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg) < 300)
+ t('/hello', ngx.HTTP_GET)
+
+ -- wait past the original delayed_clear window
+ ngx.sleep(12)
+
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local live_1980 = false
+ for _, tg in ipairs(list) do
+ if tg.port == 1980 then live_1980 = true end
+ end
+ ngx.say("live_1980: ", tostring(live_1980))
+ }
+}
+--- request
+GET /t
+--- response_body
+live_1980: true
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 40
+
+
+
+=== TEST 8: deleting an upstream cleans its shm target list
+# Deleting an upstream (with a live checker) must eventually remove its targets
+# from the shared shm so a stale node is no longer probed or reported by
+# /v1/healthcheck.
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_DELETE) < 300)
+ -- wait past the delayed_clear window plus a cleanup margin
+ ngx.sleep(15)
+
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ ngx.say("targets_after_delete: ", #list)
+ }
+}
+--- request
+GET /t
+--- response_body
+targets_after_delete: 0
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 30
+
+
+
+=== TEST 9: create_checker applies a Host-header-only change instead of wiping
the target
+# Multi-worker: a peer worker registered node 1980 with Host header "old.com".
+# The config now wants the same ip+port but Host header "new.com". Since
+# resty.healthcheck keys a target by ip+port+hostname (not the Host header),
+# create_checker must remove the stale key BEFORE adding -- otherwise the add
is
+# a no-op on the existing identity and the following remove wipes the target
+# entirely (apache/apisix#13282).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ -- peer worker: same identity (127.0.0.1:1980) with Host header
"old.com"
+ local seed = healthcheck.new({
+ name = NAME, shm_name = SHM, events_module = "resty.events",
+ checks = { active = { type = "tcp",
+ healthy = { interval = 100, successes = 1 },
+ unhealthy = { interval = 100, tcp_failures = 1 } } },
+ })
+ seed:add_target("127.0.0.1", 1980, nil, true, "old.com")
+
+ -- this worker has no checker; the first request runs create_checker()
for
+ -- a config that wants the same node with Host header "new.com"
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "pass_host": "rewrite",
+ "upstream_host": "new.com",
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- the target must survive with the new Host header, not be wiped
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local hdr = "<absent>"
+ for _, tg in ipairs(list) do
+ if tg.port == 1980 then hdr = tostring(tg.hostheader) end
+ end
+ ngx.say("hdr: ", hdr)
+ }
+}
+--- request
+GET /t
+--- response_body
+hdr: new.com
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 8
+
+
+
+=== TEST 10: create_checker removes a stale target when checks.active.host
(hostname) changes
+# resty.healthcheck keys a target by ip+port+hostname, and checks.active.host
maps
+# to that hostname. A multi-worker cold start where a peer registered the node
+# under one active-check host and the config now uses another must remove the
+# stale identity, not leave both probed and reported (apache/apisix#13282).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ -- peer worker: node 1980 registered with active-check hostname
"old-host"
+ local seed = healthcheck.new({
+ name = NAME, shm_name = SHM, events_module = "resty.events",
+ checks = { active = { type = "http", http_path = "/status",
+ healthy = { interval = 100, successes = 1 },
+ unhealthy = { interval = 100, http_failures = 1 } } },
+ })
+ seed:add_target("127.0.0.1", 1980, "old-host", true)
+
+ -- this worker has no checker; create_checker runs for a config whose
+ -- checks.active.host is "new-host" (same ip+port, different identity)
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"127.0.0.1:1980": 1},
+ "checks": { "active": { "type": "http", "http_path": "/status",
+ "host": "new-host",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "http_failures": 1 } } }
+ }
+ }]]) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- the stale "old-host" identity must be gone; only "new-host" remains
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local hosts = {}
+ for _, tg in ipairs(list) do
+ if tg.port == 1980 then hosts[#hosts + 1] = tostring(tg.hostname)
end
+ end
+ table.sort(hosts)
+ ngx.say("hostnames: ", table.concat(hosts, ","))
+ }
+}
+--- request
+GET /t
+--- response_body
+hostnames: new-host
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 8
+
+
+
+=== TEST 11: a nodes-only update reconciles the checker even without traffic
+# timer_working_pool_check keeps the checker alive on a nodes-only change, but
the
+# reconcile runs in timer_create_checker which drains waiting_pool.
waiting_pool
+# is normally filled by fetch_checker (request-driven), so without this
worker's
+# timer enqueuing the new version, a nodes-only update on an upstream that
gets no
+# further traffic would keep probing/reporting the old node set
(apache/apisix#13282).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+ local function cfg(nodes)
+ return [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": ]] .. nodes .. [[,
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]
+ end
+ local function shm_ports()
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local ports = {}
+ for _, tg in ipairs(list) do
+ ports[#ports + 1] = tg.port
+ end
+ table.sort(ports)
+ return table.concat(ports, ",")
+ end
+
+ -- build the checker with the initial node set (one request)
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1981": 1}')) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- nodes-only change (remove 1981, add 1982) but send NO request to
route 1;
+ -- the reconcile must still happen, driven by timer_working_pool_check
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1982": 1}')) < 300)
+ ngx.sleep(3)
+
+ ngx.say("ports: ", shm_ports())
+ }
+}
+--- request
+GET /t
+--- response_body
+ports: 1980,1982
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 8
+
+
+
+=== TEST 12: a checks-config change reconciles without traffic (no orphaned
shm targets)
+# On a checks-config change, timer_working_pool_check destroys the local handle
+# but must not clear the shared shm (a peer worker may own it). If every worker
+# goes cold, nothing would rebuild and the old targets would be left neither
+# probed nor purged. timer_working_pool_check therefore enqueues a rebuild so
+# timer_create_checker reconciles the shm on its per-worker timer, without
traffic
+# (apache/apisix#13282).
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local t = require("lib.test_admin").test
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+ local function cfg(interval, nodes)
+ return [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": ]] .. nodes .. [[,
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": ]] .. interval .. [[,
"successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]
+ end
+ local function shm_ports()
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local ports = {}
+ for _, tg in ipairs(list) do
+ ports[#ports + 1] = tg.port
+ end
+ table.sort(ports)
+ return table.concat(ports, ",")
+ end
+
+ -- build the checker (one request)
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg(1, '{"127.0.0.1:1980": 1, "127.0.0.1:1981": 1}')) < 300)
+ t('/hello', ngx.HTTP_GET)
+ ngx.sleep(2)
+
+ -- change the checks config (and the nodes) but send NO request to
route 1;
+ -- the shm must still reconcile to the new node set, driven by the
timers
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg(2, '{"127.0.0.1:1980": 1, "127.0.0.1:1982": 1}')) < 300)
+ ngx.sleep(3)
+
+ ngx.say("ports: ", shm_ports())
+ }
+}
+--- request
+GET /t
+--- response_body
+ports: 1980,1982
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 8
diff --git a/t/node/healthcheck-leak-bugfix.t b/t/node/healthcheck-leak-bugfix.t
index bcab5689d1..4e6bf4e280 100644
--- a/t/node/healthcheck-leak-bugfix.t
+++ b/t/node/healthcheck-leak-bugfix.t
@@ -17,7 +17,8 @@
use t::APISIX 'no_plan';
repeat_each(1);
-log_level('warn');
+# the reuse path logs "reused checker with incremental targets" at info level
+log_level('info');
no_root_location();
no_shuffle();
@@ -25,7 +26,7 @@ run_tests();
__DATA__
-=== TEST 1: ensure the old check is cleared after configuration updated
+=== TEST 1: reuse the checker without clearing it on a version-only change
--- extra_init_worker_by_lua
local healthcheck = require("resty.healthcheck")
local new = healthcheck.new
@@ -103,6 +104,9 @@ location /t {
t('/hello', ngx.HTTP_GET)
ngx.sleep(2)
assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, cfg) < 300)
+ -- re-route a request so fetch_checker observes the new version and the
+ -- manager reconciles the existing checker's targets incrementally
+ t('/hello', ngx.HTTP_GET)
ngx.sleep(2)
}
}
@@ -110,5 +114,7 @@ location /t {
--- request
GET /t
--- error_log
+reused checker with incremental targets
+--- no_error_log
clear checker
--- timeout: 7
diff --git a/t/node/healthcheck-multiworker-reconcile.t
b/t/node/healthcheck-multiworker-reconcile.t
new file mode 100644
index 0000000000..1f044ee77d
--- /dev/null
+++ b/t/node/healthcheck-multiworker-reconcile.t
@@ -0,0 +1,221 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('info');
+no_root_location();
+no_shuffle();
+workers(2);
+worker_connections(256);
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: shared shm target list converges to the desired set under config
churn (2 workers)
+# With real requests round-robining across two workers, each worker reconciles
the
+# checker through its own request path/timers. The target membership lives in a
+# shm shared by checker name, so its final state must equal the desired node
set
+# after every kind of change -- node add/remove (incremental) and a
checks-config
+# change (rebuild) -- with no orphan targets and no purge of surviving targets
+# (apache/apisix#13282, multi-worker).
+#
+# NOTE: this is a convergence/non-regression check, not a deterministic
+# reproduction of the cross-worker asymmetry -- requests are distributed across
+# workers by the OS accept(), so it cannot force the "one worker rebuilds while
+# another destroys" interleaving. The authoritative deterministic reproduction
+# is TEST 6 in healthcheck-incremental-update.t.
+--- config
+location /t {
+ content_by_lua_block {
+ local healthcheck = require("resty.healthcheck")
+ local http = require("resty.http")
+ local t = require("lib.test_admin").test
+
+ local NAME = "upstream#/apisix/routes/1"
+ local SHM = "upstream-healthcheck"
+
+ local function cfg(nodes, interval)
+ return [[{
+ "uri": "/hello",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": ]] .. nodes .. [[,
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": ]] .. interval .. [[,
"successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]
+ end
+
+ -- drive real traffic so requests spread across both workers; assert
the
+ -- requests actually succeed, otherwise a silent upstream failure could
+ -- leave the shm unchanged and let the assertions pass without
exercising
+ -- checker creation/reconciliation
+ local function drive()
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+ for _ = 1, 16 do
+ local httpc = http.new()
+ local res = httpc:request_uri(uri, { method = "GET", keepalive
= false })
+ assert(res and res.status == 200, "drive request failed")
+ end
+ end
+
+ -- the desired set is worker-agnostic: read it from the shared shm
+ local function shm_ports()
+ local list = healthcheck.get_target_list(NAME, SHM) or {}
+ local ports = {}
+ for _, tg in ipairs(list) do
+ ports[#ports + 1] = tg.port
+ end
+ table.sort(ports)
+ return table.concat(ports, ",")
+ end
+
+ ngx.sleep(2) -- let both workers settle
+
+ -- phase 1: two nodes -> both must be registered
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1981": 1}', 1)) < 300)
+ drive()
+ ngx.sleep(3)
+ ngx.say("phase1: ", shm_ports())
+
+ -- phase 2: node-only change (remove 1981, add 1982) -> incremental
reconcile,
+ -- shm must drop the orphan and add the new node
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1982": 1}', 1)) < 300)
+ drive()
+ ngx.sleep(3)
+ ngx.say("phase2: ", shm_ports())
+
+ -- phase 3: checks-config change (interval 1 -> 2), same nodes ->
rebuild.
+ -- wait past DELAYED_CLEAR_TIMEOUT (10s): surviving targets must NOT
be purged
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT,
+ cfg('{"127.0.0.1:1980": 1, "127.0.0.1:1982": 1}', 2)) < 300)
+ drive()
+ ngx.sleep(14)
+ ngx.say("phase3: ", shm_ports())
+ }
+}
+--- request
+GET /t
+--- response_body
+phase1: 1980,1981
+phase2: 1980,1982
+phase3: 1980,1982
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 40
+
+
+
+=== TEST 2: an unhealthy node is detected and filtered consistently across
workers
+# The basic multi-worker health scenario: a healthy node (1980) and a dead node
+# (1970). Active checks must mark 1970 unhealthy in the shared shm, and with
+# retries=0 every request across both workers must still succeed -- proving
both
+# workers filter the unhealthy node (a per-worker miss would send some requests
+# to 1970 and fail). This verifies health STATUS propagation, not just
membership.
+--- config
+location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local t = require("lib.test_admin").test
+ local json = require("apisix.core.json")
+
+ assert(t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/server_port",
+ "upstream": {
+ "type": "roundrobin",
+ "retries": 0,
+ "nodes": {"127.0.0.1:1980": 1, "127.0.0.1:1970": 1},
+ "checks": { "active": { "type": "tcp",
+ "healthy": { "interval": 1, "successes": 1 },
+ "unhealthy": { "interval": 1, "tcp_failures": 1 } } }
+ }
+ }]]) < 300)
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/server_port"
+ -- a burst returns how many of n requests did NOT get 200 from 1980,
i.e.
+ -- landed on the dead node 1970 (retries=0 -> hard failure) or errored
+ local function burst(n)
+ local errors = 0
+ for _ = 1, n do
+ local httpc = http.new()
+ local r = httpc:request_uri(uri, { method = "GET", keepalive =
false })
+ if not r or r.status ~= 200 or r.body ~= "1980" then
+ errors = errors + 1
+ end
+ end
+ return errors
+ end
+
+ -- Drive traffic until every worker has built its checker AND
converged its
+ -- per-worker status cache to "1970 unhealthy". The shared shm reports
1970
+ -- unhealthy as soon as ANY one worker probes, so the control-API
status is
+ -- necessary but NOT sufficient -- only routing proves BOTH workers
filter.
+ -- Require several consecutive zero-error bursts (bounded); a genuine
+ -- per-worker filtering miss keeps producing errors and never
converges,
+ -- so the test fails instead of flaking on a fixed sleep.
+ local clean_streak = 0
+ for _ = 1, 25 do
+ if burst(12) == 0 then
+ clean_streak = clean_streak + 1
+ if clean_streak >= 3 then
+ break
+ end
+ else
+ clean_streak = 0
+ end
+ ngx.sleep(1)
+ end
+ ngx.say("converged: ", tostring(clean_streak >= 3))
+
+ -- health status from the shared shm (worker-agnostic) via the control
API
+ local function healthy(status)
+ return status == "healthy" or status == "mostly_healthy"
+ end
+ local _, _, res = t('/v1/healthcheck', ngx.HTTP_GET)
+ local h1970, h1980
+ for _, info in ipairs(json.decode(res)) do
+ for _, node in ipairs(info.nodes or {}) do
+ if node.port == 1970 then h1970 = healthy(node.status) end
+ if node.port == 1980 then h1980 = healthy(node.status) end
+ end
+ end
+ ngx.say("1970_healthy: ", tostring(h1970))
+ ngx.say("1980_healthy: ", tostring(h1980))
+ }
+}
+--- request
+GET /t
+--- response_body
+converged: true
+1970_healthy: false
+1980_healthy: true
+--- no_error_log
+failed to run timer_working_pool_check
+failed to run timer_create_checker
+failed to create healthcheck
+failed to add healthcheck target
+failed to remove healthcheck target
+--- timeout: 40
diff --git a/t/node/healthcheck-service-discovery.t
b/t/node/healthcheck-service-discovery.t
index 7b60141531..2163f524fe 100644
--- a/t/node/healthcheck-service-discovery.t
+++ b/t/node/healthcheck-service-discovery.t
@@ -149,6 +149,5 @@ routes:
}
--- error_log
create new checker
-releasing existing checker
-create new checker
+reused checker with incremental targets
--- timeout: 10
diff --git a/t/node/healthcheck-stop-checker.t
b/t/node/healthcheck-stop-checker.t
index 2fecc8b88c..ff7ab20d2a 100644
--- a/t/node/healthcheck-stop-checker.t
+++ b/t/node/healthcheck-stop-checker.t
@@ -153,11 +153,11 @@ GET /t
2 code: 200
3 code: 200
--- grep_error_log eval
-qr/create new checker: table: 0x|try to release checker: table: 0x/
+qr/create new checker: table: 0x|try to release checker: table: 0x|releasing
existing checker: table: 0x/
--- grep_error_log_out
create new checker: table: 0x
-try to release checker: table: 0x
create new checker: table: 0x
+releasing existing checker: table: 0x
--- timeout: 7
@@ -249,10 +249,10 @@ GET /t
--- response_body
ok
--- grep_error_log eval
-qr/create new checker: table: 0x|try to release checker: table: 0x/
+qr/create new checker: table: 0x|try to release checker: table: 0x|releasing
existing checker: table: 0x/
--- grep_error_log_out
create new checker: table: 0x
-try to release checker: table: 0x
create new checker: table: 0x
+releasing existing checker: table: 0x
try to release checker: table: 0x
--- timeout: 30
diff --git a/t/node/healthchecker-independent-upstream.t
b/t/node/healthchecker-independent-upstream.t
index 957aef2c4e..d6af917073 100644
--- a/t/node/healthchecker-independent-upstream.t
+++ b/t/node/healthchecker-independent-upstream.t
@@ -109,7 +109,3 @@ passed
qr/create new checker: table:/
--- grep_error_log_out
create new checker: table:
-create new checker: table:
-create new checker: table:
-create new checker: table:
-create new checker: table: