Skip to content

fix(keys): prevent infinite loop in KeyIndex:add() when key_count falls behind occupied slots#16

Merged
nic-6443 merged 3 commits into
mainfrom
fix/keyindex-add-spin
Jul 16, 2026
Merged

fix(keys): prevent infinite loop in KeyIndex:add() when key_count falls behind occupied slots#16
nic-6443 merged 3 commits into
mainfrom
fix/keyindex-add-spin

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 16, 2026

Copy link
Copy Markdown

Problem

KeyIndex:add() retries dict:add() on the same slot forever when the slot is occupied but __ngx_prom__key_count reports a smaller value:

while true do
  local N = self:sync()              -- reads key_count
  ...
  N = N + 1
  local ok, err = self.dict:add(self.key_prefix .. N, key, exptime)
  if ok then ... break
  elseif err ~= "exists" then return ... end
  -- "exists": retry the same slot, forever
end

key_count is an ordinary shared-dict node: it is only refreshed when a new key is registered, so under steady traffic it goes cold, and once the dict is full it gets LRU-evicted while higher-numbered slots survive (forcible eviction always removes the LRU-tail node). sync() then reads it as nil → 0, incr() re-creates it at 1, and every subsequent new-series registration collides with a surviving slot: "exists" → retry → "exists", forever, without yielding. The worker spins at 100% CPU with the shared-dict lock hot, independent of traffic, until the process is restarted — each failed add() also LRU-refreshes the blocking slot, so the state never self-heals.

This is the root cause of apache/apisix#12275 (workers pegged at 100% CPU even with traffic removed; ngx_shmtx_lock dominating perf, shdict get:store ≈ 2:1 — exactly this loop body). Full analysis and profiles: apache/apisix#12275 (comment)

Minimal reproduction against the real shared dict:

git clone https://github.com/api7/nginx-lua-prometheus
resty -I nginx-lua-prometheus --shdict 'prometheus_metrics 10m' repro.lua
repro.lua (hangs before this fix, returns after)
local KeyIndex = require("prometheus_keys")
local dict = ngx.shared.prometheus_metrics

local ki = KeyIndex.new(dict, "__ngx_prom__")
for i = 1, 50 do
  ki:add('apisix_http_status{route="' .. i .. '"}', "dict full")
end

-- what the shared dict does on its own under memory pressure:
-- forcible eviction of the LRU-tail node == key_count
dict:delete("__ngx_prom__key_count")

print(ki:add('apisix_http_status{route="brand-new"}', "dict full"))

Fix

On a repeated "exists" collision, advance key_count past the occupied slot instead of retrying it forever; the next sync() adopts the slot's occupant and progress resumes. The first collision keeps the old plain retry, so the benign race (another worker created the slot but has not incremented key_count yet) behaves exactly as before.

Repair work per call is bounded (MAX_KEY_COUNT_REPAIRS = 1000); beyond that the call degrades like the existing dict-full path (returns the LRU-eviction error, sample dropped). The advanced counter is shared through the dict, so later calls resume where the previous one stopped and the index converges even when far more slots need repairing.

Tests

  • SimpleDict:add in the test mock now returns "exists" for present keys, matching ngx.shared.DICT:add (it previously always succeeded, so this code path was untestable).
  • testAddAfterKeyCountEvicted — the regression test: hangs forever on the unfixed code (killed only by timeout), passes in milliseconds with the fix.
  • testAddAfterKeyCountEvictedFreshWorker — same eviction seen from a worker with an empty local index (respawn/reload); it must adopt surviving slots while repairing.
  • testKeyCountRepairIsBounded — one call performs at most 1000 repairs and returns the degradation error; the next call resumes and converges.

Full unit suite (41 tests) and the docker-based integration suite pass.

Ref: apache/apisix#12275

Summary by CodeRabbit

  • Bug Fixes
    • Improved recovery when shared key counters fall behind due to eviction, with bounded repair to ensure operations terminate and converge.
    • Prevented CPU spin/stalled synchronization during key additions when slots are occupied and counters are out of sync.
    • Improved behavior for expired and duplicate keys, including correct eviction/adoption warnings after counter repair.
  • Tests
    • Added regression coverage for termination, bounded counter repair progress, worker adoption after eviction, and proper eviction reporting paths.

…ls behind occupied slots

key_count is an ordinary shared-dict node: it is only refreshed when a
new key is registered, so under steady traffic it goes cold and, once
the dict is full, gets LRU-evicted while higher-numbered slots survive.
sync() then reads it as 0 (and incr() re-creates it at 1), so add()
keeps calling dict:add() on the same occupied slot: "exists" -> retry
-> "exists", forever, without yielding. The worker spins at 100% CPU
with the shared-dict lock hot, independent of traffic, until restart.

Advance key_count past the occupied slot on a repeated collision so the
next sync() adopts the slot's occupant and progress resumes. The first
collision keeps the old plain retry, preserving the benign concurrent
add race behavior. Repair work per call is bounded and degrades to the
existing dict-full error path; counter progress is shared, so later
calls converge.

Ref: apache/apisix#12275
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40c47f3a-a182-4a16-8fe9-7c62b099a8ea

📥 Commits

Reviewing files that changed from the base of the PR and between 6ab2eb6 and 71cd6bc.

📒 Files selected for processing (2)
  • prometheus_keys.lua
  • prometheus_test.lua
🚧 Files skipped from review as they are similar to previous changes (2)
  • prometheus_test.lua
  • prometheus_keys.lua

📝 Walkthrough

Walkthrough

KeyIndex:add now repairs lagging shared-dict key counters after repeated "exists" results, with bounded retries and eviction reporting. Test helpers match shared-dict add semantics, and regression tests cover existing workers, fresh workers, bounded convergence, and forcible eviction.

Changes

Key count repair

Layer / File(s) Summary
Shared-dict add semantics and repair logic
prometheus_keys.lua, prometheus_test.lua
KeyIndex:add tracks occupied-slot retries, advances _prefix_key_count up to a configured limit, and propagates forcible eviction warnings from repair. SimpleDict:add() now prunes expired keys and reports existing keys using shared-dict-compatible semantics.
Eviction and convergence regression coverage
prometheus_test.lua
TestKeyIndex verifies repair after counter eviction for existing and fresh workers, bounded progress with later convergence, and forcible eviction reporting when an adopted key is displaced during repair.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant KeyIndex
  participant SharedDict
  Worker->>KeyIndex: add(key)
  KeyIndex->>SharedDict: add(slot, key)
  SharedDict-->>KeyIndex: exists
  KeyIndex->>SharedDict: advance key_count
  KeyIndex->>SharedDict: retry allocation
  KeyIndex-->>Worker: allocated slot or repair error
Loading
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning New coverage is unit-only: prometheus_test.lua uses SimpleDict/ngx mocks, and no integration or real-nginx E2E test was added for the add()/repair flow. Add an E2E regression that runs against real nginx+shared dict and exercises the counter-eviction/repair path, then keep the unit mocks as secondary coverage.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing an infinite loop in KeyIndex:add() when key_count lags occupied slots.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed prometheus_keys.lua/test.lua only change shared-dict key repair and tests; no secret logging, auth, DB, TLS, or ownership-sensitive code added.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/keyindex-add-spin

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@prometheus_keys.lua`:
- Around line 203-209: Update the repair branch in the key-management flow
around self.dict:incr and the repairs counter to capture and validate incr’s
error and forcible result before incrementing repairs. Only count a successful,
non-forcible repair; propagate or handle failures consistently with the existing
LRU-eviction path so a failed or forcible recreation cannot be treated as a
completed repair.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4829e5c8-229b-4082-b0cf-dcaeee64ed59

📥 Commits

Reviewing files that changed from the base of the PR and between d7d8df4 and 91eebf9.

📒 Files selected for processing (2)
  • prometheus_keys.lua
  • prometheus_test.lua

Comment thread prometheus_keys.lua
…ions

Address review feedback: the repair increment's error/forcible results
were ignored. A hard incr failure now returns immediately (mirroring the
add() error path) instead of consuming the repair budget, and a forcible
re-creation of key_count is surfaced through the existing LRU-eviction
warning. The repair cap still counts attempts, not successes: it exists
to guarantee the loop terminates.
@membphis

Copy link
Copy Markdown

P2 (non-blocking): repair_forcible is checked only on the new-slot success path. If repairing key_count causes incr(..., 0) to return forcible = true and the next sync() discovers that the requested key already exists, KeyIndex:add() breaks and returns success, so the eviction is not reported. Please consider checking repair_forcible before the existing-key break and adding a regression test for this path.

@membphis membphis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

…path

repair_forcible was only checked on the new-slot success path: when the
repair crawl adopts the requested key from an occupied slot, add() exited
through the existing-key break and silently swallowed the fact that the
repair increments had forcibly displaced other entries. Return the
LRU-eviction error there too, mirroring forcible/forcible2, with a
regression test covering the adopted-key path.
@jarvis9443

Copy link
Copy Markdown
Author

Good catch, fixed in 71cd6bc: the existing-key exit now returns the LRU-eviction error when repair_forcible is set (message points at the adopted slot), so repair evictions are reported on both exit paths. Added testKeyCountRepairReportsForcibleEviction covering exactly this flow — a worker with an empty local index registers a key that already exists ahead of the rewound counter, the repair crawl adopts it with a forcible incr, and the error must surface; it fails on the previous commit and passes now.

@nic-6443
nic-6443 merged commit f8d6e9f into main Jul 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants