This is an automated email from the ASF dual-hosted git repository.
bzp2010 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 15a438183 feat: improve API-driven standalone update reliability
(#13904)
15a438183 is described below
commit 15a438183bfbba4581ade6f7d263e3636ce38578
Author: Zeping Bai <[email protected]>
AuthorDate: Wed Sep 2 17:41:28 2026 +0800
feat: improve API-driven standalone update reliability (#13904)
---
apisix/admin/config_validate.lua | 16 +++
apisix/admin/standalone.lua | 275 +++++++++++++++++++++++++++++++++----
apisix/cli/config.lua | 1 +
apisix/cli/ngx_tpl.lua | 1 +
apisix/core/config_local.lua | 28 +++-
apisix/core/config_yaml.lua | 95 ++++++-------
apisix/init.lua | 15 +-
docs/en/latest/deployment-modes.md | 22 ++-
t/APISIX.pm | 1 +
t/admin/standalone-wait.t | 221 +++++++++++++++++++++++++++++
10 files changed, 594 insertions(+), 81 deletions(-)
diff --git a/apisix/admin/config_validate.lua b/apisix/admin/config_validate.lua
index b4d4b090f..da9aad2f7 100644
--- a/apisix/admin/config_validate.lua
+++ b/apisix/admin/config_validate.lua
@@ -58,13 +58,19 @@ local resources = {
local CONF_VERSION_KEY_SUFFIX = "_conf_version"
local ALL_RESOURCE_KEYS = {}
+local HTTP_RESOURCE_KEYS = {}
+local STREAM_RESOURCE_KEYS = {}
for dir in pairs(constants.HTTP_ETCD_DIRECTORY) do
local key = str_sub(dir, 2)
ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
+ if key ~= "stream_routes" then
+ HTTP_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
+ end
end
for dir in pairs(constants.STREAM_ETCD_DIRECTORY) do
local key = str_sub(dir, 2)
ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
+ STREAM_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
end
@@ -255,6 +261,16 @@ function _M.get_all_resource_keys()
end
+function _M.get_http_resource_keys()
+ return HTTP_RESOURCE_KEYS
+end
+
+
+function _M.get_stream_resource_keys()
+ return STREAM_RESOURCE_KEYS
+end
+
+
function _M.get_resources()
return resources
end
diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua
index 7e69b5902..8ab7280c4 100644
--- a/apisix/admin/standalone.lua
+++ b/apisix/admin/standalone.lua
@@ -16,19 +16,32 @@
local type = type
local pairs = pairs
local ipairs = ipairs
+local tostring = tostring
+local tonumber = tonumber
local str_lower = string.lower
+local str_find = string.find
+local str_sub = string.sub
+local str_gmatch = string.gmatch
+local table_concat = table.concat
local ngx = ngx
local ngx_time = ngx.time
+local ngx_now = ngx.now
+local ngx_sleep = ngx.sleep
local get_method = ngx.req.get_method
-local shared_dict = ngx.shared["standalone-config"]
+local worker_count = ngx.worker.count
local timer_every = ngx.timer.every
local exiting = ngx.worker.exiting
+local subsystem = ngx.config.subsystem
local yaml = require("lyaml")
local events = require("apisix.events")
local core = require("apisix.core")
+local config_local = require("apisix.core.config_local")
local config_yaml = require("apisix.core.config_yaml")
local config_validate = require("apisix.admin.config_validate")
+local shared_dict = ngx.shared["standalone-config"]
+local status_shared_dict = ngx.shared["standalone-status"]
+
local ALL_RESOURCE_KEYS = config_validate.get_all_resource_keys()
local EVENT_UPDATE = "standalone-api-configuration-update"
@@ -40,14 +53,52 @@ local METADATA_DIGEST = "X-Digest"
local _M = {}
+
+-- the "config" key in the standalone-config shared dict stores
+-- "<digest length>\n<digest><json body>" instead of plain JSON, so a worker
+-- can compare digests without decoding the (potentially large) JSON on
+-- every poll
+-- the reason for combining them into a single string and writing them to
+-- only one key is to ensure concurrent safety for updates by multiple
+-- workers
+local CONFIG_DIGEST_LENGTH_SEPARATOR = "\n"
+
+local function encode_config(digest, raw)
+ digest = digest or ""
+ return #digest .. CONFIG_DIGEST_LENGTH_SEPARATOR .. digest .. raw
+end
+
+
+local function decode_config(stored)
+ local idx = str_find(stored, CONFIG_DIGEST_LENGTH_SEPARATOR, 1, true)
+ if not idx then
+ return nil, nil, "missing digest length prefix"
+ end
+
+ local digest_len = tonumber(str_sub(stored, 1, idx - 1))
+ if not digest_len then
+ return nil, nil, "invalid digest length prefix"
+ end
+
+ local digest_start = idx + 1
+ local digest_end = digest_start + digest_len - 1
+ return str_sub(stored, digest_start, digest_end), str_sub(stored,
digest_end + 1)
+end
+
+
local function get_config()
- local config = shared_dict:get("config")
- if not config then
+ local stored = shared_dict:get("config")
+ if not stored then
return nil, NOT_FOUND_ERR
end
- local err
- config, err = core.json.decode(config)
+ local _, raw, err = decode_config(stored)
+ if not raw then
+ return nil, "failed to decode stored config: " .. err
+ end
+
+ local config
+ config, err = core.json.decode(raw)
if not config then
return nil, "failed to decode json: " .. err
end
@@ -55,7 +106,7 @@ local function get_config()
end
-local function update_and_broadcast_config(apisix_yaml)
+local function update_config(apisix_yaml)
local raw, err = core.json.encode(apisix_yaml)
if not raw then
core.log.error("failed to encode json: ", err)
@@ -64,7 +115,8 @@ local function update_and_broadcast_config(apisix_yaml)
if shared_dict then
-- the worker that handles Admin API calls is responsible for writing
the shared dict
- local ok, err = shared_dict:set("config", raw)
+ local stored = encode_config(apisix_yaml[METADATA_DIGEST], raw)
+ local ok, err = shared_dict:set("config", stored)
if not ok then
return nil, "failed to save config to shared dict: " .. err
end
@@ -72,11 +124,132 @@ local function update_and_broadcast_config(apisix_yaml)
else
core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
end
- return events:post(EVENT_UPDATE, EVENT_UPDATE)
+ return true
end
local validate_configuration = config_validate.validate_configuration
+
+local MAX_WAIT_MS = 60000
+local POLL_INTERVAL = 0.05
+
+
+local function parse_wait_ms(ctx)
+ local args = core.request.get_uri_args(ctx)
+ local wait = args and tonumber(args.wait)
+ if not wait or wait <= 0 then
+ return 0
+ end
+ if wait > MAX_WAIT_MS then
+ return MAX_WAIT_MS
+ end
+ return wait
+end
+
+
+-- Some resource types (e.g. /protos) only get a config.new() instance
+-- when their owning plugin is enabled, so all_workers_applied can't wait
+-- on a key that will never be reported. Records which keys this subsystem
+-- actually has, as one comma-joined shdict value; rewritten every poll
+-- tick so it self-heals after LRU eviction or a plugin toggled by reload.
+local function mark_tracked_resources()
+ if not status_shared_dict then
+ return
+ end
+ local tracked = {}
+ for key in pairs(ALL_RESOURCE_KEYS) do
+ if config_yaml.fetch_created_obj("/" .. key) then
+ tracked[#tracked + 1] = key
+ end
+ end
+ local ok, err = status_shared_dict:set("tracked:" .. subsystem,
table_concat(tracked, ","))
+ if not ok then
+ core.log.error("failed to mark tracked resources: ", err)
+ end
+end
+
+
+local function tracked_resource_set(subsystem_name)
+ local raw = status_shared_dict:get("tracked:" .. subsystem_name)
+ local set = {}
+ if raw then
+ for key in str_gmatch(raw, "[^,]+") do
+ set[key] = true
+ end
+ end
+ return set
+end
+
+
+local function all_workers_applied(target_digest)
+ if not status_shared_dict then
+ return false
+ end
+
+ local n = worker_count()
+ local check_stream = config_local.is_stream_enabled()
+ local http_tracked = tracked_resource_set("http")
+ local stream_tracked = check_stream and tracked_resource_set("stream")
+ for key in pairs(ALL_RESOURCE_KEYS) do
+ for id = 0, n - 1 do
+ if http_tracked[key] then
+ local http_key = "worker:" .. id .. ":http:" .. key
+ local digest = status_shared_dict:get(http_key)
+ if digest ~= target_digest then
+ core.log.debug("not yet applied: ", http_key, " has ",
digest,
+ ", want ", target_digest)
+ return false
+ end
+ end
+ if check_stream and stream_tracked and stream_tracked[key] then
+ local stream_key = "worker:" .. id .. ":stream:" .. key
+ local digest = status_shared_dict:get(stream_key)
+ if digest ~= target_digest then
+ core.log.debug("not yet applied: ", stream_key, " has ",
digest,
+ ", want ", target_digest)
+ return false
+ end
+ end
+ end
+ end
+ return true
+end
+
+
+-- reads standalone-config shdict once and applies it if present
+local function try_restore_from_shared_dict()
+ if not shared_dict then
+ core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
+ return nil, config_yaml.ERR_NO_SHARED_DICT
+ end
+
+ local stored, err = shared_dict:get("config")
+ if not stored then
+ if err then -- if the key does not exist, the return values are both
nil
+ core.log.error("failed to read config from shared dict: ", err)
+ end
+ core.log.info("no config found in shared dict")
+ return true
+ end
+ core.log.info("startup config loaded from shared dict: ", stored)
+
+ local _, raw, err = decode_config(tostring(stored))
+ if not raw then
+ return nil, "failed to decode config from shared dict: " .. err
+ end
+
+ local config
+ config, err = core.json.decode(raw)
+ if not config then
+ return nil, "failed to decode config from shared dict: " .. err
+ end
+ config_yaml._update_config(config)
+ core.log.info("config loaded from shared dict")
+
+ return true
+end
+
+
local function update(ctx)
-- check digest header existence
local digest = core.request.header(nil, METADATA_DIGEST)
@@ -164,13 +337,34 @@ local function update(ctx)
apisix_yaml[METADATA_LAST_MODIFIED] = ngx_time()
apisix_yaml[METADATA_DIGEST] = digest
- local ok, err = update_and_broadcast_config(apisix_yaml)
+ local ok, err = update_config(apisix_yaml)
if not ok then
- core.response.exit(500, err)
+ return core.response.exit(500, err)
+ end
+ local ok, err = events:post(EVENT_UPDATE, EVENT_UPDATE)
+ if not ok then
+ -- event broadcasting errors are tolerable because each worker has
+ -- an additional timer as a backup. This may introduce some extra
+ -- latency, but it won't prevent the configuration from being updated
+ core.log.error("failed to post update event: ", err)
end
core.response.set_header(METADATA_LAST_MODIFIED,
apisix_yaml[METADATA_LAST_MODIFIED])
core.response.set_header(METADATA_DIGEST, apisix_yaml[METADATA_DIGEST])
+
+ local wait_ms = parse_wait_ms(ctx)
+ if wait_ms <= 0 then
+ return core.response.exit(202)
+ end
+
+ local deadline = ngx_now() + wait_ms / 1000
+ while not exiting() and ngx_now() < deadline do
+ if all_workers_applied(digest) then
+ return core.response.exit(200)
+ end
+ ngx_sleep(POLL_INTERVAL)
+ end
+
return core.response.exit(202)
end
@@ -313,6 +507,16 @@ end
function _M.init_worker()
+ mark_tracked_resources()
+
+ local ok, err = try_restore_from_shared_dict()
+ if not ok then
+ core.log.error("failed to restore config from shared dict: ", err)
+
+ -- this occurs only when invalid data is stored in shdict
+ -- if the restore fails, wait for the next timer to retry
+ end
+
local function update_config(config)
if not config then
local err
@@ -323,10 +527,6 @@ function _M.init_worker()
end
end
- -- remove metadata key in-place
- -- this table is generated by json decode, so there is no need to
clone it
- config[METADATA_LAST_MODIFIED] = nil
- config[METADATA_DIGEST] = nil
config_yaml._update_config(config)
end
events:register(update_config, EVENT_UPDATE, EVENT_UPDATE)
@@ -337,22 +537,43 @@ function _M.init_worker()
-- same second are indistinguishable by it and the later one would never
-- reach this worker. The digest changes with the content, so compare both.
local last_modified_per_worker, digest_per_worker
- timer_every(1, function ()
+ timer_every(0.2, function ()
if not exiting() then
- local config, err = get_config()
- if not config then
- if err ~= NOT_FOUND_ERR then
+ mark_tracked_resources()
+
+ if not shared_dict then
+ return
+ end
+
+ local stored, err = shared_dict:get("config")
+ if not stored then
+ if err then -- if the key does not exist, the return values
are both nil
core.log.error("failed to get config: ", err)
end
- else
- local last_modified = config[METADATA_LAST_MODIFIED]
- local digest = config[METADATA_DIGEST]
- if last_modified_per_worker ~= last_modified
- or digest_per_worker ~= digest then
- update_config(config)
- last_modified_per_worker = last_modified
- digest_per_worker = digest
- end
+ return
+ end
+
+ -- the digest prefix is cheap to check (no full-config decode), so
+ -- most ticks can bail out here without ever calling json.decode
+ local latest_digest, raw = decode_config(stored)
+ if latest_digest == digest_per_worker then
+ return
+ end
+
+ local config
+ config, err = core.json.decode(raw)
+ if not config then
+ core.log.error("failed to decode config: ", err)
+ return
+ end
+
+ local last_modified = config[METADATA_LAST_MODIFIED]
+ local digest = config[METADATA_DIGEST]
+ if last_modified_per_worker ~= last_modified
+ or digest_per_worker ~= digest then
+ update_config(config)
+ last_modified_per_worker = last_modified
+ digest_per_worker = digest
end
end
end)
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 4315794f2..f4c002835 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -106,6 +106,7 @@ local _M = {
["prometheus-metrics"] = "128m",
["prometheus-cache"] = "10m",
["standalone-config"] = "10m",
+ ["standalone-status"] = "1m",
["status-report"] = "1m",
["upstream-healthcheck"] = "10m",
}
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 218ffefa4..2b4a75231 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -73,6 +73,7 @@ lua {
{% end %}
{% if standalone_with_admin_api then %}
lua_shared_dict standalone-config {*
meta.lua_shared_dict["standalone-config"] *};
+ lua_shared_dict standalone-status {*
meta.lua_shared_dict["standalone-status"] *};
{% end %}
{% if status then %}
lua_shared_dict status-report {* meta.lua_shared_dict["status-report"] *};
diff --git a/apisix/core/config_local.lua b/apisix/core/config_local.lua
index 2b8f92ff8..1de68cc7d 100644
--- a/apisix/core/config_local.lua
+++ b/apisix/core/config_local.lua
@@ -19,7 +19,8 @@
--
-- @module core.config_local
-local file = require("apisix.cli.file")
+local str_find = string.find
+local file = require("apisix.cli.file")
local _M = {}
@@ -68,4 +69,29 @@ function _M.local_conf(force)
end
+
+---
+-- Check whether the stream subsystem is enabled.
+-- Reads `apisix.proxy_mode` from the local config and looks for the
+-- substring "stream" in it, so it covers both `proxy_mode: stream` and
+-- `proxy_mode: http&stream`. `proxy_mode` doesn't change once a worker is
+-- running, so the result is computed once and cached.
+--
+-- @function require("apisix.core.config_local").is_stream_enabled
+-- @treturn boolean Whether the stream subsystem is enabled.
+-- @usage
+-- local config_local = require("apisix.core.config_local")
+-- local stream_enabled = config_local.is_stream_enabled()
+local _is_stream_enabled
+function _M.is_stream_enabled()
+ if _is_stream_enabled ~= nil then
+ return _is_stream_enabled
+ end
+ local local_conf = _M.local_conf()
+ local proxy_mode = local_conf and local_conf.apisix and
local_conf.apisix.proxy_mode
+ _is_stream_enabled = proxy_mode ~= nil and str_find(proxy_mode, "stream",
1, true) ~= nil
+ return _is_stream_enabled
+end
+
+
return _M
diff --git a/apisix/core/config_yaml.lua b/apisix/core/config_yaml.lua
index c61f1f362..12b515188 100644
--- a/apisix/core/config_yaml.lua
+++ b/apisix/core/config_yaml.lua
@@ -36,7 +36,6 @@ local setmetatable = setmetatable
local ngx_sleep = require("apisix.core.utils").sleep
local ngx_timer_at = ngx.timer.at
local ngx_time = ngx.time
-local ngx_shared = ngx.shared
local sub_str = string.sub
local tostring = tostring
local pcall = pcall
@@ -45,9 +44,13 @@ local ngx = ngx
local re_find = ngx.re.find
local process = require("ngx.process")
local worker_id = ngx.worker.id
+local subsystem = ngx.config.subsystem
local created_obj = {}
-local shared_dict
-local status_report_shared_dict_name = "status-report"
+
+local status_report_shared_dict_name = "status-report"
+local standalone_status_shared_dict_name = "standalone-status"
+
+local METADATA_DIGEST = "X-Digest"
local _M = {
version = 0.2,
@@ -177,6 +180,41 @@ local function is_use_admin_api()
end
+-- Reports the current entity's updating status on the current worker to
+-- shdict standalone-status.
+-- This status is reported to the client via the standalone API and
+-- indicates whether APISIX accepted or applied a configuration reload
+-- within the specified timeout period.
+-- Only applies to API-driven standalone
+local function mark_type_applied(self)
+ if not is_use_admin_api() then
+ return
+ end
+ local digest = apisix_yaml[METADATA_DIGEST]
+ if not digest then
+ return
+ end
+ local dict = ngx.shared[standalone_status_shared_dict_name]
+ if not dict then
+ return
+ end
+ local id = worker_id()
+ if not id then
+ -- the privileged agent processes do not have a worker ID
+ -- although they also watch changes to these resources,
+ -- they do not process traffic, so clients are unaware
+ -- of them, just skip their status report
+ return
+ end
+ local key = "worker:" .. id .. ":" .. subsystem .. ":" .. self.key
+ log.debug("mark type applied: ", key, " = ", digest)
+ local ok, err = dict:set(key, digest)
+ if not ok then
+ log.error("failed to record applied digest for [", key, "]: ", err)
+ end
+end
+
+
local function read_apisix_config(premature, pre_mtime)
if premature then
return
@@ -221,6 +259,7 @@ local function sync_data(self)
end
if not conf_version or conf_version == self.conf_version then
+ mark_type_applied(self)
return true
end
@@ -229,6 +268,7 @@ local function sync_data(self)
self.values = new_tab(8, 0)
self.values_hash = new_tab(0, 8)
self.conf_version = conf_version
+ mark_type_applied(self)
return true
end
@@ -358,6 +398,7 @@ local function sync_data(self)
end
self.conf_version = conf_version
+ mark_type_applied(self)
return true
end
@@ -381,48 +422,6 @@ local function _automatic_fetch(premature, self)
return
end
- -- the _automatic_fetch is only called in the timer, and according to the
- -- documentation, ngx.shared.DICT.get can be executed there.
- -- if the file's global variables have not yet been assigned values,
- -- we can assume that the worker has not been initialized yet and try to
- -- read any old data that may be present from the shared dict
- -- try load from shared dict only on first startup, otherwise use event
mechanism
- if is_use_admin_api() and not shared_dict then
- log.info("try to load config from shared dict")
-
- local config, err
- shared_dict = ngx_shared["standalone-config"] -- init shared dict in
current worker
- if not shared_dict then
- log.error("failed to read config from shared dict: shared dict not
found")
- goto SKIP_SHARED_DICT
- end
- config, err = shared_dict:get("config")
- if not config then
- if err then -- if the key does not exist, the return values are
both nil
- log.error("failed to read config from shared dict: ", err)
- end
- log.info("no config found in shared dict")
- goto SKIP_SHARED_DICT
- end
- log.info("startup config loaded from shared dict: ", config)
-
- config, err = json.decode(tostring(config))
- if not config then
- log.error("failed to decode config from shared dict: ", err)
- goto SKIP_SHARED_DICT
- end
- _M._update_config(config)
- log.info("config loaded from shared dict")
-
- ::SKIP_SHARED_DICT::
- if not shared_dict then
- log.crit(_M.ERR_NO_SHARED_DICT)
-
- -- fill that value to make the worker not try to read from shared
dict again
- shared_dict = "error"
- end
- end
-
local i = 0
while not exiting() and self.running and i <= 32 do
i = i + 1
@@ -554,8 +553,10 @@ end
function _M.init_worker()
sync_status_to_shdict(false)
if is_use_admin_api() then
- apisix_yaml = {}
- apisix_yaml_mtime = 0
+ if not apisix_yaml then
+ apisix_yaml = {}
+ end
+
return true
end
diff --git a/apisix/init.lua b/apisix/init.lua
index 548c83b52..7e6675850 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -1350,6 +1350,16 @@ function _M.stream_init_worker()
core.lrucache.init_worker()
+ -- admin.init.init_worker() registers an events callback, so events must
+ -- already be initialized here
+ require("apisix.events").init_worker()
+
+ -- must run before core.config.init_worker() and
router.stream_init_worker():
+ -- it patches the resource schemas (e.g. allowing modifiedIndex) that
+ -- those two synchronously validate data against as part of their own
+ -- worker startup, same as in http_init_worker
+ require("apisix.admin.init").init_worker()
+
if core.config.init_worker then
local ok, err = core.config.init_worker()
if not ok then
@@ -1365,11 +1375,6 @@ function _M.stream_init_worker()
require("apisix.http.service").init_worker()
apisix_upstream.init_worker()
- require("apisix.events").init_worker()
-
- -- for admin api of standalone mode, we need to startup background timer
and patch schema etc.
- require("apisix.admin.init").init_worker()
-
if discovery and discovery.init_worker then
discovery.init_worker()
end
diff --git a/docs/en/latest/deployment-modes.md
b/docs/en/latest/deployment-modes.md
index 2d747dcf6..a9002cc2d 100644
--- a/docs/en/latest/deployment-modes.md
+++ b/docs/en/latest/deployment-modes.md
@@ -202,6 +202,14 @@ This disables the local file source of configuration in
favor of the API. When A
Allow setting an index for each resource. APISIX compares this index to
its modifiedIndex to determine whether to accept the update.
+* `?wait` query parameter on `PUT /apisix/admin/configs`
+
+ Controls whether the request waits for the configuration to be applied
before responding.
+
+ - Without `?wait` (or with `?wait=0`), APISIX returns `202 Accepted` as
soon as the configuration is accepted, without waiting to confirm it has been
applied.
+ - With `?wait=<milliseconds>`, APISIX waits up to that many milliseconds
and returns `200 OK` once every worker, including the stream subsystem if
enabled, has confirmed applying this configuration. If the deadline is reached
first, it returns `202 Accepted` instead, meaning the update was accepted but
not yet confirmed as fully applied. The wait time is capped at 60000 (60
seconds).
+ - This has no effect when the request is skipped with `204` because its
`X-Digest` matches the currently loaded configuration.
+
##### Example
1. get configuration
@@ -250,7 +258,19 @@ The client can determine its content. The value is
transparent to APISIX and wil
:::
-3. update based on resource type
+3. wait for the update to be applied
+
+```shell
+curl -X PUT "http://127.0.0.1:9180/apisix/admin/configs?wait=3000" \
+ -H "X-API-KEY: <apikey>" \
+ -H "Content-Type: application/json" \
+ -H "X-Digest: example_string#3" \
+ -d '{}'
+```
+
+With `?wait=3000`, this call blocks for up to 3 seconds and returns `200 OK`
as soon as every worker has confirmed applying this configuration. If the
deadline is reached first, it falls back to `202 Accepted`, same as a request
without `?wait`.
+
+4. update based on resource type
In APISIX memory, the current configuration is:
diff --git a/t/APISIX.pm b/t/APISIX.pm
index c1d9a9058..91b6ef133 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -328,6 +328,7 @@ lua {
lua_shared_dict prometheus-metrics 15m;
lua_shared_dict prometheus-cache 10m;
lua_shared_dict standalone-config 10m;
+ lua_shared_dict standalone-status 1m;
lua_shared_dict status-report 1m;
lua_shared_dict nacos 10m;
lua_shared_dict consul 10m;
diff --git a/t/admin/standalone-wait.t b/t/admin/standalone-wait.t
new file mode 100644
index 000000000..60d11894d
--- /dev/null
+++ b/t/admin/standalone-wait.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.
+#
+BEGIN {
+ # restarts cause the memory cache to be emptied, don't do this
+ $ENV{TEST_NGINX_FORCE_RESTART_ON_TEST} = 0;
+}
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+use_hup();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!defined $block->yaml_config) {
+ $block->set_value("yaml_config", <<'EOF');
+apisix:
+ proxy_mode: http&stream
+deployment:
+ role: traditional
+ role_traditional:
+ config_provider: yaml
+ admin:
+ admin_key:
+ - name: admin
+ key: edd1c9f034335f136f87ad84b625c8f1
+ role: admin
+EOF
+ }
+
+ $block->set_value("stream_enable", 1);
+
+ if (!defined $block->config) {
+ # shared by every block that doesn't set its own --- config, so that
+ # only TEST 8 (which deliberately uses a different --- config)
+ # triggers a reload -- keeping it constant here avoids surprising
+ # every other block with an unwanted worker replacement mid-test
+ $block->set_value("config", <<'EOF');
+ location /stream_request {
+ content_by_lua_block {
+ local sock, err = ngx.socket.tcp()
+ assert(sock, err)
+
+ local ok, err = sock:connect("127.0.0.1", 1985)
+ if not ok then
+ ngx.say("connect to stream server error: ", err)
+ return
+ end
+ local bytes, err = sock:send("mmm")
+ if not bytes then
+ ngx.say("send stream request error: ", err)
+ return
+ end
+
+ local data, err = sock:receive("*a")
+ sock:close()
+ if not data then
+ ngx.say("receive stream response error: ", err)
+ return
+ end
+ ngx.print(data)
+ }
+ }
+EOF
+ }
+
+ if (!defined $block->no_error_log) {
+ # a privileged agent process runs the same init_worker machinery as a
+ # regular worker but has no worker ordinal (ngx.worker.id() is nil),
+ # so make sure nothing here ever tries to concatenate that into a key.
+ $block->set_value("no_error_log", "attempt to concatenate a nil
value");
+ }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: no ?wait at all still returns 202 immediately (pre-existing
behavior, unaffected)
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w1
+--- error_code: 202
+
+
+
+=== TEST 2: ?wait=0 is the same as no ?wait (still an immediate 202)
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs?wait=0
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}],"routes_conf_version":2}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w2
+--- error_code: 202
+
+
+
+=== TEST 3: a real ?wait actually waits, returning 200 once every worker (http
and stream) has applied it
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs?wait=3000
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}],"routes_conf_version":3}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w3
+--- error_code: 200
+
+
+
+=== TEST 4: the route is already live (no sleep needed, the 200 from TEST 3
already confirmed it)
+--- log_level: debug
+--- request
+GET /r1
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 5: resubmitting the same digest is still 204, doesn't go through the
wait loop at all
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs?wait=3000
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}],"routes_conf_version":3}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w3
+--- error_code: 204
+
+
+
+=== TEST 6: ?wait covers the stream subsystem too (no more guessing a sleep,
the 200 means the TCP route is already live)
+--- pipelined_requests eval
+[
+ "PUT /apisix/admin/configs?wait=3000\n" .
"{\"stream_routes\":[{\"modifiedIndex\":1,\"server_addr\":\"127.0.0.1\",\"server_port\":1985,\"id\":1,\"upstream\":{\"nodes\":{\"127.0.0.1:1995\":1},\"type\":\"roundrobin\"}}]}",
+ "GET /stream_request"
+]
+--- more_headers eval
+[
+ "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1\n" . "X-Digest: w6",
+ "",
+]
+--- error_code eval
+[200, 200]
+--- response_body eval
+["", "hello world\n"]
+
+
+
+=== TEST 7: setup route for TEST 8, independent of whatever earlier tests left
in "routes"
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs?wait=3000
+{"routes":[{"id":"r7","uri":"/r7","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w7
+--- error_code: 200
+
+
+
+=== TEST 8: config restored synchronously in init_worker survives a reload (no
empty-config window)
+--- config
+ location /t8 {} # a different location forces this test's worker(s) to
reload
+--- log_level: debug
+--- request
+GET /r7
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 9: ?wait doesn't hang on a resource type whose owning plugin is
disabled (e.g. protos / grpc-transcode never get a config.new() instance)
+--- yaml_config
+plugins: # only one unrelated plugin, no grpc-transcode enabled
+ - real-ip
+deployment:
+ role: traditional
+ role_traditional:
+ config_provider: yaml
+ admin:
+ admin_key:
+ - name: admin
+ key: edd1c9f034335f136f87ad84b625c8f1
+ role: admin
+--- config
+ location /t9 {} # a different location, together with the yaml_config
+ # above, forces a reload so the new plugin list
+ # actually takes effect (yaml_config alone is a no-op
+ # unless --- config also changes)
+--- log_level: debug
+--- request
+PUT /apisix/admin/configs?wait=3000
+{"routes":[{"id":"r9","uri":"/r9","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: w9
+--- error_code: 200