Skip to content

Commit 5d045cb

Browse files
committed
fix(pooling): fix race conditions, deadlock, and connection leak in PoolEntry
PoolEntry state transitions had several races between netty IO threads, the HashedWheelTimer heartbeat worker, and user-facing reconnect calls that surfaced as ABBA deadlocks, NPEs on inline connect failures, and leaked connections after a KILL/reconnect cycle in CI. PoolEntry: - Synchronize state mutations and mark connectFuture, heartbeatTask, reconnectTask, lastHeartbeatEvent, isLocked, and isShutdown volatile/AtomicBoolean for cross-thread visibility. - Narrow the entry-monitor critical sections to field mutations; call client.close() and emit() outside the monitor to break the ABBA deadlock between ConnectionImpl and PoolEntry monitors that hung DistributingRoundRobinBalancerTest. - Return the local connect future from internalConnect() so an inline connect failure cannot leave the caller observing a null connectFuture after handleConnectError() nulls it for reconnect. - Keep client.close() in shutdown() on every invocation (closeChannel is idempotent if already closed) but guard only the onConnectionClosed emit, so a KILL-then-reconnect cycle cannot leak the new connection when its auth/ping subsequently fails. - Serialize connectAfter() reconnect-task scheduling to avoid double scheduling; add a double-check of connectFuture in internalConnect() to return the in-flight future instead of starting a new connect. IProtoClientPoolImpl: - Synchronize forEach() on connectionPoolLock to avoid CME under concurrent setGroups(). Tests (BasePoolTest / ConnectionPoolReconnectsTest): - Wait for box.stat.net().CONNECTIONS.current to stabilise in getActiveConnectionsCount: the IProto worker updates it asynchronously, so a single read often lags by 5-15 connections when 20+ are opened in a burst. - Collapse the wait-for-stable Lua script to a single line so tarantool emits one YAML document (SnakeYAML rejects multi-document streams). - Wait for the active connection count to reach the expected value in ConnectionPoolReconnectsTest post-reconnect assertions via a new waitForActiveConnections() helper. Verified locally on 3.5.0 and 2.11.8: ConnectionPoolReconnectsTest, ConnectionPoolTest, ConnectionPoolHeartbeatTest, DistributingRoundRobinBalancerTest, and unit tests all pass consistently where they were previously flaky.
1 parent c76d799 commit 5d045cb

4 files changed

Lines changed: 115 additions & 32 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,9 +524,11 @@ public void setReconnectAfter(long reconnectAfter) throws IllegalArgumentExcepti
524524

525525
@Override
526526
public void forEach(Consumer<IProtoClient> action) {
527-
for (List<PoolEntry> group : entries.values()) {
528-
for (PoolEntry entry : group) {
529-
action.accept(entry.getClient());
527+
synchronized (connectionPoolLock) {
528+
for (List<PoolEntry> group : entries.values()) {
529+
for (PoolEntry entry : group) {
530+
action.accept(entry.getClient());
531+
}
530532
}
531533
}
532534
}

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

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.ArrayDeque;
99
import java.util.concurrent.CompletableFuture;
1010
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.atomic.AtomicBoolean;
1112
import java.util.concurrent.atomic.AtomicInteger;
1213
import java.util.function.BiFunction;
1314
import java.util.function.Consumer;
@@ -146,24 +147,33 @@ final class PoolEntry {
146147
private CompletableFuture<IProtoClient> connectFuture;
147148

148149
/** Last heartbeat state/event. */
149-
private HeartbeatEvent lastHeartbeatEvent;
150+
private volatile HeartbeatEvent lastHeartbeatEvent;
150151

151152
/** Heartbeat timer/task. */
152-
private Timeout heartbeatTask;
153+
private volatile Timeout heartbeatTask;
153154

154155
/** Reconnection task. */
155156
private Timeout reconnectTask;
156157

157158
/** Flag signaling if heartbeat started or not. */
158-
private boolean isHeartbeatStarted;
159+
private volatile boolean isHeartbeatStarted;
159160

160161
/**
161162
* Flag signaling if connection is available or not.
162163
*
163164
* <p>When connection comes to invalidated state or killed, pool entry is locked and connection
164165
* will not be returned to outer client.
165166
*/
166-
private boolean isLocked;
167+
private volatile boolean isLocked;
168+
169+
/**
170+
* Idempotency flag for {@link #shutdown()}.
171+
*
172+
* <p>Guarantees that the close listener is invoked only once even if both {@link #close()} and
173+
* {@link #shutdown()} are called, or shutdown is invoked multiple times due to overlapping
174+
* connection error events.
175+
*/
176+
private final AtomicBoolean isShutdown = new AtomicBoolean(false);
167177

168178
/** Count of failed pings occurred in invalidated state. */
169179
private int currentDeathPings;
@@ -305,7 +315,7 @@ public IProtoClient getClient() {
305315
*
306316
* <p>Also increments count of unavailable clients.
307317
*/
308-
public void lock() {
318+
public synchronized void lock() {
309319
if (!isLocked) {
310320
unavailable.incrementAndGet();
311321
isLocked = true;
@@ -317,7 +327,7 @@ public void lock() {
317327
*
318328
* <p>Also decrements count of unavailable clients and cancels reconnect task.
319329
*/
320-
public void unlock() {
330+
public synchronized void unlock() {
321331
if (isLocked) {
322332
stopReconnectTask();
323333
unavailable.decrementAndGet();
@@ -340,16 +350,28 @@ public void close() {
340350
shutdown();
341351
}
342352

343-
/** Closes client and stops heartbeat task is started. */
353+
/**
354+
* Closes the underlying client and stops the heartbeat task.
355+
*
356+
* <p>Performs field mutations under the entry monitor, then releases it before calling {@code
357+
* client.close()} (which acquires the {@code ConnectionImpl} monitor) and emitting the close
358+
* event. Holding the entry monitor across either of those calls would create an ABBA deadlock
359+
* with the Netty close-callback path, which takes the {@code ConnectionImpl} monitor first and
360+
* then re-enters {@link #handleConnectError(Object, Throwable)} on the entry.
361+
*/
344362
public void shutdown() {
345-
connectFuture = null;
346-
stopHeartbeat();
363+
synchronized (this) {
364+
connectFuture = null;
365+
stopHeartbeat();
366+
}
347367
try {
348368
client.close();
349369
} catch (Exception e) {
350370
log.warn("Cannot close client in pool", e);
351371
}
352-
emit(listener -> listener.onConnectionClosed(tag, index));
372+
if (isShutdown.compareAndSet(false, true)) {
373+
emit(listener -> listener.onConnectionClosed(tag, index));
374+
}
353375
}
354376

355377
/**
@@ -410,15 +432,27 @@ public void stopHeartbeat() {
410432
/**
411433
* Internal method used by reconnect task and public connect.
412434
*
435+
* <p>Returns the local chain {@code cf} rather than the {@link #connectFuture} field. If the
436+
* underlying {@code client.connect()} fails inline (e.g. connection refused), the {@code
437+
* whenComplete} callback fires reentrantly on the calling thread, which causes {@link
438+
* #handleConnectError(Object, Throwable)} to clear the field for reconnect purposes. The local
439+
* variable still references the (failed) future, so callers always receive a non-null result and
440+
* observe the failure via {@code ExecutionException} from {@code .get()}, while the field being
441+
* null guarantees that the next reconnect attempt actually reconnects instead of returning a
442+
* stale failed future.
443+
*
413444
* @return {@link java.util.concurrent.CompletableFuture} with client
414445
*/
415-
private CompletableFuture<IProtoClient> internalConnect() {
446+
private synchronized CompletableFuture<IProtoClient> internalConnect() {
447+
if (connectFuture != null) {
448+
return connectFuture;
449+
}
416450
log.info("connect {}/{}", tag, index);
417451
LongTaskTimer.Sample timer = startTimer(connectTime);
418452
CompletableFuture<?> future =
419453
client.connect(group.getAddress(), connectTimeout, gracefulShutdown);
420454
String user = group.getUser();
421-
connectFuture =
455+
CompletableFuture<IProtoClient> cf =
422456
future
423457
.thenCompose(
424458
greeting -> {
@@ -428,9 +462,10 @@ private CompletableFuture<IProtoClient> internalConnect() {
428462
}
429463
return client.ping(firstPingOpts);
430464
})
431-
.thenApply(r -> client)
432-
.whenComplete(this::onConnectComplete);
433-
return connectFuture;
465+
.thenApply(r -> client);
466+
connectFuture = cf;
467+
cf.whenComplete(this::onConnectComplete);
468+
return cf;
434469
}
435470

436471
/**
@@ -462,6 +497,10 @@ private void onConnectComplete(Object r, Throwable exc) {
462497
/**
463498
* Handler for connection close.
464499
*
500+
* <p>The {@code connectFuture} reset is performed under this monitor; emit, {@link #lock()},
501+
* {@link #shutdown()} and {@link #connectAfter()} run outside it (see {@link #shutdown()} for
502+
* why).
503+
*
465504
* @param r connection instance
466505
* @param exc exception which led to connection close
467506
*/
@@ -470,7 +509,9 @@ private void handleConnectError(Object r, Throwable exc) {
470509
return;
471510
}
472511
Throwable failure = exc.getCause() != null ? exc.getCause() : exc;
473-
connectFuture = null;
512+
synchronized (this) {
513+
connectFuture = null;
514+
}
474515
log.error("connect error {}/{}: {}", tag, index, failure.toString());
475516
emit(listener -> listener.onConnectionFailed(tag, index, failure));
476517
lock();
@@ -480,13 +521,19 @@ private void handleConnectError(Object r, Throwable exc) {
480521

481522
/** Reconnect task scheduler. */
482523
private void connectAfter() {
483-
log.info("reconnect {}/{} after {} ms", tag, index, reconnectAfter);
484-
if (reconnectTask == null) {
485-
reconnecting.incrementAndGet();
524+
synchronized (this) {
525+
log.info("reconnect {}/{} after {} ms", tag, index, reconnectAfter);
526+
if (reconnectTask != null) {
527+
// existing task is being replaced; the existing increment in `reconnecting` carries over
528+
// to the new task, so no counter change is needed here.
529+
reconnectTask.cancel();
530+
} else {
531+
reconnecting.incrementAndGet();
532+
}
533+
reconnectTask =
534+
timerService.newTimeout(
535+
timeout -> internalConnect(), reconnectAfter, TimeUnit.MILLISECONDS);
486536
}
487-
reconnectTask =
488-
timerService.newTimeout(
489-
timeout -> internalConnect(), reconnectAfter, TimeUnit.MILLISECONDS);
490537
emit(listener -> listener.onReconnectScheduled(tag, index, reconnectAfter));
491538
}
492539

@@ -658,7 +705,7 @@ private void incHeartbeatCounters(int fail) {
658705
}
659706

660707
/** Stops reconnecting task if it is active. */
661-
private void stopReconnectTask() {
708+
private synchronized void stopReconnectTask() {
662709
if (reconnectTask != null) {
663710
reconnecting.decrementAndGet();
664711
reconnectTask.cancel();

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.util.concurrent.CompletionException;
1616
import java.util.concurrent.ThreadLocalRandom;
1717

18+
import static org.junit.jupiter.api.Assertions.assertEquals;
1819
import static org.junit.jupiter.api.Assertions.assertTrue;
1920
import static org.junit.jupiter.api.Assertions.fail;
2021
import io.micrometer.core.instrument.Counter;
@@ -93,10 +94,21 @@ protected void execLua(TarantoolContainer<?> container, String command) {
9394

9495
protected int getActiveConnectionsCount(TarantoolContainer<?> tt) {
9596
try {
96-
List<? extends Object> result =
97-
TarantoolContainerClientHelper.executeCommandDecoded(
98-
tt, "return box.stat.net().CONNECTIONS.current");
99-
return (Integer) result.get(0) - 1;
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.
101+
String lua =
102+
"local last = box.stat.net().CONNECTIONS.current;"
103+
+ " for i = 1, 50 do"
104+
+ " require('fiber').sleep(0.05);"
105+
+ " local cur = box.stat.net().CONNECTIONS.current;"
106+
+ " if cur == last then return cur - 1 end;"
107+
+ " last = cur;"
108+
+ " end;"
109+
+ " return last - 1";
110+
List<? extends Object> result = TarantoolContainerClientHelper.executeCommandDecoded(tt, lua);
111+
return (Integer) result.get(0);
100112
} catch (Exception e) {
101113
throw new RuntimeException(e);
102114
}
@@ -106,6 +118,28 @@ protected int getActiveConnectionsCountDelta(TarantoolContainer<?> tt, int basel
106118
return getActiveConnectionsCount(tt) - baseline;
107119
}
108120

121+
/**
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.
128+
*
129+
* @param tt the Tarantool container under test
130+
* @param expected the expected number of active connections
131+
*/
132+
protected void waitForActiveConnections(TarantoolContainer<?> tt, int expected) {
133+
try {
134+
waitFor(
135+
"Active connections count never reached " + expected,
136+
Duration.ofSeconds(10),
137+
() -> assertEquals(expected, getActiveConnectionsCount(tt)));
138+
} catch (Exception e) {
139+
throw new RuntimeException(e);
140+
}
141+
}
142+
109143
protected MeterRegistry createMetricsRegistry() {
110144
MeterRegistry metricsRegistry = new SimpleMeterRegistry();
111145
LongTaskTimer.builder("request.timer")

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public void testReconnectAfterNodeFailure() throws Exception {
7777
assertTrue(pool.hasAvailableClients());
7878
List<IProtoClient> clients = getConnects(pool, "node-a", count1);
7979
assertTrue(pingClients(clients));
80-
assertEquals(count1, getActiveConnectionsCount(tt));
80+
waitForActiveConnections(tt, count1);
8181

8282
tt.stop();
8383
Thread.sleep(1000);
@@ -110,7 +110,7 @@ public void testReconnectAfterNodeFailure() throws Exception {
110110
});
111111

112112
assertTrue(pingClients(clients));
113-
assertEquals(count1, getActiveConnectionsCount(tt));
113+
waitForActiveConnections(tt, count1);
114114

115115
assertEquals(count1, metricsRegistry.get("pool.size").gauge().value());
116116
assertEquals(count1, metricsRegistry.get("pool.available").gauge().value());

0 commit comments

Comments
 (0)