This is an automated email from the ASF dual-hosted git repository.

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new f3623982e fix(etcd): watch from the revision the configuration was 
read at (#13917)
f3623982e is described below

commit f3623982ef0e2d6de41306f637caf08ff0ef59ac
Author: Nic <[email protected]>
AuthorDate: Mon Sep 7 12:31:02 2026 +0800

    fix(etcd): watch from the revision the configuration was read at (#13917)
---
 apisix/core/config_etcd.lua        |  35 +++++++----
 t/core/config_etcd.t               |  31 +++-------
 t/core/etcd-watch-start-revision.t | 116 +++++++++++++++++++++++++++++++++++++
 3 files changed, 147 insertions(+), 35 deletions(-)

diff --git a/apisix/core/config_etcd.lua b/apisix/core/config_etcd.lua
index e59dc6b32..c6b4697c8 100644
--- a/apisix/core/config_etcd.lua
+++ b/apisix/core/config_etcd.lua
@@ -70,6 +70,9 @@ if not is_http then
 end
 local created_obj  = {}
 local loaded_configuration = {}
+-- the etcd revision loaded_configuration was read at, kept separately because
+-- its entries are removed as they are consumed
+local loaded_configuration_rev
 local configuration_loaded_time
 local watch_ctx
 
@@ -144,18 +147,14 @@ local function do_run_watch(premature)
             error("failed to create etcd instance: " .. string(err))
         end
 
-        local rev = 0
-        if loaded_configuration then
-            local _, res = next(loaded_configuration)
-            if res then
-                rev = tonumber(res.headers["X-Etcd-Index"])
-                if not rev or rev <= 0 then
-                    log.warn("invalid or missing X-Etcd-Index header, ",
-                             "will fetch revision from etcd directly")
-                    rev = 0
-                end
-            end
-        end
+        -- Watch from the revision the preloaded configuration was read at, so
+        -- that everything written after that snapshot is still delivered. The
+        -- revision cannot be read back from loaded_configuration here:
+        -- core.config.new() removes each entry as it consumes it, so the table
+        -- is empty once every preloaded type has been registered -- and then
+        -- the fallback below would start the watch at the current revision and
+        -- drop every write made since the snapshot.
+        local rev = loaded_configuration_rev or 0
 
         if rev == 0 then
             while true do
@@ -1206,6 +1205,7 @@ end
 
 local function init_loaded_configuration()
     loaded_configuration = {}
+    loaded_configuration_rev = nil
     local etcd_cli, prefix, err = etcd_apisix.new_without_proxy()
     if not etcd_cli then
         return "failed to start a etcd instance: " .. err
@@ -1216,6 +1216,17 @@ local function init_loaded_configuration()
         return err
     end
 
+    -- One readdir backs every entry create_formatter() stored, so there is a
+    -- single revision to record. Nothing is consumed yet at this point, so an
+    -- empty table here means the read stored nothing -- unlike the same test
+    -- made at watch time, which is the bug being fixed. Leave that case alone:
+    -- with no preloaded data every resource type reads for itself at first
+    -- sync, which already picks up whatever was written since, and the
+    -- rev == 0 path below keeps handling it exactly as before.
+    if next(loaded_configuration) then
+        loaded_configuration_rev = tonumber(res.headers["X-Etcd-Index"])
+    end
+
     configuration_loaded_time = ngx_time()
 end
 
diff --git a/t/core/config_etcd.t b/t/core/config_etcd.t
index 02fbee1a7..b25cff005 100644
--- a/t/core/config_etcd.t
+++ b/t/core/config_etcd.t
@@ -605,8 +605,13 @@ etcd watch timeout, upgrade revision to
 
 
 
-=== TEST 15: missing X-Etcd-Index header should not crash init worker
+=== TEST 15: a startup without a preloaded revision must not crash the init 
worker
+The watch takes its start revision from the read that preloads the
+configuration. When there is no such read there is no revision either, and the
+watch has to fall back to asking etcd for the current one rather than failing.
 --- yaml_config
+apisix:
+  disable_sync_configuration_during_start: true
 deployment:
   role: traditional
   role_traditional:
@@ -615,26 +620,6 @@ deployment:
     host:
       - "http://127.0.0.1:2379";
     prefix: /apisix
---- extra_init_by_lua
-    -- Clear loaded_configuration and inject a fake entry with missing
-    -- X-Etcd-Index header so do_run_watch exercises the fallback path.
-    local config_etcd = require("apisix.core.config_etcd")
-    for i = 1, 256 do
-        local name, val = debug.getupvalue(config_etcd.new, i)
-        if not name then
-            break
-        end
-        if name == "loaded_configuration" and type(val) == "table" then
-            for k in pairs(val) do
-                val[k] = nil
-            end
-            val["__test_fake"] = {
-                body = { nodes = {} },
-                headers = {},
-            }
-            break
-        end
-    end
 --- config
     location /t {
         content_by_lua_block {
@@ -647,9 +632,9 @@ GET /t
 --- response_body
 passed
 --- grep_error_log eval
-qr/invalid or missing X-Etcd-Index header/
+qr/main etcd watcher initialised, revision=\d+/
 --- grep_error_log_out eval
-qr/(invalid or missing X-Etcd-Index header\n){1,}/
+qr/(main etcd watcher initialised, revision=\d+\n){1,}/
 
 
 
diff --git a/t/core/etcd-watch-start-revision.t 
b/t/core/etcd-watch-start-revision.t
new file mode 100644
index 000000000..28eaeecd0
--- /dev/null
+++ b/t/core/etcd-watch-start-revision.t
@@ -0,0 +1,116 @@
+#
+# 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);
+no_long_string();
+no_root_location();
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: a write made after the configuration snapshot is still delivered
+The configuration is preloaded once in init_by_lua and handed to each resource
+type as it registers, which removes it from the preload table. This test drives
+that table empty by giving etcd a prefix holding a single directory, so /routes
+is the only type with anything to consume, and then writes a route between the
+snapshot and the first watch. The route can only reach the worker through the
+watch, so it arrives if and only if the watch starts from the revision the
+snapshot was read at.
+--- yaml_config
+apisix:
+    node_listen: 1984
+deployment:
+    role: traditional
+    role_traditional:
+        config_provider: etcd
+    admin:
+        admin_key: null
+    etcd:
+        prefix: "/apisix-watch-start-revision"
+        host:
+            - "http://127.0.0.1:2379";
+--- extra_init_by_lua_start
+    -- io.popen reports nothing about how curl fared, so the etcd response is
+    -- checked instead: a silently failing write here would look exactly like
+    -- the lost event this test is about
+    local function etcd_call(path, body)
+        local f = assert(io.popen("curl -sS --fail-with-body -X POST "
+                                  .. "http://127.0.0.1:2379/v3/kv/"; .. path
+                                  .. " -d '" .. body .. "'"))
+        local out = f:read("*a")
+        f:close()
+        if not out or not out:find('"header"', 1, true) then
+            error("etcd " .. path .. " failed: " .. tostring(out))
+        end
+    end
+
+    local function etcd_put(key, value)
+        etcd_call("put", '{"key":"' .. ngx.encode_base64(key)
+                         .. '","value":"' .. ngx.encode_base64(value) .. '"}')
+    end
+    _G.etcd_put_for_test = etcd_put
+
+    local function etcd_clear()
+        etcd_call("deleterange",
+            '{"key":"' .. ngx.encode_base64("/apisix-watch-start-revision/")
+            .. '","range_end":"' .. 
ngx.encode_base64("/apisix-watch-start-revision0") .. '"}')
+    end
+    _G.etcd_clear_for_test = etcd_clear
+
+    -- start from an empty prefix, so the route written below can only ever
+    -- reach the worker through the watch and never through the snapshot. The
+    -- run clears up after itself too; this is here for a run that did not.
+    etcd_clear()
+
+    -- Do not remove: this key is what puts /routes on the preload path. 
Without
+    -- it the prefix is empty, /routes has nothing to consume and reads for
+    -- itself at first sync, and that read picks the route up whatever revision
+    -- the watch started from -- the test then passes even unpatched. One key
+    -- also means one preload entry, so registering /routes empties the table,
+    -- which is the state being tested.
+    etcd_put("/apisix-watch-start-revision/routes/", "init_dir")
+--- extra_init_by_lua
+    -- after the snapshot was taken, before any worker starts watching
+    _G.etcd_put_for_test("/apisix-watch-start-revision/routes/1",
+        
'{"uri":"/hello","upstream":{"type":"roundrobin","nodes":{"127.0.0.1:1980":1}}}')
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+
+            ngx.sleep(1)
+
+            local routes = core.config.fetch_created_obj("/routes")
+            local route = routes and routes:get("1")
+
+            -- the local etcd is shared, so do not leave this prefix populated
+            _G.etcd_clear_for_test()
+
+            if not route then
+                ngx.say("route written during startup was lost")
+                return
+            end
+
+            ngx.say("uri: ", route.value.uri)
+        }
+    }
+--- request
+GET /t
+--- response_body
+uri: /hello

Reply via email to