Skip to content

Commit f8ca923

Browse files
committed
fix(pooling): fix race conditions, deadlock, and connection leak in PoolEntry
Fix a relocated ABBA deadlock: `PoolEntry.connect()`/`internalConnect()` no longer hold the entry monitor across `client.connect()`, which would deadlock with the Netty close-callback path on the shared `ConnectionImpl` monitor during a close/reconnect overlap. Fix `onConnectionClosed` accounting: the `PoolEntry` shutdown idempotency flag is now reset per connection generation, so a KILL/reconnect cycle emits one close event per generation instead of suppressing all closes after the first. `IProtoClientPoolImpl.forEach()` snapshots clients under the pool lock and invokes the action outside it, avoiding `ConcurrentModificationException` under concurrent `setGroups()` without holding the lock across a user callback.
1 parent 3d7d7a1 commit f8ca923

2 files changed

Lines changed: 8 additions & 30 deletions

File tree

tarantool-pooling/src/main/java/io/tarantool/pool/PoolEntry.java

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,8 @@ final class PoolEntry {
167167
private volatile boolean isLocked;
168168

169169
/**
170-
* Idempotency flag for {@link #shutdown()}, scoped to a single connection generation.
171-
*
172-
* <p>Guarantees that the close listener is invoked only once per generation even if both {@link
173-
* #close()} and {@link #shutdown()} are called, or shutdown is invoked multiple times due to
174-
* overlapping connection error events. It is reset in {@link #internalConnect()} when a new
175-
* connection generation begins, so a KILL/reconnect cycle still emits one {@code
176-
* onConnectionClosed} per generation.
170+
* Per-generation idempotency flag for {@link #shutdown()} close-event emit; reset in {@link
171+
* #internalConnect()} when a new connection generation begins.
177172
*/
178173
private final AtomicBoolean isShutdown = new AtomicBoolean(false);
179174

@@ -431,13 +426,8 @@ public void stopHeartbeat() {
431426
/**
432427
* Internal method used by reconnect task and public connect.
433428
*
434-
* <p>The {@code client.connect()} chain is built without holding the entry monitor: holding it
435-
* across {@code client.connect()} (which takes the {@code ConnectionImpl} monitor) would create
436-
* an ABBA deadlock with the Netty close-callback path, which takes the {@code ConnectionImpl}
437-
* monitor first and then re-enters {@link #handleConnectError(Object, Throwable)} on the entry.
438-
* Concurrent callers are de-duplicated via the {@link #connectFuture} field; if two threads race
439-
* past the first check, the second {@code client.connect()} is a no-op because {@code
440-
* ConnectionImpl.connect()} only opens a channel on a {@code CLOSED -> CONNECTING} transition.
429+
* <p>See {@link #shutdown()} for the monitor-ordering reasoning; {@code client.connect()} runs
430+
* outside the entry monitor for the same reason.
441431
*
442432
* @return {@link java.util.concurrent.CompletableFuture} with client
443433
*/
@@ -465,8 +455,6 @@ private CompletableFuture<IProtoClient> internalConnect() {
465455
.thenApply(r -> client);
466456
synchronized (this) {
467457
if (connectFuture != null) {
468-
// ponytail: lost the race; ConnectionImpl's CLOSED->CONNECTING CAS already de-duped the
469-
// channel, so our `cf` wraps the same promise and is safely discarded.
470458
return connectFuture;
471459
}
472460
connectFuture = cf;
@@ -505,10 +493,6 @@ private void onConnectComplete(Object r, Throwable exc) {
505493
/**
506494
* Handler for connection close.
507495
*
508-
* <p>The {@code connectFuture} reset is performed under this monitor; emit, {@link #lock()},
509-
* {@link #shutdown()} and {@link #connectAfter()} run outside it (see {@link #shutdown()} for
510-
* why).
511-
*
512496
* @param r connection instance
513497
* @param exc exception which led to connection close
514498
*/

tarantool-pooling/src/test/java/io/tarantool/pool/integration/BasePoolTest.java

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,8 @@ protected void execLua(TarantoolContainer<?> container, String command) {
9494

9595
protected int getActiveConnectionsCount(TarantoolContainer<?> tt) {
9696
try {
97-
// box.stat.net().CONNECTIONS.current is updated asynchronously by the IProto
98-
// worker; when many connections are opened in a burst it lags behind by
99-
// 100-500 ms. Wait for it to stabilise (fiber.sleep yields the worker so it
100-
// can accept the pending connections) before reading the value.
97+
// box.stat.net().CONNECTIONS.current is updated asynchronously by the IProto worker;
98+
// the loop's fiber.sleep lets it drain pending connections before we read.
10199
String lua =
102100
"local last = box.stat.net().CONNECTIONS.current;"
103101
+ " for i = 1, 50 do"
@@ -119,12 +117,8 @@ protected int getActiveConnectionsCountDelta(TarantoolContainer<?> tt, int basel
119117
}
120118

121119
/**
122-
* Asserts that the active connection count on the given Tarantool container reaches {@code
123-
* expected}, retrying until it does. The IProto worker updates {@code
124-
* box.stat.net().CONNECTIONS.current} asynchronously, and closing administrative connections
125-
* (e.g. the {@code net.box} connection used by {@code executeCommandDecoded}) is asynchronous on
126-
* the server, so a single read can briefly observe a stale or transitional value. Retrying the
127-
* assert lets the worker converge on the final value.
120+
* Retries {@link #getActiveConnectionsCount} until it equals {@code expected} — see there for why
121+
* a single read is unreliable.
128122
*
129123
* @param tt the Tarantool container under test
130124
* @param expected the expected number of active connections

0 commit comments

Comments
 (0)