Skip to content

fix(keys): reclaim expired shared-dict entries in remove_expired_keys()#18

Open
AlinsRan wants to merge 1 commit into
mainfrom
fix/flush-expired-shm-reclaim
Open

fix(keys): reclaim expired shared-dict entries in remove_expired_keys()#18
AlinsRan wants to merge 1 commit into
mainfrom
fix/flush-expired-shm-reclaim

Conversation

@AlinsRan

Copy link
Copy Markdown

Problem

prometheus-metrics free_space drops in steps and never recovers when metrics are registered with an expire (apache/apisix#13658). A reporter's dump showed 747970 of 751496 keys were stale __ngx_prom__key_N index slots against 28 live ones, 388MB of a 512MB dict, with very few active series.

KeyIndex:remove_expired_keys() only cleared the worker-local keys / index / expire_keys tables. It issued no writes to the shared dict, so the expired entries themselves — both the index slots and the metric value keys, which share the dict — stayed physically allocated.

Nothing else reclaimed them:

  • Index slots are never reused. Once a metric's slot has expired, add() takes the not-found branch and allocates a fresh slot at key_count+1. key_count is monotonic, so under label churn with an exptime the slot count grows without bound.
  • The passive expiry scan cannot reach them. shdict checks only the LRU tail on each write and stops at the first non-expired entry. A permanent entry — the error metric, or any metric registered without an exptime — inevitably goes cold and ends up sitting there, blocking the scan for good.

Reproduced standalone against 0.20250302 and 0.20260623 with identical output: 1000 active series held constant, free_space dropping one step per burst and never recovering, and a single explicit flush_expired(0) at the end reclaiming 7000 entries and restoring free_space to its initial value. A separate experiment confirmed the LRU-tail blocking: with a permanent entry at the tail, 1000 writes reclaimed nothing; without it, everything was reclaimed.

This is also what feeds #12275 — the unbounded growth is what fills the dict in the first place, which is the precondition for the spin fixed in #16.

Fix

Call self.dict:flush_expired() once per remove_expired_keys() timer round.

Expired entries are indistinguishable from absent ones through every shared-dict API (ngx_http_lua_shdict_lookup() returns NGX_DONE for them, and every caller treats that as a miss), so reclaiming them cannot change any observable behaviour — /metrics output, metric values and expire semantics are unchanged. Only free_space and eviction pressure change.

Live series are not at risk: an active metric's index slot TTL is refreshed on every operation via the lookup_or_create() cache-hit path, which calls key_index:add() with the exptime whenever exptime > 0, and counter value TTLs are refreshed by the counter sync each round. Only series that genuinely saw no writes for the whole expire window can be flushed, which is exactly the semantics of the feature.

Cost is a locked linear scan of the dict per worker per timer round (3600s under the APISIX wiring). At steady state that is microseconds to milliseconds. An instance that has already accumulated the backlog pays one larger scan on the first round after upgrade and then converges — no manual flush or restart needed.

Tests

Three regression tests, all of which fail without the one-line change and pass with it:

  • TestKeyIndex:testRemoveExpiredKeysReclaimsSharedDict — an expired index slot and an expired value key are both physically gone after a round; a non-expired entry survives.
  • TestKeyIndex:testExpiredSlotsDoNotAccumulate — repeated expire/re-add churn leaves at most one index slot in the dict.
  • TestPrometheus:testExpiredMetricIsReclaimedFromDict — same, driven through the public counter API.

Two supporting changes to the test harness:

  • SimpleDict:flush_expired() added, matching the real semantics (scans everything, removes only expired entries, returns the count; the optional argument caps removals, not scans).
  • SimpleDict:ttl() no longer physically prunes the entry it reads. The real ttl reports an expired entry as not found but leaves it allocated; the mock's pruning would have made the reclamation assertions vacuous, since remove_expired_keys() calls ttl on exactly the slots under test.

The assertions read dict.dict directly rather than going through get(), since get() prunes as well — physical presence is the whole point here and cannot be observed through the dict API by design.

CI

prometheus_test.lua was never executed by CI, only linted — luaunit was installed and unused, so the tests added in #14 and #16 have never actually gated anything. Added a step to run it, which is what makes the above regression tests meaningful.

Notes

remove_expired_keys() only cleared the worker-local keys/index/expire_keys
tables and issued no writes to the shared dict, so expired entries -- both
the __ngx_prom__key_N index slots and the metric value keys -- stayed
physically allocated.

Nothing else reclaimed them. Index slots are never reused: when a metric's
slot has expired, add() allocates a fresh slot at key_count+1, so under
label churn with an exptime the slots grow without bound. And the passive
per-write expiry scan cannot help, because it stops at the first
non-expired entry at the LRU tail, where a permanent entry (the error
metric, or any metric registered without an exptime) inevitably ends up
sitting. The result is prometheus-metrics free_space stepping down on
every new series and never recovering, until the dict is full and starts
force-evicting live metrics.

Flush the expired entries once per timer round. Expired entries are
indistinguishable from absent ones through every shared-dict API, so
reclaiming them cannot change any observable behaviour; only free_space
and eviction pressure change.

Also run prometheus_test.lua in CI -- it was never executed, only linted.

Fixes apache/apisix#13658
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9555cc1f-9218-4367-8b51-e5cc75a3c6b3

📥 Commits

Reviewing files that changed from the base of the PR and between 613577e and 4bf2ede.

📒 Files selected for processing (3)
  • .github/workflows/tests.yml
  • prometheus_keys.lua
  • prometheus_test.lua
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/flush-expired-shm-reclaim

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

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.

1 participant