Repository navigation
Description
Redis version
7.0 (Alibaba Cloud ApsaraDB for Redis, single server)
Redisson version
3.52.0
Redisson configuration
singleServerConfig:
address: "redis://..."
timeout: 3000
connectTimeout: 10000Default 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