wklken opened a new issue, #13961: URL: https://github.com/apache/apisix/issues/13961
### Current Behavior After upgrading our APISIX-based deployment from 3.16 to 3.18, one Pod started exhibiting recurring high CPU utilization across multiple workers. Other Pods in the deployment were less affected. The production traffic was reported as unchanged across the upgrade; this was not a controlled replay benchmark. Two low-frequency profiles of the same worker identified the mutex of **`prometheus-metrics`** as a substantial CPU hotspot. We traced the mutex pointer to the actual shared-memory zone and verified its name from the zone's management metadata. Comparing the Prometheus dependency versions revealed a possible regression mechanism: **re-adding an expired indexed metric now increments `delete_count`, causing other workers to scan the entire historical index through `sync_range(0, N)`**. The affected Pod has an index high-water counter of approximately 192,000. We can establish the lock identity, deployed dependency, configuration, index size, and source-level amplification mechanism. **We have not captured a Lua stack proving that `sync_range()` was executing during these CPU spikes, and have not completed a controlled A/B test.** We are reporting an observed performance problem with a specific, evidence-backed hypothesis, rather than claiming that a particular commit has been conclusively proven responsible. > Disclosure note: infrastructure identifiers, exact timestamps, runtime addresses, and application label values are omitted. Internal index counters are explicitly rounded. Public source revisions and sampling counts are retained. ## CPU trend The cropped dashboard below shows the affected Pod's elevated green CPU series alongside the other Pods. Identifying legends are omitted. This is a dashboard observation, separate from the single-worker perf sample percentages reported below. <img width="1602" height="468" alt="CPU utilization across Pods, with one green series showing recurring elevated CPU" src="https://github.com/user-attachments/assets/f7205178-e9f2-42ab-b07b-5ddf00ed6fd9" /> ## Evidence 1: the dependency changed, and the new deployed files were verified | Official APISIX release | Pinned `nginx-lua-prometheus-api7` version | |---|---| | [3.16.0 rockspec](https://github.com/apache/apisix/blob/f9c4ee37170bdfe075c846973c1be65a582a0c4f/apisix-master-0.rockspec#L54) | `0.20250302-1` | | [3.18.0 rockspec](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix-master-0.rockspec#L57) | `1.0.0-1` | The dependency repository is `api7/nginx-lua-prometheus`: - Old version: `f86aa3ef2249e2dce4312e549ab45ad79203634f` (`0.20250302`). - New version: `bc04f9daa8f8da0f8f8165687c9e53136f9c50f5` (`1.0.0`). SHA-256 hashes of the three deployed files match `1.0.0`: ```text e8f909659a3a53bf11bdf908c7f68a709b74641102fb74b030c2d10a9fea58e1 prometheus.lua 91ae2b17b138602b7388f840177fbec9bf5516e2c3730b4c36224f1ba8bb3497 prometheus_keys.lua 749ecb10c6dd2060dbf081c93005a87f757679ff2eb81c99989a2553bd96bb01 prometheus_resty_counter.lua ``` The previous production image's files have not been independently verified; the old-side comparison uses the official 3.16.0 dependency pin. Between these two dependency tags, `prometheus.lua` and `prometheus_resty_counter.lua` are unchanged. The runtime code differences are in `prometheus_keys.lua`. ## Evidence 2: repeated CPU samples identify the same metric-zone mutex We sampled one serving worker from its host, using the same parameters twice: user-space `cpu-clock:u`, 19 Hz, 12 seconds, no inherited tasks, and the `BX` user register. A one-second CPU observation immediately before the second profile showed that worker at 99% CPU. | Profile | Total decoded samples | Samples in the inspected spin loop | Sample share | |---|---:|---:|---:| | First | 118 | 47 | 39.8% | | Second | 105 | 54 | 51.4% | All selected spin-loop samples in both profiles contained the same mutex pointer. Exact runtime addresses are omitted. The attribution was verified as follows: 1. Disassembly of the **actual worker executable** showed that `ngx_shmtx_lock()` saves its mutex argument from `RDI` in `RBX` and retains it in the sampled spin loop. The sampled instructions were matched to that inspected spin loop. 2. `/proc/<host-worker-pid>/maps` placed that mutex inside a shared mapping of size 128 MiB. 3. The actual binary's shared-dict initializer showed the `log_ctx` field offset. A bounded read of the zone header and its context string returned: ```text zone_context = in lua_shared_dict zone "prometheus-metrics" ``` This identifies the dictionary by its actual name, not just by matching its configured size. The [module's initialization code](https://github.com/api7/apisix-nginx-module/blob/1.19.9/src/meta/ngx_meta_lua_shdict.c#L234-L241) explains this name field. Earlier native profiles also showed `ngx_meta_lua_shdict_lookup` and `lj_str_new` as hotspots. Those symbols are consistent with repeated dictionary lookup and string construction, but are not unique to `KeyIndex:sync_range()`. **Interpretation limit:** these percentages describe the selected worker's samples, not total Pod CPU, measured lock-hold duration, or a throughput regression percentage. We have no valid Lua call chain from these profiles. The sampled worker may be a lock waiter rather than the worker causing a long hold. <details> <summary>Profiling method</summary> The capture used the following command shape, with a verified host worker PID substituted for `WORKER_PID`: ```bash timeout -s INT -k 2s 16s perf record -q -N -B -o - \ -p "$WORKER_PID" -e cpu-clock:u -F 19 -m 16 \ --no-inherit --user-regs=bx -- sleep 12 | timeout -s INT -k 2s 22s perf script -i - -F time,ip,uregs ``` The decoded output was retained in shell memory for analysis. The spin-loop filter used instruction addresses verified against the actual executable. Exact addresses are omitted; instruction addresses and structure offsets are binary-specific and must not be reused blindly with other builds. Metadata inspection used read-only `/proc/<pid>/mem` access without attaching a debugger, stopping a worker, injecting code, or calling the live shared-dict API. Profiling and inspection have some overhead; we did not establish zero request-latency impact. The collectors exited and all eight workers remained present afterward. </details> ## Evidence 3: expiry is configured and the historical index is large Two bounded read-only lookups of the internal numeric keys produced the following state. Values are intentionally rounded for disclosure; these are summarized observations, not raw output: | Relative time | `__ngx_prom__key_count` | `__ngx_prom__delete_count` | |---|---:|---:| | T | approximately 192,000 | approximately 6,100 | | T + 1 second | approximately 192,000 | approximately 6,100 | These were non-atomic snapshots of the running process, with exact key-name/type checks. Less than 6 KiB of tree/entry metadata was read in total; no full key enumeration was performed. Important distinctions: - `key_count` is an index-allocation counter/high-water value, **not the current live series count**. Normal deletion does not compact the scan range; counter eviction/recreation is a separate exceptional case. - `delete_count` is a cumulative notification counter, **not the number of full scans**. Explicit deletions can also increase it. - The allocation counter increased slightly, while the deletion counter did not change between these two snapshots. Rounding masks the small allocation increase. These readings establish state and scale, not an expiry/re-add event during that particular second or during the earlier profiles. ## Source-level hypothesis: expired re-adds amplify full historical resynchronization The relevant change is [api7/nginx-lua-prometheus#14](https://github.com/api7/nginx-lua-prometheus/pull/14), commit [`845084f`](https://github.com/api7/nginx-lua-prometheus/commit/845084f47417f722918f7ea6ec28721fa8f31215). Its purpose is to fix duplicate metric exposition following expired-key re-addition. In the [old version](https://github.com/api7/nginx-lua-prometheus/blob/f86aa3ef2249e2dce4312e549ab45ad79203634f/prometheus_keys.lua#L114-L142), failure to renew an expired index slot clears local state and registers a new slot. It does not increment `delete_count` on this branch. In the [new version](https://github.com/api7/nginx-lua-prometheus/blob/bc04f9daa8f8da0f8f8165687c9e53136f9c50f5/prometheus_keys.lua#L155-L175), `err == "not found"` additionally executes: ```lua self.deleted = self.deleted + 1 local _, incr_err, forcible = self.dict:incr(self.delete_count, 1, 0) ``` Another worker's next [`KeyIndex:sync()`](https://github.com/api7/nginx-lua-prometheus/blob/bc04f9daa8f8da0f8f8165687c9e53136f9c50f5/prometheus_keys.lua#L71-L108) then takes the existing full-sync branch: ```lua local delete_count = self.dict:get(self.delete_count) or 0 local N = self.dict:get(self.key_count) or 0 if self.deleted ~= delete_count then self:sync_range(0, N) self.deleted = delete_count end ``` `sync_range()` iterates every index in that numeric range, constructs `self.key_prefix .. i`, calls `dict:get()` for each slot, and conditionally calls `dict:ttl()`. It includes historical empty slots and has no explicit yield in the loop. This can execute on the request path: [`lookup_or_create()`](https://github.com/api7/nginx-lua-prometheus/blob/bc04f9daa8f8da0f8f8165687c9e53136f9c50f5/prometheus.lua#L415-L424) calls `KeyIndex:add()` even on a local lookup-cache hit when positive expiry is configured. `add()` calls `sync()` before updating the slot. ```text metric update with expiry configured → lookup_or_create / KeyIndex:add → expired indexed metric is re-added → shared delete_count increases [new behavior] → other workers observe the notification → sync_range(0, historical key_count) → many string constructions and shared-dict get/ttl operations → potential cross-worker mutex contention and request-path CPU amplification ``` At the observed, rounded N≈192,000, **one** full sync executes approximately 192,000 index `get()` calls, plus conditional TTL reads. Across multiple workers, the operation count can reach the million range for an invalidation observed by all of them. Notifications can coalesce; we have not measured the actual scan frequency and do not multiply the cumulative deletion counter by N. This provides a mechanism for increased CPU with unchanged traffic: the work performed for the same expired-metric reappearance changed, and its cost depends on accumulated index history. It does not yet explain why only one Pod is much more affected; per-Pod index history and expiry/re-add behavior have not been compared. ## Secondary hypothesis: periodic full shared-dict reclamation [api7/nginx-lua-prometheus#18](https://github.com/api7/nginx-lua-prometheus/pull/18), commit [`5f68f6c`](https://github.com/api7/nginx-lua-prometheus/commit/5f68f6ca40d98de506c65c4990461a23351abf9c), adds an unconditional `self.dict:flush_expired()` after local expired-index cleanup. For the initialization path observed in our deployment, `Prometheus.init(dict, string_prefix)` supplies the library's **3600-second** cleanup interval. Therefore the `or 600` fallback in `KeyIndex.new()` is not the effective interval here. This is separate from the 15-second exporter refresh and 300/600-second metric TTLs. The [apisix-nginx-module 1.19.9 implementation](https://github.com/api7/apisix-nginx-module/blob/1.19.9/src/meta/ngx_meta_lua_shdict.c#L558-L646) holds the zone mutex while traversing the LRU. With no argument, reclamation is not count-limited. This is a plausible source of periodic long holds, but we have not correlated its execution with the CPU spikes and do not attribute all recurring spikes to it. Also, a finite `flush_expired(n)` bounds successfully freed entries, not necessarily nodes visited, so it would not by itself establish a strict traversal-time bound. ## Questions for maintainers 1. Is broadcasting a full historical resync on expired-key re-addition expected at this index scale, or could api7/nginx-lua-prometheus#14 introduce this performance regression under expiry/re-add churn? 2. Could the consistency fix use bounded/incremental invalidation or index compaction while retaining duplicate-free exposition? 3. Should the new reclamation path have single-worker ownership and a bounded amount of work, given that it shares the serving workers' metric-zone mutex? We are not proposing simply removing either correctness fix: removing the broadcast can reintroduce duplicate metrics, and removing reclamation can reintroduce retained expired entries. We would appreciate guidance on a bounded implementation and the most useful additional evidence to distinguish these two paths. ### Expected Behavior Reintroducing an expired metric should preserve index consistency and avoid duplicate exposition without causing excessive request-path work across workers as the historical index grows. Background reclamation should also avoid excessive shared-dict lock hold times. ### Error Logs No error-log excerpt is attached. The evidence above consists of CPU samples, native symbol/disassembly analysis, and bounded read-only shared-memory metadata inspection. We did not change production log levels. ### Steps to Reproduce We do not yet have an executed minimal reproduction. We avoided changing production code/configuration or enabling in-worker instrumentation to obtain one. The production observation is recurring CPU spikes after upgrading our customized APISIX deployment from 3.16 to 3.18, with traffic reported as unchanged; this was not a controlled replay. A focused isolated validation could compare the two dependency versions under the same conditions: 1. Create two or more independent KeyIndex instances sharing one dictionary, representing workers with separate local index state. 2. Build a large historical index, sync both instances, and let an indexed metric expire while retaining its local cached index entry. 3. Re-add that metric through one instance. 4. Update an existing metric through the other instance; count sync_range() calls and dictionary get/ttl operations. 5. Compare incremental versus full historical scans, and verify duplicate-free exposition and expiry correctness. Then repeat with real workers to measure CPU, mutex contention, and request latency. Validate the periodic cleanup path separately. This is a proposed validation procedure, **not an experiment already performed**. ### Environment - APISIX-based **customized build**, upgraded from 3.16 to 3.18. - Eight NGINX workers; container CPU quota equivalent to eight cores. - Linux 5.4-based kernel; x86-64, LuaJIT-enabled OpenResty. Vendor-specific build details are omitted. - `lua_shared_dict prometheus-metrics 128m`. - Exporter refresh interval: 15 seconds. - Enabled bandwidth metrics expire after 300 seconds; several other configured metrics expire after 600 seconds. - Additional application-specific labels are present. - Our customized exporter disables the official HTTP status/latency updates and enables bandwidth/LLM updates. These switches are customization details, **not stock APISIX configuration fields**. The contribution of AI-specific metrics has not been measured. - The underlying `prometheus.lua`, `prometheus_keys.lua`, and `prometheus_resty_counter.lua` files are byte-for-byte identical to the dependency's `1.0.0` release. The customized exporter and the lack of a controlled traffic replay are relevant limitations when attributing the issue to an upstream change. -- 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]
