Skip to content

Fix RedissonRateLimiter discards permits returned by release() when it recalculates the currently available permits - #7343

Merged
mrniko merged 5 commits into
redisson:masterfrom
likerhythm:fix/ratelimiter-release-permits-discarded
Sep 16, 2026
Merged

mrniko merged 5 commits into
redisson:masterfrom
likerhythm:fix/ratelimiter-release-permits-discarded

Conversation

@likerhythm

Copy link
Copy Markdown
Contributor

Fixes #7342

Problem

RedissonRateLimiter.tryAcquireAsync() recalculates currentValue as rate - used whenever currentValue + released > rate. This discards permits that were returned by release().

Consider a RedissonRateLimiter with rate=10 and interval=1000ms in the following situation.

  1. Call tryAcquire(1) and release(1) nine times at 100ms intervals. Because release() does not touch the sorted set, currentValue is restored to 10 each time and ends at currentValue=10, while nine unexpired permit entries accumulate in the sorted set.
  2. At 1001ms, calling tryAcquire(1) expires the first permit, so released=1. Once that entry is removed, eight entries remain, giving used=8. Since currentValue(10) + released(1) > rate(10), the recalculation path runs and currentValue is reset to rate - used = 2.
  3. As a result, the permits returned by release(1) are lost, and the available permit count collapses from 10 to 2.

Root Cause

A permit returned by release() is immediately reflected in currentValue, but not in used. As a result, recalculating currentValue as rate - used overwrites and loses the permits that have already been released.

Fix

Skip the rate - used recalculation when used + currentValue already exceeds rate. That condition indicates currentValue has been inflated by release(), so skipping the recalculation preserves the returned permits.

Testing

Added a regression test, testReleasedPermitsPreservedAfterExpiry, that reproduces the case where permits returned by release() are lost through the rate - used recalculation on expiry. The test fails on the current master branch and passes with this change.

…it recalculates the currently available permits

When the number of expired permits plus the currently available permits exceeds the rate, `tryAcquireAsync()` recalculates the available permits as `rate - used`, where `used` is the number of permits still in use. This discards permits returned by `release()`: a released permit is immediately added back to the available permits, but it is never reflected in `used`, so the recalculation overwrites and loses it.

Skip the `rate - used` recalculation when `used + currentValue` already exceeds the rate, so that permits returned by `release()` are preserved instead of being overwritten.

Added a regression test that acquires a permit via `tryAcquire()` while `used + available` exceeds the rate and verifies that the available permits are not recalculated to `rate - used`. The test fails on the current master branch and passes with this change.

Signed-off-by: likerhythm <[email protected]>
+ "used = used + permits;"
+ "end; "
+ "currentValue = tonumber(rate) - used; "
+ "if used + tonumber(currentValue) <= tonumber(rate) then "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The cause should be fixed in releaseAsync() method

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for your answer. I agree that releaseAsync() is the root cause. I'll revert the change and try fixing releaseAsync() instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've updated releaseAsync() and reverted tryAcquireAsync(). The tests pass with these changes. Please review it again when you get a chance.

Revert the change in tryAcquireAsync().

The root cause was that releaseAsync() did not remove the released
permits from the zset. As a result, the leftover entries remained
in the zset and were reapplied when they expired, causing the
available permit count to become inconsistent.

releaseAsync() now removes permits from the zset by the released
amount, starting with the oldest entries.

Signed-off-by: likerhythm <[email protected]>

+ "local toRelease = tonumber(ARGV[1]);"
+ "local values = redis.call('zrange', permitsName, 0, -1, 'withscores');"
+ "for i = #values - 1, 1, -2 do "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the order is: newest first but it should be oldest first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I fixed the order and moved the zrem call out of the if-else. Could you take another look? Thanks!

+ " local score = values[i + 1];"
+ " local random, permits = struct.unpack('Bc0I', v);"
+ " if permits <= toRelease then "
+ " redis.call('zrem', permitsName, v);"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

that line can always be called

@mrniko mrniko added this to the 4.8.0 milestone Sep 16, 2026
@mrniko mrniko added the bug label Sep 16, 2026
@mrniko
mrniko merged commit 2de4843 into redisson:master Sep 16, 2026
4 checks passed
@mrniko

mrniko commented Sep 16, 2026

Copy link
Copy Markdown
Member

Thanks for contribution

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

RedissonRateLimiter discards permits returned by release() when it recalculates the currently available permits

2 participants