nic-6443 commented on issue #12275:
URL: https://github.com/apache/apisix/issues/12275#issuecomment-4988690889
I spent some time on this and I believe the root cause is an infinite busy
loop in the `nginx-lua-prometheus` key index. It exists unchanged from 2.15
(upstream `0.20220527`) through 3.15 (`nginx-lua-prometheus-api7 0.20240201`),
3.17 (`0.20250302`) and current master (`0.20260623`) — neither #13088 nor
#13602 touches it, which is consistent with @shlok-srivastava hitting it again
after upgrading to 3.17.
The library tracks metric names in a key index stored in the same shared
dict: slots `__ngx_prom__key_1 .. key_N` plus a counter
`__ngx_prom__key_count`. Every new time series goes through `KeyIndex:add()` in
`prometheus_keys.lua`:
```lua
while true do
local N = self:sync() -- 2x dict:get (delete_count,
key_count)
if self.index[key] ~= nil then break end
N = N + 1
local ok, err = self.dict:add(self.key_prefix .. N, key) -- 1x dict:add
if ok then
self.dict:incr(self.key_count, 1, 0) -- key_count only advances on
success
...
break
elseif err ~= "exists" then
return "Unexpected error adding a key: " .. err
end
-- err == "exists": retry the same slot, forever
end
```
The loop only makes progress if slot `key_count + 1` is free, and a full
dict breaks that invariant. When a full dict needs room, lua-nginx-module
force-evicts the LRU-tail node (`ngx_http_lua_shdict_expire(ctx, 0)`, "deletes
oldest entry by force") on every set/add/incr. `__ngx_prom__key_count` is an
ordinary evictable node, and it is only refreshed when a worker registers/looks
up a *new* key through the index — under steady traffic with a stable set of
series nothing touches it, so it ages to the LRU tail and eventually gets
evicted (that narrow timing window is also why this takes hours and only hits
some instances — 2/38 above). After that `sync()` reads it as `nil → 0`, and if
one add does succeed, `dict:incr(key_count, 1, 0)` re-creates it at **1**, far
below the surviving slots. The next new series then does
`dict:add("__ngx_prom__key_1", ...)` against a slot that still exists →
`"exists"` → same N → `"exists"` → forever. (The key_count rewind also silently
c
orrupts the index — duplicate/missing series, the milder symptom family that
0.20260623 tried to address.)
Two properties make it exactly what you both captured:
- the loop never yields, so the worker never returns to the event loop: 100%
CPU forever, completely traffic-independent — removing traffic can't help
because the stuck request never finishes. Any other worker that later registers
any new series joins the spin. Only a restart recovers: reload keeps the shm
zone, and `Prometheus.init()` itself calls `key_index:add()` from init_worker,
so reloaded workers can get stuck before serving a single request.
- every failed `dict:add()` does a lookup on the blocking slot, which moves
that node to the LRU *head* — the node blocking progress becomes the
most-recently-used one and can never be evicted. The poisoned state is
self-sustaining.
The loop body is 2 gets + 1 store per iteration, and the flamegraph in the
original report confirms it: extracting the numbers from the SVG gives
`ngx_meta_lua_ffi_shdict_get` 65.78% vs `ngx_meta_lua_ffi_shdict_store` 32.68%
— a 2.01:1 ratio, with `ngx_shmtx_lock` under both. There is **no
`ffi_shdict_incr` frame anywhere** in that flamegraph, which rules out metric
write pressure (counter/histogram flushes go through `dict:incr`): those
workers weren't recording metrics at all, they were spinning in this loop. The
remaining symbols are the loop too — `ngx_crc32_short`/`ngx_memn2cmp` (key
hashing/comparison) and `lj_strfmt_wint`/`lj_str_new` (the `key_prefix .. N`
concat each iteration).
Repro, takes seconds, no APISIX needed — drives the real library:
```bash
git clone https://github.com/api7/nginx-lua-prometheus
resty -I nginx-lua-prometheus --shdict 'prometheus_metrics 10m' repro.lua
```
<details><summary>repro.lua</summary>
```lua
local KeyIndex = require("prometheus_keys")
local dict = ngx.shared.prometheus_metrics
-- 50 series registered through the public API
local ki = KeyIndex.new(dict, "__ngx_prom__")
for i = 1, 50 do
ki:add('apisix_http_status{route="' .. i .. '"}', "dict full")
end
-- Stand-in for the one thing the shared dict does on its own under memory
-- pressure: forcible eviction of the LRU-tail node, which is key_count
-- whenever no new series has been registered for a while.
dict:delete("__ngx_prom__key_count")
-- watchdog: abort instead of hanging the process forever
local count = 0
local proxy = setmetatable({}, { __index = function(_, method)
return function(_, ...)
if method == "add" then
count = count + 1
if count > 200000 then error("SPIN", 0) end
end
return dict[method](dict, ...)
end
end })
ki.dict = proxy
-- one brand-new series arrives...
local t0 = ngx.now()
local ok, err = pcall(ki.add, ki, 'apisix_http_status{route="brand-new"}',
"dict full")
ngx.update_time()
if not ok and err == "SPIN" then
print(string.format("infinite loop: 200000 failed dict:add() on the same
slot in %.2fs, add() never returns", ngx.now() - t0))
else
print("add() returned normally")
end
```
</details>
Output on both `0.20250302` (3.17.0's dep) and current master `0.20260623`:
the watchdog fires after 200k failed `add()` in ~0.3s; without it the call
never returns. The 2.15-era upstream lib has the identical loop, and so does
upstream knyar/nginx-lua-prometheus master.
I also ran the uncapped version inside a 4-worker openresty (apisix-runtime)
and profiled the stuck workers to compare with your captures:
```
perf (self time) 2.15 report 3.15 report this repro
ngx_shmtx_lock 70.8% 75.6% 75.3%
ffi_shdict_get 3.9% 6.2% 11.2%
ffi_shdict_store 2.0% 2.8% 3.6%
shdict_lookup 2.3% 4.0% 2.0%
shdict_expire 2.2% 1.2% 1.1%
ngx_shmtx_unlock 10.1% 4.3% 5.6%
```
strace on a stuck worker shows the same `futex(FUTEX_WAKE)` / `FUTEX_WAIT =
EAGAIN` flood as the original screenshot (the shdict mutex semaphore under
contention), and the workers stayed pegged at ~100% with zero traffic until
killed.
For the fix, I think `KeyIndex:add()` has to stop trusting `key_count`
unconditionally: when `add()` returns `"exists"`, key_count has fallen behind
an occupied slot, so advance it / adopt the occupant into the local index
instead of retrying the same slot forever — plus a hard iteration cap that
degrades to the existing "dict full" error rather than spinning. I validated
that locally: with the same poisoned state a guarded `add()` returns in ~3ms,
places the key and repairs key_count.
Until then the practical mitigation is what @moonming said: keep
`prometheus-metrics` large enough that it never actually fills (the spin can
only arm on a full dict — worth alerting on `free_space()` reaching 0);
`expire`/fewer labels/smaller bucket sets slow the fill but don't remove the
loop.
Happy to submit a PR (fix in api7/nginx-lua-prometheus + dependency bump
here) if this direction makes sense.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]