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

Reply via email to