Skip to content

IndexOutOfBoundsException in ContainsDecoder.decode from lock watchdog renewal (mutable args list aliased) #7402

Open

Description

@dengwn1999

Redis version

7.0 (Alibaba Cloud ApsaraDB for Redis, single server)

Redisson version

3.52.0

Redisson configuration

singleServerConfig:
  address: "redis://..."
  timeout: 3000
  connectTimeout: 10000

Default lockWatchdogBatchSize (100). Locks acquired with tryLock(waitTime, -1, unit), i.e. watchdog mode.

What is the Expected behavior?

No IndexOutOfBoundsException; watchdog renewal keeps renewing held locks.

What is the Actual behavior?

Under load we sporadically get:

WARN i.n.u.HashedWheelTimer : An exception was thrown by TimerTask.
java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1
    at java.base/java.util.ArrayList.get(ArrayList.java:428)
    at org.redisson.client.protocol.decoder.ContainsDecoder.decode(ContainsDecoder.java:50)
    at org.redisson.client.handler.CommandDecoder.decodeList(...)
    at org.redisson.client.handler.CommandDecoder.decodeCommandBatch(...)
    at org.redisson.renewal.LockTask.renew(LockTask.java:78)
    at org.redisson.renewal.RenewalTask.execute(RenewalTask.java:83)
    at org.redisson.renewal.RenewalTask.run(RenewalTask.java:170)

This is not the same defect as #6506 / #6519. Those were about keys.get(0) being called when keys was empty, fixed in 3.46.1 by the if (keys.isEmpty()) return ... guard — that guard is present in 3.52.0 and we still hit this.

Observed correlation in our logs, which pinpoints the mechanism:

KEYS sent in the EVAL length in the exception
2 0
4 1
3 2
1 0

The reported length is the size of the local args list, not the reply size, and it is always smaller than the number of keys sent. The reply itself is intact — the Lua script returns exactly one element per key.

Root cause: ContainsDecoder aliases the caller's list instead of copying it.

public ContainsDecoder(Collection<T> args) {
    if (args instanceof List) {
        this.args = (List<T>) args;      // no copy
    } else {
        this.args = new ArrayList<>(args);
    }
}

while LockTask.renew() mutates that very same list after the command is dispatched:

new RedisCommand<>("EVAL", new ContainsDecoder<>(keys)),
...
return f.thenCompose(existingNames -> {
    keys.removeAll(existingNames);
    ...
});

When the reply is decoded after the callback already ran (delayed response, retry, connection switch), decode() walks parts of the original size but reads a shortened args.

The parts.isEmpty() guard in decode() cannot help: this path always sends at least one key, so parts is never empty.

Impact

The exception is thrown inside decoding, so the EVAL future never completes (CompletableFutureWrapper[Not completed, 1 dependents] in the logs). RenewalTask re-schedules only from whenComplete, so the renewal chain for that RedissonClient stalls until the command times out. Locks then expire under lockWatchdogTimeout while business logic is still running, silently breaking mutual exclusion. In the same window we also observed RedisResponseTimeoutException (3000 ms) on unrelated async listeners and several tryLock failures.

Suggested fix

Snapshot the collection in the constructor:

public ContainsDecoder(Collection<T> args) {
    this.args = new ArrayList<>(args);
}

I have a PR ready with this change plus a JUnit 5 regression suite (ContainsDecoderTest) that fails on 3.52.0 with the exact signatures above and passes with the fix.

Additional information

master still contains the aliasing version, so this is unresolved upstream.

Steps to reproduce deterministically

Force the mutate-before-decode ordering:

List<String> keys = new ArrayList<>(Arrays.asList("k1", "k2"));
ContainsDecoder<String> decoder = new ContainsDecoder<>(keys);
keys.clear();                                  // what the completion callback does
decoder.decode(Arrays.asList(1L, 1L), null);   // -> Index 0 out of bounds for length 0

Activity

  1. modified the milestone: 4.8.1 on Oct 27, 2026
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