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 b8738ef13 fix(config_etcd): keep the previous value when a full reload 
gets invalid data (#13717)
b8738ef13 is described below

commit b8738ef13e4b074dfa993aaeb154096c40e9ebca
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 29 11:11:43 2026 +0800

    fix(config_etcd): keep the previous value when a full reload gets invalid 
data (#13717)
---
 apisix/core/config_etcd.lua |  72 +++++++++++--
 apisix/plugin.lua           |  21 +++-
 t/core/config_etcd.t        | 248 ++++++++++++++++++++++++++++++++++++++++++++
 t/plugin/proxy-cache/disk.t |  81 +++++++++++++++
 4 files changed, 408 insertions(+), 14 deletions(-)

diff --git a/apisix/core/config_etcd.lua b/apisix/core/config_etcd.lua
index a56a221b6..79a66bf88 100644
--- a/apisix/core/config_etcd.lua
+++ b/apisix/core/config_etcd.lua
@@ -546,9 +546,24 @@ local function sync_status_to_shdict(status)
 end
 
 
-local function load_full_data(self, dir_res, headers)
+local function get_prev_item(prev_values, prev_values_hash, key)
+    if not prev_values or not prev_values_hash then
+        return nil
+    end
+
+    -- deleted items are tombstoned as `false` in the values array and removed
+    -- from the hash by the watch path, so a hash hit is always a live item
+    local idx = prev_values_hash[key]
+    return idx and prev_values[idx] or nil
+end
+
+
+local function load_full_data(self, dir_res, headers, prev_values, 
prev_values_hash)
     local err
     local changed = false
+    -- previous items carried over because their new data failed the check;
+    -- their clean handlers must NOT be fired
+    local carried = {}
 
     if self.single_item then
         self.values = new_tab(1, 0)
@@ -583,6 +598,19 @@ local function load_full_data(self, dir_res, headers)
             if self.filter then
                 self.filter(item)
             end
+
+        elseif item.value ~= nil then
+            -- new data exists but is invalid: keep the previous value like the
+            -- incremental watch path does. An absent value (deleted key) must
+            -- not be resurrected, hence the `item.value ~= nil` guard.
+            local prev_item = get_prev_item(prev_values, prev_values_hash, 
self.key)
+            if prev_item then
+                log.warn("failed to check item data of [", self.key,
+                         "], keep the previous configuration, err: ", err)
+                insert_tab(self.values, prev_item)
+                self.values_hash[self.key] = #self.values
+                carried[prev_item] = true
+            end
         end
 
         self:upgrade_version(item.modifiedIndex)
@@ -601,8 +629,10 @@ local function load_full_data(self, dir_res, headers)
         for _, item in ipairs(values) do
             local key = short_key(self, item.key)
             local data_valid = true
+            err = nil
             if type(item.value) ~= "table" then
                 data_valid = false
+                err = "invalid item data, it should be an object"
                 log.error("invalid item data of [", self.key .. "/" .. key,
                           "], val: ", item.value,
                           ", it should be an object")
@@ -637,12 +667,35 @@ local function load_full_data(self, dir_res, headers)
                 if self.filter then
                     self.filter(item)
                 end
+
+            else
+                local prev_item = get_prev_item(prev_values, prev_values_hash, 
key)
+                if prev_item then
+                    -- keep serving with the last valid configuration instead 
of
+                    -- silently dropping the whole item on a full reload, see 
the
+                    -- incremental path in sync_data for the same semantics
+                    log.warn("failed to check item data of [", self.key, "/", 
key,
+                             "], keep the previous configuration, err: ", err)
+                    insert_tab(self.values, prev_item)
+                    self.values_hash[key] = #self.values
+                    carried[prev_item] = true
+                end
             end
 
             self:upgrade_version(item.modifiedIndex)
         end
     end
 
+    -- fire the clean handlers of the previous items that were not carried
+    -- over: they were either replaced by a new value or deleted from etcd
+    if prev_values then
+        for _, item in ipairs(prev_values) do
+            if item and not carried[item] then
+                config_util.fire_all_clean_handlers(item)
+            end
+        end
+    end
+
     if headers then
         self.prev_index = tonumber(headers["X-Etcd-Index"]) or 0
         self:upgrade_version(headers["X-Etcd-Index"])
@@ -691,16 +744,15 @@ local function sync_data(self)
         log.debug("readdir key: ", self.key, " res: ",
                   json.delay_encode(dir_res))
 
-        if self.values then
-            for i, val in ipairs(self.values) do
-                config_util.fire_all_clean_handlers(val)
-            end
-
-            self.values = nil
-            self.values_hash = nil
-        end
+        -- hand the previous values over to load_full_data so that an item 
whose
+        -- new data fails the validation can keep serving with its old value,
+        -- consistent with the incremental watch path. The clean handlers of 
the
+        -- replaced / deleted items are fired inside load_full_data.
+        local prev_values, prev_values_hash = self.values, self.values_hash
+        self.values = nil
+        self.values_hash = nil
 
-        load_full_data(self, dir_res, headers)
+        load_full_data(self, dir_res, headers, prev_values, prev_values_hash)
 
         return true
     end
diff --git a/apisix/plugin.lua b/apisix/plugin.lua
index 65179364e..a2898bc0d 100644
--- a/apisix/plugin.lua
+++ b/apisix/plugin.lua
@@ -1017,8 +1017,16 @@ local function check_single_plugin_schema(name, 
plugin_conf, schema_type, skip_d
     if plugin_obj.check_schema then
         local ok, err = plugin_obj.check_schema(plugin_conf, schema_type)
         if not ok then
-            return false, "failed to check the configuration of plugin "
-                .. name .. " err: " .. err
+            if check_disable(plugin_conf) ~= true then
+                return false, "failed to check the configuration of plugin "
+                    .. name .. " err: " .. err
+            end
+
+            -- the plugin is disabled via _meta.disable so it will never be
+            -- executed: an environment dependent failure (e.g. proxy-cache
+            -- cache_zone not found on this node) must not invalidate the item
+            core.log.warn("failed to check the configuration of disabled 
plugin ",
+                          name, ", accepting it anyway")
         end
 
         if plugin_conf._meta then
@@ -1262,8 +1270,13 @@ local function stream_check_schema(plugins_conf, 
schema_type, skip_disabled_plug
         if plugin_obj.check_schema then
             local ok, err = plugin_obj.check_schema(plugin_conf, schema_type)
             if not ok then
-                return false, "failed to check the configuration of "
-                              .. "stream plugin [" .. name .. "]: " .. err
+                if check_disable(plugin_conf) ~= true then
+                    return false, "failed to check the configuration of "
+                                  .. "stream plugin [" .. name .. "]: " .. err
+                end
+
+                core.log.warn("failed to check the configuration of disabled ",
+                              "stream plugin [", name, "], accepting it 
anyway")
             end
         end
 
diff --git a/t/core/config_etcd.t b/t/core/config_etcd.t
index 9ccac862f..16deda060 100644
--- a/t/core/config_etcd.t
+++ b/t/core/config_etcd.t
@@ -615,3 +615,251 @@ passed
 qr/invalid or missing X-Etcd-Index header/
 --- grep_error_log_out eval
 qr/(invalid or missing X-Etcd-Index header\n){1,}/
+
+
+
+=== TEST 16: full reload keeps the previous value of an item whose new data is 
invalid
+--- timeout: 20
+--- yaml_config
+deployment:
+  role: traditional
+  role_traditional:
+    config_provider: etcd
+  etcd:
+    host:
+      - "http://127.0.0.1:2379";
+    prefix: /apisix
+--- extra_yaml_config
+nginx_config:
+    worker_processes: 1
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local etcd = require("resty.etcd")
+            local etcd_cli, err = etcd.new({
+                http_host = "http://127.0.0.1:2379";,
+            })
+            if not etcd_cli then
+                ngx.say("failed to create etcd client: ", err)
+                return
+            end
+
+            local valid = {
+                id = "1",
+                create_time = 1700000000,
+                update_time = 1700000000,
+                plugins = {
+                    ["response-rewrite"] = {
+                        headers = {
+                            set = {["X-Global-Test"] = "hit"}
+                        }
+                    }
+                }
+            }
+            -- proxy-cache validates cache_zone against the zones declared in
+            -- the local config.yaml, so this value is permanently invalid on
+            -- this node while being perfectly storable in etcd
+            local invalid = core.table.deepcopy(valid)
+            invalid.plugins["proxy-cache"] = {
+                cache_zone = "no_such_zone_for_test",
+                cache_strategy = "disk",
+                cache_key = {"$host", "$request_uri"},
+                cache_method = {"GET"},
+                cache_http_status = {200},
+            }
+
+            local function global_rule(id)
+                local obj = core.config.fetch_created_obj("/global_rules")
+                for _, item in ipairs(obj.values or {}) do
+                    if item and item.value and item.value.id == id then
+                        return item.value
+                    end
+                end
+            end
+
+            etcd_cli:set("/apisix/global_rules/1", valid)
+            ngx.sleep(1)
+            ngx.say("valid loaded: ", global_rule("1") ~= nil)
+
+            etcd_cli:set("/apisix/global_rules/1", invalid)
+            ngx.sleep(1)
+            ngx.say("kept by watch path: ", global_rule("1") ~= nil)
+
+            -- reproduce the state an etcd compaction leaves behind, then write
+            -- again to wake up the sync loop blocked on the watch semaphore
+            core.config.fetch_created_obj("/global_rules").need_reload = true
+            etcd_cli:set("/apisix/global_rules/1", invalid)
+            ngx.sleep(2)
+
+            local rule = global_rule("1")
+            ngx.say("kept by full reload: ",
+                    rule ~= nil and rule.plugins["response-rewrite"] ~= nil)
+
+            etcd_cli:delete("/apisix/global_rules/1")
+            ngx.sleep(1)
+        }
+    }
+--- request
+GET /t
+--- response_body
+valid loaded: true
+kept by watch path: true
+kept by full reload: true
+--- error_log
+keep the previous configuration
+
+
+
+=== TEST 17: full reload does not resurrect an item that no longer exists in 
etcd
+--- timeout: 20
+--- yaml_config
+deployment:
+  role: traditional
+  role_traditional:
+    config_provider: etcd
+  etcd:
+    host:
+      - "http://127.0.0.1:2379";
+    prefix: /apisix
+--- extra_yaml_config
+nginx_config:
+    worker_processes: 1
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local etcd = require("resty.etcd")
+            local etcd_cli, err = etcd.new({
+                http_host = "http://127.0.0.1:2379";,
+            })
+            if not etcd_cli then
+                ngx.say("failed to create etcd client: ", err)
+                return
+            end
+
+            local obj = core.config.fetch_created_obj("/global_rules")
+            obj.values = obj.values or {}
+            obj.values_hash = obj.values_hash or {}
+
+            -- an item that lives in memory but not in etcd, i.e. what the full
+            -- reload is expected to drop
+            local stale = {
+                key = "/apisix/global_rules/ghost",
+                modifiedIndex = 1,
+                clean_handlers = {},
+                value = {id = "ghost", plugins = {}},
+            }
+            core.table.insert(obj.values, stale)
+            obj.values_hash["ghost"] = #obj.values
+
+            obj.need_reload = true
+            etcd_cli:set("/apisix/global_rules/2", {
+                id = "2",
+                create_time = 1700000000,
+                update_time = 1700000000,
+                plugins = {
+                    ["response-rewrite"] = {
+                        headers = {
+                            set = {["X-Global-Test"] = "hit"}
+                        }
+                    }
+                }
+            })
+            ngx.sleep(2)
+
+            local found_ghost, found_2 = false, false
+            for _, item in ipairs(obj.values or {}) do
+                if item and item.value and item.value.id == "ghost" then
+                    found_ghost = true
+                end
+                if item and item.value and item.value.id == "2" then
+                    found_2 = true
+                end
+            end
+            ngx.say("stale item resurrected: ", found_ghost)
+            ngx.say("valid item loaded: ", found_2)
+
+            etcd_cli:delete("/apisix/global_rules/2")
+            ngx.sleep(1)
+        }
+    }
+--- request
+GET /t
+--- response_body
+stale item resurrected: false
+valid item loaded: true
+
+
+
+=== TEST 18: full reload still skips an invalid item that has no previous value
+--- timeout: 20
+--- yaml_config
+deployment:
+  role: traditional
+  role_traditional:
+    config_provider: etcd
+  etcd:
+    host:
+      - "http://127.0.0.1:2379";
+    prefix: /apisix
+--- extra_yaml_config
+nginx_config:
+    worker_processes: 1
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local etcd = require("resty.etcd")
+            local etcd_cli, err = etcd.new({
+                http_host = "http://127.0.0.1:2379";,
+            })
+            if not etcd_cli then
+                ngx.say("failed to create etcd client: ", err)
+                return
+            end
+
+            local invalid = {
+                id = "9",
+                create_time = 1700000000,
+                update_time = 1700000000,
+                plugins = {
+                    ["proxy-cache"] = {
+                        cache_zone = "no_such_zone_for_test",
+                        cache_strategy = "disk",
+                        cache_key = {"$host", "$request_uri"},
+                        cache_method = {"GET"},
+                        cache_http_status = {200},
+                    }
+                }
+            }
+
+            local function has_rule(id)
+                local obj = core.config.fetch_created_obj("/global_rules")
+                for _, item in ipairs(obj.values or {}) do
+                    if item and item.value and item.value.id == id then
+                        return true
+                    end
+                end
+                return false
+            end
+
+            etcd_cli:set("/apisix/global_rules/9", invalid)
+            ngx.sleep(1)
+
+            core.config.fetch_created_obj("/global_rules").need_reload = true
+            etcd_cli:set("/apisix/global_rules/9", invalid)
+            ngx.sleep(2)
+
+            ngx.say("invalid new item loaded: ", has_rule("9"))
+
+            etcd_cli:delete("/apisix/global_rules/9")
+            ngx.sleep(1)
+        }
+    }
+--- request
+GET /t
+--- response_body
+invalid new item loaded: false
+--- no_error_log
+keep the previous configuration
diff --git a/t/plugin/proxy-cache/disk.t b/t/plugin/proxy-cache/disk.t
index 1d3a12459..06892f378 100644
--- a/t/plugin/proxy-cache/disk.t
+++ b/t/plugin/proxy-cache/disk.t
@@ -753,3 +753,84 @@ passed
 GET /hello?bar=a
 --- response_body chop
 hello world!
+
+
+
+=== TEST 30: a disabled plugin with an invalid cache zone is accepted
+--- config
+       location /t {
+           content_by_lua_block {
+               local t = require("lib.test_admin").test
+               local code, body = t('/apisix/admin/routes/1',
+                    ngx.HTTP_PUT,
+                    [[{
+                        "plugins": {
+                            "proxy-cache": {
+                               "_meta": {"disable": true},
+                               "cache_zone": "invalid_disk_cache",
+                               "cache_method": ["GET"],
+                               "cache_http_status": [200]
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1986": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello*"
+                   }]]
+                   )
+
+               if code >= 300 then
+                   ngx.status = code
+               end
+               ngx.say(body)
+           }
+       }
+--- request
+GET /t
+--- response_body
+passed
+--- error_log
+failed to check the configuration of disabled plugin proxy-cache
+
+
+
+=== TEST 31: re-enabling the same plugin conf is still rejected
+--- config
+       location /t {
+           content_by_lua_block {
+               local t = require("lib.test_admin").test
+               local code, body = t('/apisix/admin/routes/1',
+                    ngx.HTTP_PUT,
+                    [[{
+                        "plugins": {
+                            "proxy-cache": {
+                               "_meta": {"disable": false},
+                               "cache_zone": "invalid_disk_cache",
+                               "cache_method": ["GET"],
+                               "cache_http_status": [200]
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1986": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello*"
+                   }]]
+                   )
+
+               if code >= 300 then
+                   ngx.status = code
+               end
+               ngx.say(body)
+           }
+       }
+--- request
+GET /t
+--- error_code: 400
+--- response_body eval
+qr/cache_zone invalid_disk_cache not found/

Reply via email to