Skip to content

RedissonRateLimiter.release() causes availablePermits() to return a value greater than the rate. #7329

Description

@likerhythm

Redis version

7.4.10

Redisson version

4.6.1

Redisson configuration

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonConfig {

    public RedissonClient create() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

What is the Expected behavior?

Even after calling release(), RedissonRateLimiter.availablePermits() should return the number of currently available permits, and this value must never exceed the configured rate.

What is the Actual behavior?

When release() is called, availablePermits() sometimes returns a value greater than the configured rate.

Additional information

availablePermits() increases currentValue by the number of expired permits stored in the sorted set and returns the result. release(), on the other hand, increments currentValue without updating the sorted set. Because of this, when availablePermits() is called in an environment that also uses release(), the already-expired permits get counted into currentValue a second time. On top of that, since availablePermits() never re-caps currentValue against the configured rate, the returned value can end up larger than the rate.

@Override
public RFuture<Long> availablePermitsAsync() {
    return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
            "local rate = redis.call('hget', KEYS[1], 'rate');"
          + "local interval = redis.call('hget', KEYS[1], 'interval');"
          + "local type = redis.call('hget', KEYS[1], 'type');"
          + "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')"

          + "local valueName = KEYS[2];"
          + "local permitsName = KEYS[4];"
          + "if type == '1' then "
              + "valueName = KEYS[3];"
              + "permitsName = KEYS[5];"
          + "end;"

          + "local currentValue = redis.call('get', valueName); "
          + "if currentValue == false then "
                 + "redis.call('set', valueName, rate); "
                 + "return rate; "
          + "else "
                 + "local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[1]) - interval); "
                 + "local released = 0; "
                 + "for i, v in ipairs(expiredValues) do "
                      + "local random, permits = struct.unpack('Bc0I', v);"
                      + "released = released + permits;"
                 + "end; "

                 + "if released > 0 then "
                      + "redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[1]) - interval); "
                    // Double-counting: permits already returned by release() are added again here, and unlike release() this has no clamp to rate.
                      + "currentValue = tonumber(currentValue) + released; "
                      + "redis.call('set', valueName, currentValue);"
                 + "end;"

                 + "return currentValue; "
          + "end;",
            Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()),
            System.currentTimeMillis());
}
@Override
public RFuture<Void> releaseAsync(long permits) {
    if (permits < 0) {
        throw new IllegalArgumentException("Permits amount can't be negative");
    }
    if (permits == 0) {
        return CompletableFutureWrapper.completedNull();
    }
    return commandExecutor.evalWriteAsync(getRawName(), StringCodec.INSTANCE, RedisCommands.EVAL_VOID,
            "local rate = redis.call('hget', KEYS[1], 'rate');"
            + "local interval = redis.call('hget', KEYS[1], 'interval');"
            + "local type = redis.call('hget', KEYS[1], 'type');"
            + "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized');"

            + "local valueName = KEYS[2];"
            + "local permitsName = KEYS[4];"
            + "if type == '1' then "
            + "    valueName = KEYS[3];"
            + "    permitsName = KEYS[5];"
            + "end;"

            + "local currentValue = redis.call('get', valueName);"
            + "if currentValue == false then "
            + "    currentValue = tonumber(rate);"
            + "else "
            + "    currentValue = tonumber(currentValue);"
            + "end;"

            + "local newValue = currentValue + tonumber(ARGV[1]);"
            + "if newValue > tonumber(rate) then "
            + "    newValue = tonumber(rate);"
            + "end;"
            + "redis.call('set', valueName, newValue);"
            // the acquired permit's entry in permitsName is not removed here, so availablePermits() later counts it a second time.

            + "local keepAliveTime = redis.call('hget', KEYS[1], 'keepAliveTime');"
            + "if (keepAliveTime ~= false and tonumber(keepAliveTime) > 0) then "
            + "    redis.call('pexpire', KEYS[1], keepAliveTime);"
            + "    redis.call('pexpire', valueName, keepAliveTime);"
            + "    redis.call('pexpire', permitsName, keepAliveTime);"
            + "else "
            + "    local ttl = redis.call('pttl', KEYS[1]);"
            + "    if ttl > 0 then "
            + "        redis.call('pexpire', valueName, ttl);"
            + "        redis.call('pexpire', permitsName, ttl);"
            + "    end;"
            + "end;",
            Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()), permits);
}

I reproduced this through an actual experiment.

Experiment conditions

  • rate = 10
  • interval = 1000ms

Under these conditions, I called tryAcquire(1) and then ran two separate experiments depending on whether release(1) was called afterward. The first experiment calls release(1): it assumes a scenario where a permit is acquired, and 100ms later the code realizes the permit is no longer needed and calls release(1). The experiment ran for 30 seconds, and the graph below shows the number of available permits along with the permit-consumption history currently stored in the sorted set, queried each time release(1) was called.

Image

And the graph below shows the result when release(1) is not called, letting the permits recover automatically via the interval instead.

Image

Comparing the two experiments, when relying solely on the interval-based automatic recovery without release(1) (the second graph), availablePermits() never exceeds rate=10. However, when release(1) is used (the first graph), it returns 11–12, exceeding the rate.

Below is the code used for the experiment.

public void run() throws InterruptedException {
    RateLimiterConfig config = new RateLimiterConfig();
    RRateLimiter limiter = config.create();
    System.out.println("init permits = " + limiter.availablePermits());

    long startNano = System.nanoTime();
    long endAt = System.nanoTime() + SimulationConfig.DURATION_SEC * 1_000_000_000L;
    long count = 0;

    List<long[]> samples = new ArrayList<>();

    while (System.nanoTime() < endAt) {
        limiter.tryAcquire(1);
        Thread.sleep(SimulationConfig.RELEASE_DELAY_MS);
        limiter.release(1);
        long permits = limiter.availablePermits();
        long tMs = (System.nanoTime() - startNano) / 1_000_000;
        long history = config.historySize();
        samples.add(new long[]{tMs, permits, history});
        count++;
    }

    writeJson(samples);
}

The fundamental fix would be to remove the permit's acquisition record from the sorted set when release() is called, but this requires identifying which acquisition is being returned. Since the current API only takes the number of permits to release and doesn't identify the target, it would require an API change — returning an ID on acquisition and passing that ID back on release.

An approach that avoids changing the API would be to have release() remove the oldest acquisition records first, without identifying specific ones. However, this approach has a problem: the permits the user intended to release and the ones actually removed can diverge, delaying the recovery of available capacity. For example, with interval=1000ms and rate=100, suppose 49 permits (A) are acquired at 0ms and 51 permits (B) at 999ms, and then 51 are released immediately: oldest-first removal wipes out A first, leaving 49 of B's permits in the sorted set. Those 49 don't expire until 1999ms, so even though the permits were just released, capacity equal to the rate isn't restored for roughly 2 seconds.

Is this behavior — where availablePermits() returns a value greater than the rate — intended? I can't come up with a way to solve this. So I'd like to hear the maintainers' thoughts on this issue and their judgment on which direction would be the appropriate solution.

If you're curious about the code used for the experiment, please refer to here.
https://github.com/likerhythm/mylab/tree/master/p01-redisson-ratelimiter-permitoverflow/src/main/java/org/example/p01redissonratelimiterpermitoverflow/nonspring_test

Thank you for your time and for maintaining this project.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions