spring-projects / spring-projects/spring-data-redis
Pub/Sub in a concurrent scenario may result in the inability to use subscriptions anymore, which can ultimately lead to memory leaks
Open
@mp911de is already working on this.
Since Jan 13, 2025.
status: waiting-for-triage
- Dominant language
- Java
- Stars
- 1.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
Problem Description
- In certain concurrent scenarios, we have observed that the pub/sub mechanism can result in subscription failures and subsequently lead to memory leaks.#2425
Code Execution Process
addListener Method
- Initially, the listener is added to the
listenerTopicscollection. - Subsequently, the
subscribeChannelmethod is invoked to establish the subscription.
org.springframework.data.redis.connection.util.AbstractSubscription#subscribe Method
- The method checks if the
aliveflag is set to true. If it is not, aRedisInvalidSubscriptionExceptionis thrown.
removeMessageListener Method
- The listener is first removed from the
listenerTopicscollection. - Next, it is verified whether
listenerTopicsis empty. If so, thestopListeningmethod is called. - Following this, the channel is removed from the subscription's channels, and the
closeIfUnsubscribedmethod is invoked.
org.springframework.data.redis.connection.util.AbstractSubscription#closeIfUnsubscribed Method
- The method checks if the channels collection is empty. If it is, the
aliveflag is set to false usingalive.compareAndSet(true, false).
Bug Analysis
- Steps 2 and 3 of the
removeMessageListenermethod introduce a potential race condition. During the interval between these steps, it is possible for step 2 to detect thatlistenerTopicsis not empty while step 3 detects that the channel is empty (resulting in settingaliveto false). - Consequently, any subsequent calls to
addListenerwill fail because the subscription process is deemed unsuccessful. As a result, applications typically do not invokeremoveMessageListeneragain, leading to the listener remaining in thelistenerTopicscollection indefinitely.
Reproduction
- I made some changes to the code to make the issue easier to reproduce.
Modify Code (RedisMessageListenerContainer)
- The first invocation of
addListenerexecutes successfully and promptly. - The second invocation of
addListenerexperiences a delay between Step 1 and Step 2. Execution resumes after the first call toremoveMessageListener.
public class RedisMessageListenerContainer implements InitializingBean, DisposableBean, BeanNameAware, SmartLifecycle {
// ==============add code start
private static final AtomicInteger counter = new AtomicInteger(0);
private static final AtomicBoolean firstListenerHasRemoved = new AtomicBoolean(false);
// ==============add code end
// ==============add code start
private static final AtomicInteger counter = new AtomicInteger(0);
private static final AtomicBoolean firstListenerHasRemoved = new AtomicBoolean(false);
private void addListener(MessageListener listener, Collection<? extends Topic> topics) {
// ....
for (Topic topic : topics) {
// ...
}
// ============== add code start
boolean wasListening = isListening();
counter.incrementAndGet();
while (counter.get() == 2 && !firstListenerHasRemoved.get()) { }
// ============== add code end
if (isRunning()) {
lazyListen();
// .....
}
}
Add test case to RedisMessageListenerContainerIntegrationTests
@ParameterizedRedisTest
void subscribeErrorAfterStart() throws Exception {
CompositeListener listener1 = (message, pattern) -> { };
CompositeListener listener2 = (message, pattern) -> { };
container.start();
// the first subscribe will succeed
container.addMessageListener(listener1, new ChannelTopic("a"));
CompletableFuture<Exception> subscribeExceptionFuture = new CompletableFuture<>();
new Thread(() -> {
try {
container.addMessageListener(listener2, new ChannelTopic("b"));
subscribeExceptionFuture.complete(null);
} catch (Exception e) {
subscribeExceptionFuture.complete(e);
}
}).start();
new Thread(() -> {
container.removeMessageListener(listener1);
}).start();
// the second subscribe will fail (not expected)
subscribeExceptionFuture.get();
CompositeListener listener3 = (message, pattern) -> { };
// after the exception, we should still be able to subscribe to channels
CompletableFuture<Exception> otherSubscribeExceptionFuture = new CompletableFuture<>();
new Thread(() -> {
try {
container.addMessageListener(listener3, new ChannelTopic("c"));
otherSubscribeExceptionFuture.complete(null);
} catch (Exception e) {
otherSubscribeExceptionFuture.complete(e);
}
}).start();
assertThat(otherSubscribeExceptionFuture.get()).isNull();
container.destroy();
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.