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 6a3a76648d fix(consul): run the catalog and health watchers as
independent timers (#14032)
6a3a76648d is described below
commit 6a3a76648d5a017f82f0363b82b879f2a9935e85
Author: Nic <[email protected]>
AuthorDate: Wed Oct 14 10:38:42 2026 +0800
fix(consul): run the catalog and health watchers as independent timers
(#14032)
---
apisix/discovery/consul/client.lua | 10 ++-
apisix/discovery/consul/init.lua | 136 +++++++++++++-----------------
t/discovery/consul-watch-race.t | 168 +++++++++++++++++++++++++++++++++++++
3 files changed, 233 insertions(+), 81 deletions(-)
diff --git a/apisix/discovery/consul/client.lua
b/apisix/discovery/consul/client.lua
index c5afd20e8e..a86f6356c3 100644
--- a/apisix/discovery/consul/client.lua
+++ b/apisix/discovery/consul/client.lua
@@ -144,10 +144,13 @@ end
-- ─── blocking query watchers ──────────────────────────────────────────
+-- watch_catalog / watch_health run one blocking query. They return the watch
+-- type and the new index (an error index on failure), or nothing when the
+-- index did not change.
+
function _M.watch_catalog(consul_server)
local client = resty_consul:new(get_opts(consul_server, true))
- ::RETRY::
local watch_result, watch_err =
client:get(consul_server.consul_watch_catalog_url)
local watch_error_info = (watch_err ~= nil and watch_err)
or ((watch_result ~= nil and watch_result.status
~= 200)
@@ -166,7 +169,7 @@ function _M.watch_catalog(consul_server)
local random_delay = math_random(default_random_range)
log.info("watch catalog has no change, re-watch consul after ",
random_delay, " seconds")
core_sleep(random_delay)
- goto RETRY
+ return
end
return watch_type_catalog, watch_result.headers['X-Consul-Index']
@@ -176,7 +179,6 @@ end
function _M.watch_health(consul_server)
local client = resty_consul:new(get_opts(consul_server, false))
- ::RETRY::
local watch_result, watch_err =
client:get(consul_server.consul_watch_health_url)
local watch_error_info = (watch_err ~= nil and watch_err)
or ((watch_result ~= nil and watch_result.status ~= 200)
@@ -195,7 +197,7 @@ function _M.watch_health(consul_server)
local random_delay = math_random(default_random_range)
log.info("watch health has no change, re-watch consul after ",
random_delay, " seconds")
core_sleep(random_delay)
- goto RETRY
+ return
end
return watch_type_health, watch_result.headers['X-Consul-Index']
diff --git a/apisix/discovery/consul/init.lua b/apisix/discovery/consul/init.lua
index 2277e93586..282b8a03b6 100644
--- a/apisix/discovery/consul/init.lua
+++ b/apisix/discovery/consul/init.lua
@@ -31,10 +31,7 @@ local json_delay_encode = core.json.delay_encode
local process = require("ngx.process")
local ngx_worker_id = ngx.worker.id
local exiting = ngx.worker.exiting
-local thread_spawn = ngx.thread.spawn
-local thread_wait = ngx.thread.wait
-local thread_kill = ngx.thread.kill
-local math_random = math.random
+local pcall = pcall
local is_http = ngx.config.subsystem == "http"
local _M = {
@@ -55,7 +52,6 @@ local nodes_cache = core.lrucache.new({
})
local default_skip_services = {"consul"}
-local default_random_range = 5
local function get_dict()
@@ -224,83 +220,30 @@ end
-- ─── polling loop ─────────────────────────────────────────────────────
-local function check_keepalive(reg, consul_server, retry_delay)
- if reg.stop_flag then
- return
- end
-
- if exiting() then
- return
- end
-
- if consul_server.keepalive then
- local ok, err = ngx_timer_at(0, _M.connect, reg, consul_server,
retry_delay)
- if not ok then
- log.error("create ngx_timer_at got error: ", err)
- return
- end
- else
- -- self-rescheduling poll: use timer.at instead of timer.every
- -- so stop_flag can actually halt future wakeups
- local ok, err = ngx_timer_at(consul_server.fetch_interval,
- _M.connect, reg, consul_server,
retry_delay)
- if not ok then
- log.error("create ngx_timer_at got error: ", err)
- return
- end
- end
-end
+local watch
-function _M.connect(premature, reg, consul_server, retry_delay)
- if premature or reg.stop_flag then
- return
- end
-
- local catalog_thread, spawn_catalog_err =
thread_spawn(consul_client.watch_catalog,
- consul_server)
- if not catalog_thread then
- local random_delay = math_random(default_random_range)
- log.error("failed to spawn thread watch catalog: ", spawn_catalog_err,
- ", retry connecting consul after ", random_delay, " seconds")
- core_sleep(random_delay)
-
- check_keepalive(reg, consul_server, retry_delay)
+local function arm_watch(reg, consul_server, watch_fn, delay)
+ if reg.stop_flag or exiting() then
return
end
- local health_thread, err = thread_spawn(consul_client.watch_health,
consul_server)
- if not health_thread then
- thread_kill(catalog_thread)
- local random_delay = math_random(default_random_range)
- log.error("failed to spawn thread watch health: ", err,
- ", retry connecting consul after ", random_delay, " seconds")
- core_sleep(random_delay)
-
- check_keepalive(reg, consul_server, retry_delay)
- return
+ local ok, err = ngx_timer_at(delay, watch, reg, consul_server, watch_fn)
+ if not ok then
+ log.error("create ngx_timer_at got error: ", err)
end
+end
- local thread_wait_ok, watch_type, index = thread_wait(catalog_thread,
health_thread)
- thread_kill(catalog_thread)
- thread_kill(health_thread)
- if not thread_wait_ok then
- local random_delay = math_random(default_random_range)
- log.error("failed to wait thread: ", watch_type, ", retry connecting
consul after ",
- random_delay, " seconds")
- core_sleep(random_delay)
-
- check_keepalive(reg, consul_server, retry_delay)
- return
- end
+-- One round: validate the watch result, then fetch all services, backing off
on
+-- errors.
+local function handle_change(reg, consul_server, watch_type, index)
if not consul_client.watch_result_is_valid(tonumber(watch_type),
tonumber(index), consul_server.catalog_index,
consul_server.health_index) then
- retry_delay = consul_client.get_retry_delay(retry_delay)
+ local retry_delay =
consul_client.get_retry_delay(consul_server.retry_delay)
+ consul_server.retry_delay = retry_delay
log.warn("get all svcs got err, retry connecting consul after ",
retry_delay, " seconds")
core_sleep(retry_delay)
-
- check_keepalive(reg, consul_server, retry_delay)
return
end
@@ -318,12 +261,11 @@ function _M.connect(premature, reg, consul_server,
retry_delay)
})
if fetch_err then
- retry_delay = consul_client.get_retry_delay(retry_delay)
+ local retry_delay =
consul_client.get_retry_delay(consul_server.retry_delay)
+ consul_server.retry_delay = retry_delay
log.warn("get all svcs got err: ", fetch_err,
", retry connecting consul after ", retry_delay, " seconds")
core_sleep(retry_delay)
-
- check_keepalive(reg, consul_server, retry_delay)
return
end
@@ -345,7 +287,44 @@ function _M.connect(premature, reg, consul_server,
retry_delay)
end
consul_client.update_index(consul_server, new_catalog_index,
new_health_index)
- check_keepalive(reg, consul_server, retry_delay)
+end
+
+
+-- Each index has its own watcher timer, and one run is one blocking query: no
+-- watcher is ever killed mid-read, and every run frees its memory when it
ends.
+-- A watcher that sees a change runs the round itself. Rounds never overlap: a
+-- watcher that sees a change while another round runs parks, and is re-armed
+-- with the round's watcher when it ends, so it queries again against the
+-- updated indexes.
+watch = function(premature, reg, consul_server, watch_fn)
+ if premature or reg.stop_flag then
+ return
+ end
+
+ local watch_type, index = watch_fn(consul_server)
+ if not watch_type then
+ arm_watch(reg, consul_server, watch_fn, 0)
+ return
+ end
+
+ if consul_server.in_round then
+ consul_server.parked[watch_fn] = true
+ return
+ end
+
+ consul_server.in_round = true
+ local ok, err = pcall(handle_change, reg, consul_server, watch_type, index)
+ consul_server.in_round = false
+ if not ok then
+ log.error("failed to handle consul watch result: ", err)
+ end
+
+ local delay = consul_server.keepalive and 0 or consul_server.fetch_interval
+ for parked_fn in pairs(consul_server.parked) do
+ arm_watch(reg, consul_server, parked_fn, delay)
+ end
+ consul_server.parked = {}
+ arm_watch(reg, consul_server, watch_fn, delay)
end
@@ -437,9 +416,12 @@ function _M.start_registry(reg)
log.info("consul_server_list: ", json_delay_encode(consul_servers_list,
true))
for _, server in ipairs(consul_servers_list) do
- local ok, timer_err = ngx_timer_at(0, _M.connect, reg, server)
- if not ok then
- error("create consul got error: " .. timer_err)
+ server.parked = {}
+ for _, watch_fn in ipairs({consul_client.watch_catalog,
consul_client.watch_health}) do
+ local ok, timer_err = ngx_timer_at(0, watch, reg, server, watch_fn)
+ if not ok then
+ error("create consul got error: " .. timer_err)
+ end
end
end
end
diff --git a/t/discovery/consul-watch-race.t b/t/discovery/consul-watch-race.t
new file mode 100644
index 0000000000..276c96b9a6
--- /dev/null
+++ b/t/discovery/consul-watch-race.t
@@ -0,0 +1,168 @@
+#
+# 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);
+log_level('info');
+no_root_location();
+no_shuffle();
+
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ # mock consul server: both indexes change together every 3 seconds. The
+ # catalog watch answers 0.3s after a change while the health watch streams
a
+ # large chunked body for about a second, and a non-blocking catalog read
+ # takes 1.5s, so the health body is still being read when the catalog watch
+ # returns and finishes while the following fetch is waiting for its
response.
+ my $http_config = $block->http_config // <<_EOC_;
+
+ server {
+ listen 18507;
+
+ location /v1/catalog/services {
+ content_by_lua_block {
+ local index = math.floor(ngx.now() / 3)
+ local requested = tonumber(ngx.req.get_uri_args().index)
+ if requested then
+ local deadline = ngx.now() + 60
+ while index <= requested and ngx.now() < deadline do
+ ngx.sleep(0.05)
+ index = math.floor(ngx.now() / 3)
+ end
+ ngx.sleep(0.3)
+ else
+ ngx.sleep(1.5)
+ end
+ ngx.header["X-Consul-Index"] = index
+ ngx.header.content_type = "application/json"
+ ngx.say('{"consul":[],"service_a":[]}')
+ }
+ }
+
+ location /v1/health/state/any {
+ content_by_lua_block {
+ local index = math.floor(ngx.now() / 3)
+ local requested = tonumber(ngx.req.get_uri_args().index)
+ ngx.header.content_type = "application/json"
+ if not requested then
+ ngx.header["X-Consul-Index"] = index
+ ngx.say('[]')
+ return
+ end
+
+ local deadline = ngx.now() + 60
+ while index <= requested and ngx.now() < deadline do
+ ngx.sleep(0.05)
+ index = math.floor(ngx.now() / 3)
+ end
+ ngx.header["X-Consul-Index"] = index
+ local check =
'{"Node":"node1","CheckID":"check","Status":"passing",'
+ .. '"Notes":"' .. string.rep("x", 2000) .. '"},'
+ ngx.print("[")
+ for _ = 1, 100 do
+ ngx.print(check)
+ ngx.flush(true)
+ ngx.sleep(0.01)
+ end
+
ngx.print('{"Node":"node1","CheckID":"last","Status":"passing"}]')
+ }
+ }
+
+ location /v1/health/service/service_a {
+ content_by_lua_block {
+ ngx.header["X-Consul-Index"] = math.floor(ngx.now() / 3)
+ ngx.header.content_type = "application/json"
+ ngx.say('[{"Node":{"Node":"node1","Address":"127.0.0.1"},'
+ .. '"Service":{"ID":"service_a1","Service":"service_a",'
+ .. '"Address":"127.0.0.1","Port":30511},"Checks":[]}]')
+ }
+ }
+ }
+
+ server {
+ listen 30511;
+
+ location /hello {
+ content_by_lua_block {
+ ngx.say("server 1")
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+});
+
+our $yaml_config = <<_EOC_;
+apisix:
+ node_listen: 1984
+deployment:
+ role: data_plane
+ role_data_plane:
+ config_provider: yaml
+discovery:
+ consul:
+ servers:
+ - "http://127.0.0.1:18507"
+ timeout:
+ connect: 1000
+ read: 3000
+ wait: 60
+ keepalive: true
+_EOC_
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: a watcher still reading its response body must not break the
following fetch
+--- yaml_config eval: $::yaml_config
+--- apisix_yaml
+routes:
+ -
+ uri: /hello
+ upstream:
+ service_name: service_a
+ discovery_type: consul
+ type: roundrobin
+#END
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.sleep(6)
+
+ local http = require("resty.http")
+ local httpc = http.new()
+ local res, err = httpc:request_uri("http://127.0.0.1:1984/hello")
+ if not res then
+ ngx.say("request failed: ", err)
+ return
+ end
+ ngx.status = res.status
+ ngx.print(res.body)
+ }
+ }
+--- timeout: 15
+--- request
+GET /t
+--- response_body
+server 1
+--- no_error_log
+[error]
+got boolean