Conversation
KeyIndex never reuses slots: a key that expires or is removed leaves its slot number behind and key_count only grows. Every full sync walks 0..key_count synchronously, and full syncs happen whenever a worker starts and in every worker after any delete_count bump, which includes re-adding an expired key. With label churn and an exptime the cost grows without bound. remove_expired_keys() now compacts the index once key_count reaches 10000 slots and at least twice the live keys. Under a shared-dict lock it copies the live keys, with their remaining ttl, into a new generation of slots and switches the generation. It then reconciles with what other workers did to the old generation before they switched: renewed expiries are carried over, removed keys are dropped and slots written past the copied range are registered. Finally the old slots are deleted. Workers switch on their next sync(); add() re-registers a key whose slot landed in the old generation after the switch. Generation 0 keeps the existing key names. Compaction yields on a sleep timer, because a posted ngx.sleep(0) event does not keep an exiting worker alive. It is not started by the premature timer run or an exiting worker, is abandoned before the switch when the worker starts exiting, and completes without yielding after the switch.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: Comment |
|
bwang seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
added 2 commits
September 15, 2026 10:21
- Reconcile the copy by comparing each slot's ttl with the one read while copying - Never shorten a new-generation slot, and keep it when only it was renewed - Drop keys removed from the new generation instead of re-adding them - Add regression tests for renewals and removals after the switch
- Re-check the generation after renewing an existing slot and renew the key again in the current generation when a compaction switched it meanwhile - Treat only a higher generation as a switch, so an evicted gen key does not make add() loop - Bound the generation switches add() follows per key
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
KeyIndexnever reuses slots. A key that expires or is removed leaves its slot number behind, sokey_countonly grows. Every full sync walks0..key_countsynchronously, and full syncs happen:delete_countbump, which includesadd()re-registering a key whose slot expired.With label churn and an
exptime,key_counttherefore grows for the lifetime of the shared dict, and so does the cost of starting a worker or of any delete. #18 reclaims the memory of expired entries, but not the slot numbers.Fix
remove_expired_keys()compacts the index oncekey_countreaches 10000 slots and at least twice the live keys:dict:addwith a ttl, refreshed after every batch);<prefix><gen>_key_N), yielding between batches;<prefix>gen);key_countgets a ttl rather than a delete.Other workers switch on their next
sync(). The copy is complete before the switch, so no worker lists a partial index.add()checks the generation after writing a new slot and re-registers the key if the slot landed in the old generation after the switch. Whenadd()renews an existing key and the generation has switched since its sync, it renews the key again in the current generation, since the reconciliation may already have run. Only a higher generation counts as a switch, andadd()gives up on a key after following 3 switches.remove()now syncs first, so it acts on the current generation.Worker exit:
ngx.sleep(0)is a posted event and does not keep an exiting worker alive, so a compaction yielding that way during a reload would be abandoned with its lock held. Compaction therefore yields withngx.sleep(0.001), is not started by the premature timer run or by an exiting worker, gives up before the switch if the worker starts exiting, and finishes without yielding after the switch.Generation 0 keeps the existing key names, so workers running an older version share the index during a reload.
Hot path cost:
sync()reads one more dict key, andadd()reads one more key when it allocates a new slot.Tests
Unit tests (
prometheus_test.lua) cover: dead slots dropped with ttl preserved, no compaction below the threshold or when most slots are live, lock held by another worker, other workers following the switch,remove()from a worker that has not synced since the switch, a slot written pastkey_count, a key removed or renewed during the copy,add()racing the switch, a renewal racing the switch, repeated switches duringadd(), leftovers from an interrupted compaction, an evicted generation node, the premature timer run, a worker exiting before or during the copy, and unchangedmetric_data()output. Each fix point was checked by disabling it and confirming the corresponding test fails.Stress test on OpenResty 1.27.1.2 with 4 workers: 40000 requests, each registering 5 counter and 5 histogram series with a 1 s exptime (30 keys) plus one of 50 long-lived series;
remove_expired_keys_interval = 1,compact_min_slotslowered to 300.key_countKeyIndex(live keys: 52)The same run with
nginx -s reloadafter 20 s:key_countKeyIndexThe reload loses the same 4 requests in both versions (unsynced per-worker counters of the old workers).
Notes
forcible, the compaction aborts, deletes what it wrote and logs an error.