Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apisix/healthcheck_manager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,137 @@ function _M.fetch_node_status(checker, ip, port, hostname)
end


-- Cold-start readiness gate support (PS-12691): checker creation is
-- otherwise entirely lazy -- waiting_pool is only ever seeded by
-- fetch_checker(), which is only called from the live request path. An
-- upstream that receives zero traffic before a readiness probe fires would
-- never get a checker built at all, so a "has this been probed" gate would
-- hang forever for an idle-but-critical upstream on a fresh pod. This lets a
-- caller (the readiness plugin) force that seeding proactively.
--
-- Scoped to plain, non-plugin-constructed resource paths (e.g.
-- "/upstreams/<id>", "/routes/<id>") -- unlike timer_create_checker, this
-- does not resolve the get_plugin_name()/construct_upstream branch used by
-- plugins like ai-proxy-multi. Critical upstreams for readiness gating are
-- configured as standalone apisixUpstreams entries, not inline plugin
-- config, so this scope is sufficient.
function _M.ensure_checker(resource_path)
if working_pool[resource_path] then
-- already created, by traffic or a previous ensure_checker call
return true
end

local res_conf = resource.fetch_latest_conf(resource_path)
if not res_conf then
core.log.error("ensure_checker: resource not found: ", resource_path)
return false, "resource not found"
end

local upstream = res_conf.value.upstream or res_conf.value
if not upstream.checks then
core.log.warn("ensure_checker: resource has no checks configured: ", resource_path)
return false, "no checks configured"
end

-- Domain-name nodes are otherwise only resolved by apisix.upstream's
-- get_by_id -> parse_domain_in_up, which runs exclusively on the live
-- request path (apisix/init.lua). Without this, a checker seeded here
-- would start probing under the unresolved domain-string identity, then
-- get rebuilt -- wiping its accumulated shm state, including the probe
-- count is_resource_probed relies on -- the moment real traffic first
-- resolves the domain and bumps _nodes_ver. res_conf is the same shared
-- config object get_by_id operates on (both come from
-- core.config.fetch_created_obj), so has_domain/dns_nodes are already
-- populated by the config watcher's filter callback regardless of
-- traffic; resolve here so the checker is built once, under its final
-- identity, from the start.
if res_conf.has_domain then
local resolved, err = upstream_utils.parse_domain_in_up(res_conf)
if not resolved then
core.log.error("ensure_checker: failed to resolve domain nodes for ",
resource_path, ": ", err)
-- fall through with the unresolved config -- a subsequent real
-- request or ensure_checker call will retry the resolution
else
res_conf = resolved
upstream = res_conf.value.upstream or res_conf.value
end
end

if not upstream.nodes or #upstream.nodes == 0 then
return false, "no nodes"
end

local new_version = upstream_utils.version(res_conf.modifiedIndex, upstream._nodes_ver)
if waiting_pool[resource_path] ~= new_version then
core.log.info("ensure_checker: seeding waiting pool for ", resource_path,
" with version: ", new_version)
waiting_pool[resource_path] = new_version
end
return true
end


-- A single active-check attempt does not guarantee a target's real state is
-- known: with e.g. unhealthy.http_failures = 2 configured, internal_health
-- only converges after two consecutive failed attempts. Returns the
-- worst-case number of consecutive attempts needed to guarantee the real
-- state has been reached in either direction (healthy or unhealthy), or nil
-- if the resource has no active check configured at all -- in which case
-- there is nothing for a readiness gate to wait on.
local function required_probe_attempts(checks)
local active = checks and checks.active
if not active then
return nil
end
local unhealthy = active.unhealthy or {}
local healthy = active.healthy or {}
local max_attempts = 1
for _, threshold in ipairs({
unhealthy.http_failures,
unhealthy.tcp_failures,
unhealthy.timeouts,
healthy.successes,
}) do
if threshold and threshold > max_attempts then
max_attempts = threshold
end
end
return max_attempts
end


-- Cold-start readiness gate support (PS-12691): true only once every target
-- of the resource's checker has had enough real active-check attempts for
-- its state to have actually converged (see resty.healthcheck's
-- all_targets_probed -- "probed" means attempted, not "healthy", and one
-- attempt is not always enough, see required_probe_attempts above). false
-- if there is no live checker yet (ensure_checker not called, or
-- timer_create_checker hasn't run its next tick yet).
function _M.is_resource_probed(resource_path)
local item = working_pool[resource_path]
if not item or not item.checker or item.checker.dead then
return false
end

local min_attempts = required_probe_attempts(item.checks)
if min_attempts == nil then
-- no active check configured: nothing for this gate to wait on.
return true
end

if not healthcheck then
healthcheck = require("resty.healthcheck")
end
local ok, err = healthcheck.all_targets_probed(item.checker.name, healthcheck_shdict_name, min_attempts)
if ok == nil then
core.log.error("is_resource_probed: ", err)
return false
end
return ok
end


local function add_working_pool(resource_path, resource_ver, checker, checks)
working_pool[resource_path] = {
version = resource_ver,
Expand Down
Loading
Loading