Bug Report
LettuceReactiveRedisConnection.AsyncConnect leaks a pooled connection when the connection acquisition is cancelled (e.g. request timeout, client disconnect, subscription cancellation) before the connection has arrived from the LettuceConnectionProvider.
When this happens, the late-arriving connection is closed via it.closeAsync() but is never released back to the connection provider, so a pooled provider's internal accounting (objectCount / all queue in Lettuce's BoundedAsyncPool) still counts the connection as active. Over time this exhausts the pool and leads to PoolException / NoSuchElementException: Pool exhausted, even though the physical connections are already closed.
Current Behavior
In the AsyncConnect constructor:
this.connectionPublisher = defer.doOnNext(it -> {
if (isClosing(STATE.get(this))) {
it.closeAsync(); // closes the connection but never releases the pool slot
} else {
connection = it;
}
})
Race scenario:
- Subscriber subscribes to
getConnection(); AsyncConnect requests a connection from the pool. this.connection is still null.
- The subscription is cancelled, so
close() runs. Because this.connection is still null, connectionProvider.releaseAsync(...) is skipped and the state becomes CLOSED.
- The pool finally yields the connection.
doOnNext sees the closing state and calls it.closeAsync().
connectionProvider.releaseAsync(it) is never called, so the pool never reclaims the slot. Leak.
Expected Behavior
A connection that arrives after close() should be released back to the connection provider (connectionProvider.releaseAsync(it)), mirroring what close() itself does for an already-arrived connection. For non-pooled providers this is equivalent to closing (the default releaseAsync delegates to closeAsync()), and for pooled providers it correctly returns the connection to the pool.
Context
Originally reported against Lettuce as redis/lettuce#3609, but the root cause is in Spring Data Redis's AsyncConnect. The fix is a one-line change plus a regression test; PR to follow.
Bug Report
LettuceReactiveRedisConnection.AsyncConnectleaks a pooled connection when the connection acquisition is cancelled (e.g. request timeout, client disconnect, subscription cancellation) before the connection has arrived from theLettuceConnectionProvider.When this happens, the late-arriving connection is closed via
it.closeAsync()but is never released back to the connection provider, so a pooled provider's internal accounting (objectCount/allqueue in Lettuce'sBoundedAsyncPool) still counts the connection as active. Over time this exhausts the pool and leads toPoolException/NoSuchElementException: Pool exhausted, even though the physical connections are already closed.Current Behavior
In the
AsyncConnectconstructor:Race scenario:
getConnection();AsyncConnectrequests a connection from the pool.this.connectionis stillnull.close()runs. Becausethis.connectionis stillnull,connectionProvider.releaseAsync(...)is skipped and the state becomesCLOSED.doOnNextsees the closing state and callsit.closeAsync().connectionProvider.releaseAsync(it)is never called, so the pool never reclaims the slot. Leak.Expected Behavior
A connection that arrives after
close()should be released back to the connection provider (connectionProvider.releaseAsync(it)), mirroring whatclose()itself does for an already-arrived connection. For non-pooled providers this is equivalent to closing (the defaultreleaseAsyncdelegates tocloseAsync()), and for pooled providers it correctly returns the connection to the pool.Context
Originally reported against Lettuce as redis/lettuce#3609, but the root cause is in Spring Data Redis's
AsyncConnect. The fix is a one-line change plus a regression test; PR to follow.