Skip to content

fix(keys): re-claim the expired slot in place instead of allocating a new one - #22

Open
AlinsRan wants to merge 1 commit into
mainfrom
fix/keys-reclaim-slot-in-place
Open

AlinsRan wants to merge 1 commit into
mainfrom
fix/keys-reclaim-slot-in-place

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Sep 21, 2026

Copy link
Copy Markdown

Problem

KeyIndex:add() handles a key whose index slot was reclaimed from the shared dict by allocating a new slot number and bumping delete_count, so that every other worker drops the stale slot on its next sync(). Both are avoidable — the old slot number is free, so the key can simply take it back.

Keeping the slot number removes the condition the duplicate-metric fix in #14 was written for (a key can no longer occupy two slots) and avoids three costs that turn out to matter a great deal in production:

1. key_count grows on expiry churn, permanently. It only ever increases, and sync_range(0, key_count) walks every slot it ever handed out — dead ones included. Every re-allocation makes every later full sync more expensive, for the lifetime of the dict.

2. delete_count pushes every worker into a full sync from its request path. sync() is called from add(), so a bump means each worker performs sync_range(0, key_count) — that many shared-dict reads, all contending for the same shm mutex. With several workers on one dict this is a spin-lock convoy: ngx_shmtx_lock dominates CPU while almost no useful work happens.

3. That full sync is itself what makes key_count explode. sync_range clears index[key] for every slot whose node has ttl-expired but not yet been reclaimed — dict:get returns nil for those. Those keys then have no slot on their next add() and are re-allocated too. One bump costs far more than one slot:

combinations ttl-expired at the moment of the bump key_count growth delete_count growth
1,000 +1,000 +1
5,000 +5,000 +1
20,000 +20,000 +1

Control (no bump, everything else identical): key_count +2.

Because the growth feeds the cost of the next sync, this is self-reinforcing: the more a dict is bumped the more expensive each bump becomes, so a process can diverge from an identically configured peer with no difference in load.

Fix

Re-claim the same slot number:

local ok2, err2, forcible2 = self.dict:add(self.key_prefix .. idx, key, exptime)
if ok2 or (err2 == "exists" and self.dict:get(self.key_prefix .. idx) == key) then

expire() only reports "not found" once the node is physically gone. A node that has merely ttl-expired is resurrected by expire() and keeps its slot — so this branch is reached only after flush_expired() (#18) or an LRU eviction has removed it, and the slot is genuinely free.

Put differently: for a live slot and for a ttl-expired-but-present slot the library already reuses the slot in place. This change extends that to the third state rather than introducing a new strategy.

slot state dict:get dict:expire before after
live value true reuse in place unchanged
ttl-expired, node present nil true (resurrects) reuse in place unchanged
reclaimed, node gone nil "not found" new slot + bump reuse in place

Races

dict:add() is atomic. A peer that re-claims the slot first makes it return "exists"; the value is compared against the key before the slot is accepted. If the slot cannot be re-claimed at all — out of memory, or it holds a different key because key_count was evicted and restarted — the previous behaviour of allocating a new slot still applies, and list() keeps reporting the key once from the slot index points at.

The forcible flag from the re-claiming add() is surfaced through err_msg_lru_eviction, as the new-slot path already does.

The canonical-slot check in list() from #14 is kept: it costs nothing and still covers the fallback path and mixed-version rolling upgrades.

Measurements

OpenResty 1.29.2.4, 10 workers on one 512m dict, 144k slots pre-loaded, a 4000-combination rotation expiring and returning, flush_expired() once a second, 20s:

total CPU peak key_count growth delete_count duplicate series
v1.0.0 421% 970% +1,368 268 0
this PR 13% 40% +0 0 0

Concurrency and correctness, same setup at 1500 rps over a 3000-combination rotation, 25s, repeated with the dict sized 512m / 4m / 1m (the last one runs with 0 bytes free, so the LRU is evicting throughout):

dict duplicate series key_count delete_count local self.keys of which stale
512m 0 5,001 0 5,001 0
4m 0 5,001 0 5,001 0
1m (full) 0 5,001 0 5,001 0

5,001 is the rotation size plus the error metric, i.e. no growth at all. The peer-won branch (add() returning "exists" with a matching value) was exercised 1–5 times per run, and the fallback branch never was, including with the dict full.

For comparison, v1.0.0 under the 512m run reaches key_count 12,936 with delete_count 795.

Removing only the bump and keeping the new-slot allocation was also measured: it does stop the convoy, but key_count still grows (13,553 vs 14,221) and the stale entries in each worker's self.keys are never collected — 9,712 of 12,713 entries were dead after 25s, which list() walks on every scrape. Re-claiming the slot avoids both.

Tests

testExpiredReAddNoDuplicate now asserts the slot is re-claimed, key_count does not grow, delete_count stays unset, and the second worker remains correct without being told anything — list() still reports the key exactly once, which is what the test is for.

testExpiredReAddFallbackNoDuplicate is added for the fallback path: the slot is stolen between expire() reporting it gone and add() trying to re-claim it, which is the only way another key can end up on that slot number. Both workers must still list the key once.

lua prometheus_test.lua: 46 tests, 45 pass. TestPrometheus.testPrintfTable fails identically on unmodified main under LuaJIT (trailing-nil vararg handling differs from the Lua 5.2 CI runs). luacheck is clean on all four files.

Relationship to #20

#20 compacts the index so dead slots do not accumulate. It stays useful — slots freed by an explicit remove(), and any index that has already grown, still need reclaiming. This change removes the largest source of dead slots rather than collecting them afterwards, so the two are complementary.

Summary by CodeRabbit

  • Bug Fixes

    • Improved recovery when renewing an expired key encounters a “not found” error.
    • Existing key slots are now reclaimed when possible, preserving consistent key tracking across workers.
    • Added fallback handling to allocate a new slot when reclamation is unavailable.
    • Prevented duplicate key listings and unnecessary key-count changes during recovery.
  • Tests

    • Expanded coverage for successful slot reclamation and fallback allocation scenarios.

… new one

When a key's index slot is reclaimed from the shared dict, add() allocated a
new slot number and bumped delete_count so that every other worker would drop
the stale slot on its next sync(). Both are avoidable: the slot number is free,
so the key can simply take it back.

Re-claiming in place keeps the key's identity, which removes the condition the
duplicate-metric fix was written for -- a key can no longer occupy two slots --
and has three further effects:

  * key_count stops growing on expiry churn. It only ever grows, and
    sync_range(0, key_count) walks every slot it ever handed out, so each
    re-allocation makes every later full sync more expensive, permanently.

  * delete_count stops moving, so no worker is pushed into a full
    sync_range(0, key_count) from its request path. With several workers on one
    dict that scan is O(key_count) shared-dict reads each, all contending for
    the same shm mutex.

  * that full sync is itself what wiped index[key] for every slot whose node had
    ttl-expired but not yet been reclaimed, so those keys were re-allocated too.
    One bump therefore cost far more than one slot.

expire() only reports "not found" once the node is physically gone. A node that
has merely ttl-expired is resurrected by expire() and keeps its slot, so this
branch is reached only after flush_expired() (or an LRU eviction) has removed
it, and the slot is genuinely free.

Races are handled by relying on dict:add() being atomic. A peer that re-claims
the slot first makes add() return "exists"; the value is then compared against
the key before the slot is accepted. If it cannot be re-claimed at all -- out of
memory, or the slot holds a different key because key_count was evicted and
restarted -- the old behaviour of allocating a new slot still applies, and
list() keeps reporting the key once from the slot index points at.

Measured on OpenResty with 10 workers sharing a 512m dict, 144k slots, a 4000
combination rotation expiring and returning, and flush_expired() once a second:

                        total CPU      key_count       delete_count   duplicates
  before                421% (peak 970%)  +1368             268            0
  after                  13% (peak  40%)     +0               0            0

testExpiredReAddNoDuplicate now asserts the slot is re-claimed, key_count does
not grow and delete_count stays unset, and that the second worker stays correct
without being told anything. testExpiredReAddFallbackNoDuplicate covers the
fallback path, where the slot is stolen between expire() and add().
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Essentials

Run ID: 6d4fcbec-4a9c-4414-9c19-1ef2ac17cbcd

📥 Commits

Reviewing files that changed from the base of the PR and between bc04f9d and df6e307.

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

Included review availability: 4 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.


📝 Walkthrough

Walkthrough

The change updates expired-key re-add handling. KeyIndex:add reclaims the existing slot when possible and allocates a new slot when necessary. Tests cover both paths across multiple workers.

Changes

Expired key re-add

Layer / File(s) Summary
Slot reclamation logic
prometheus_keys.lua
KeyIndex:add reclaims the existing slot after an expired key returns "not found". It preserves the index and expiration state when reclamation succeeds. It clears stale state and allocates a new slot when reclamation fails.
Reclamation regression coverage
prometheus_test.lua
Tests verify in-place re-add without changes to key_count or delete_count. Additional coverage verifies fallback allocation to slot 2 and deduplicated listings across workers.

Priority: ➖ Normal

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to df6e3

The fallback path keeps worker listings synchronized without requiring a delete-count update, so no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Blocking issue: the changed regression tests are component-level tests only. They call KeyIndex:add() and list() with the in-file SimpleDict fake. They do not exercise the public Prometheus flow… Add an end-to-end test that runs Nginx/OpenResty with a real shared dictionary. Register an expiring metric through the public API, record it, let the slot expire, record it again, and collect metrics from multiple workers. Assert that the …
✅ 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 and concisely describes the main change: reclaiming expired key slots in place instead of allocating new 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 No security finding was introduced by this pull request. The authoritative diff changes only Prometheus metric-key slot reclamation and related tests. The key values are metric names and label combi…
Full details: E2e Test Quality Review

Explanation

Blocking issue: the changed regression tests are component-level tests only. They call KeyIndex:add() and list() with the in-file SimpleDict fake. They do not exercise the public Prometheus flow (counter:inc()/metric_data() or collect()) with a real Nginx/OpenResty shared dictionary. The repository's real-service integration test uses non-expiring metrics, so it does not cover this changed path.

Resolution

Add an end-to-end test that runs Nginx/OpenResty with a real shared dictionary. Register an expiring metric through the public API, record it, let the slot expire, record it again, and collect metrics from multiple workers. Assert that the metric is present exactly once after reclamation. Keep the existing unit tests for slot identity and fallback behavior, and add a real concurrent-worker case for the reclamation race if possible.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Warning

⚠️ This pull request shows signs of AI-generated slop (redundant_comments). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.


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

@AlinsRan AlinsRan self-assigned this Sep 22, 2026
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.

2 participants