This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git
The following commit(s) were added to refs/heads/master by this push:
new 9fe1449d [ISSUE #1309] [Java] Improve gRPC transport resilience (#1310)
9fe1449d is described below
commit 9fe1449d19449b41442aa3a97ab168ed6b5bd6b1
Author: qianye <[email protected]>
AuthorDate: Wed Jul 29 14:10:37 2026 +0800
[ISSUE #1309] [Java] Improve gRPC transport resilience (#1310)
---
.../apache/rocketmq/client/java/impl/Client.java | 9 +-
.../rocketmq/client/java/impl/ClientImpl.java | 24 +-
.../rocketmq/client/java/impl/ClientManager.java | 7 +
.../client/java/impl/ClientManagerImpl.java | 126 +++++++++
.../client/java/impl/ClientSessionImpl.java | 91 +++++--
.../apache/rocketmq/client/java/rpc/RpcClient.java | 14 +
.../rocketmq/client/java/rpc/RpcClientImpl.java | 11 +
.../apache/rocketmq/client/java/rpc/RpcFuture.java | 26 +-
.../rocketmq/client/java/impl/ClientImplTest.java | 16 +-
.../client/java/impl/ClientManagerImplTest.java | 167 +++++++++++-
.../client/java/impl/ClientSessionImplTest.java | 141 +++++++++-
.../rocketmq/client/java/rpc/RpcFutureTest.java | 79 ++++++
...ProducerHalfOpenTcpRecoveryIntegrationTest.java | 296 +++++++++++++++++++++
13 files changed, 975 insertions(+), 32 deletions(-)
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/Client.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/Client.java
index 4de14ffa..83510113 100644
--- a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/Client.java
+++ b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/Client.java
@@ -55,6 +55,13 @@ public interface Client {
*/
boolean isSslEnabled();
+ /**
+ * Reconnect telemetry to the specified endpoints.
+ *
+ * @param endpoints endpoints to reconnect.
+ */
+ void reconnectTelemetry(Endpoints endpoints);
+
/**
* Send Heartbeat
*
@@ -75,4 +82,4 @@ public interface Client {
* <p>Perform some statistics for the client.
*/
void doStats();
-}
\ No newline at end of file
+}
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientImpl.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientImpl.java
index 4d772104..b523af73 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientImpl.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientImpl.java
@@ -130,7 +130,6 @@ public abstract class ClientImpl extends
AbstractIdleService implements Client,
private final ReadWriteLock sessionsLock;
private final CompositedMessageInterceptor compositedMessageInterceptor;
- private boolean receiveReconnect = false;
public ClientImpl(ClientConfiguration clientConfiguration, Set<String>
topics) {
this.clientConfiguration = checkNotNull(clientConfiguration,
"clientConfiguration should not be null");
@@ -294,7 +293,7 @@ public abstract class ClientImpl extends
AbstractIdleService implements Client,
@Override
public void onReconnectEndpointsCommand(Endpoints endpoints,
ReconnectEndpointsCommand command) {
- receiveReconnect = true;
+ getClientManager().reconnect(endpoints);
}
/**
@@ -547,12 +546,21 @@ public abstract class ClientImpl extends
AbstractIdleService implements Client,
return clientId;
}
- public boolean isReceiveReconnect() {
- return receiveReconnect;
- }
-
- public void setReceiveReconnect(boolean receiveReconnect) {
- this.receiveReconnect = receiveReconnect;
+ @Override
+ public void reconnectTelemetry(Endpoints endpoints) {
+ final ClientSessionImpl clientSession;
+ sessionsLock.readLock().lock();
+ try {
+ clientSession = sessionsTable.get(endpoints);
+ } finally {
+ sessionsLock.readLock().unlock();
+ }
+ if (null == clientSession) {
+ log.warn("Failed to rebuild telemetry because client session does
not exist, endpoints={}, clientId={}",
+ endpoints, clientId);
+ return;
+ }
+ clientSession.reconnect();
}
/**
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManager.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManager.java
index d8f5c22f..ea19ea89 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManager.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManager.java
@@ -62,6 +62,13 @@ public abstract class ClientManager extends
AbstractIdleService {
*/
public abstract ScheduledExecutorService getScheduler();
+ /**
+ * Reconnect to the specified endpoints.
+ *
+ * @param endpoints endpoints to reconnect.
+ */
+ public abstract void reconnect(Endpoints endpoints);
+
/**
* Query topic route asynchronously, the method ensures no throwable.
*
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManagerImpl.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManagerImpl.java
index d0fd49bb..c7b3e1bb 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManagerImpl.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientManagerImpl.java
@@ -42,9 +42,14 @@ import apache.rocketmq.v2.SendMessageResponse;
import apache.rocketmq.v2.SyncLiteSubscriptionRequest;
import apache.rocketmq.v2.SyncLiteSubscriptionResponse;
import apache.rocketmq.v2.TelemetryCommand;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
import com.google.errorprone.annotations.concurrent.GuardedBy;
+import io.grpc.ConnectivityState;
import io.grpc.Metadata;
+import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
import java.time.Duration;
@@ -52,12 +57,16 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.net.ssl.SSLException;
@@ -94,6 +103,9 @@ public class ClientManagerImpl extends ClientManager {
public static final Duration SYNC_SETTINGS_DELAY = Duration.ofSeconds(1);
public static final Duration SYNC_SETTINGS_PERIOD = Duration.ofMinutes(5);
+ static final int HEART_BEAT_FAILURE_THRESHOLD = 2;
+ static final Duration TRANSPORT_RECOVERY_COOLDOWN = Duration.ofSeconds(30);
+
private static final Logger log =
LoggerFactory.getLogger(ClientManagerImpl.class);
private final Client client;
@@ -101,6 +113,8 @@ public class ClientManagerImpl extends ClientManager {
@GuardedBy("rpcClientTableLock")
private final Map<Endpoints, RpcClient> rpcClientTable;
private final ReadWriteLock rpcClientTableLock;
+ private final ConcurrentMap<Endpoints, Integer> heartbeatFailureAttempts;
+ private final ConcurrentMap<Endpoints, TransportRecoveryState>
transportRecoveryStates;
/**
* In charge of all scheduled tasks.
@@ -116,6 +130,8 @@ public class ClientManagerImpl extends ClientManager {
this.client = client;
this.rpcClientTable = new HashMap<>();
this.rpcClientTableLock = new ReentrantReadWriteLock();
+ this.heartbeatFailureAttempts = new ConcurrentHashMap<>();
+ this.transportRecoveryStates = new ConcurrentHashMap<>();
final long clientIndex = client.getClientId().getIndex();
this.scheduler = new ScheduledThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
@@ -148,6 +164,8 @@ public class ClientManagerImpl extends ClientManager {
final Duration idleDuration = rpcClient.idleDuration();
if (idleDuration.compareTo(RPC_CLIENT_MAX_IDLE_DURATION) > 0) {
it.remove();
+ heartbeatFailureAttempts.remove(endpoints);
+ transportRecoveryStates.remove(endpoints);
rpcClient.shutdown();
log.info("Rpc client has been idle for a long time,
endpoints={}, idleDuration={}, " +
"rpcClientMaxIdleDuration={}, clientId={}",
endpoints, idleDuration,
@@ -196,6 +214,30 @@ public class ClientManagerImpl extends ClientManager {
}
}
+ private RpcClient getRpcClientIfPresent(Endpoints endpoints) {
+ rpcClientTableLock.readLock().lock();
+ try {
+ return rpcClientTable.get(endpoints);
+ } finally {
+ rpcClientTableLock.readLock().unlock();
+ }
+ }
+
+ @Override
+ public void reconnect(Endpoints endpoints) {
+ final RpcClient rpcClient = getRpcClientIfPresent(endpoints);
+ if (null == rpcClient) {
+ log.warn("Failed to reconnect because rpc client does not exist,
endpoints={}, clientId={}",
+ endpoints, client.getClientId());
+ return;
+ }
+ reconnect(endpoints, rpcClient);
+ }
+
+ void reconnect(Endpoints endpoints, RpcClient rpcClient) {
+ recoverTransport(endpoints, rpcClient, "server reconnect command",
false);
+ }
+
@Override
public RpcFuture<QueryRouteRequest, QueryRouteResponse>
queryRoute(Endpoints endpoints, QueryRouteRequest request,
Duration duration) {
@@ -219,12 +261,96 @@ public class ClientManagerImpl extends ClientManager {
final Context context = new Context(endpoints, metadata);
final RpcClient rpcClient = getRpcClient(endpoints);
ListenableFuture<HeartbeatResponse> future =
rpcClient.heartbeat(metadata, request, asyncWorker, duration);
+ monitorHeartbeat(endpoints, rpcClient, future);
return new RpcFuture<>(context, request, future);
} catch (Throwable t) {
return new RpcFuture<>(t);
}
}
+ void monitorHeartbeat(Endpoints endpoints, RpcClient rpcClient,
ListenableFuture<HeartbeatResponse> future) {
+ Futures.addCallback(future, new FutureCallback<HeartbeatResponse>() {
+ @Override
+ public void onSuccess(HeartbeatResponse result) {
+ heartbeatFailureAttempts.remove(endpoints);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ final Status.Code code = Status.fromThrowable(t).getCode();
+ if (Status.Code.UNAVAILABLE == code) {
+ heartbeatFailureAttempts.remove(endpoints);
+ if (ConnectivityState.READY == rpcClient.getState(false)) {
+ recoverTransport(endpoints, rpcClient, "heartbeat
failure, statusCode=" + code, true);
+ }
+ return;
+ }
+ if (Status.Code.DEADLINE_EXCEEDED != code) {
+ heartbeatFailureAttempts.remove(endpoints);
+ return;
+ }
+ final int attempts = heartbeatFailureAttempts.merge(endpoints,
1, Integer::sum);
+ if (attempts >= HEART_BEAT_FAILURE_THRESHOLD) {
+ recoverTransport(endpoints, rpcClient, "heartbeat failure,
statusCode=" + code, true);
+ }
+ }
+ }, MoreExecutors.directExecutor());
+ }
+
+ private void recoverTransport(Endpoints endpoints, RpcClient rpcClient,
String reason,
+ boolean respectHeartbeatCooldown) {
+ final TransportRecoveryState recoveryState =
transportRecoveryStates.computeIfAbsent(
+ endpoints, ignored -> new TransportRecoveryState());
+ if (!recoveryState.recovering.compareAndSet(false, true)) {
+ log.info("Skip concurrent transport recovery, endpoints={},
reason={}, clientId={}",
+ endpoints, reason, client.getClientId());
+ return;
+ }
+ try {
+ final long now = System.nanoTime();
+ final long previous = recoveryState.lastRecoveryNanoTime.get();
+ if (TransportRecoveryState.NO_RECOVERY_NANO_TIME != previous) {
+ if (respectHeartbeatCooldown
+ && now - previous < TRANSPORT_RECOVERY_COOLDOWN.toNanos())
{
+ log.info("Skip transport recovery during heartbeat
cooldown, endpoints={}, reason={}, "
+ + "cooldown={}, clientId={}", endpoints, reason,
TRANSPORT_RECOVERY_COOLDOWN,
+ client.getClientId());
+ return;
+ }
+ }
+ recoveryState.lastRecoveryNanoTime.set(now);
+ heartbeatFailureAttempts.remove(endpoints);
+ log.warn("Try to recover transport, endpoints={}, reason={},
clientId={}",
+ endpoints, reason, client.getClientId());
+ try {
+ rpcClient.enterIdle();
+ } catch (RuntimeException e) {
+ log.warn("Failed to enter idle mode while recovering
transport, endpoints={}, reason={}, clientId={}",
+ endpoints, reason, client.getClientId(), e);
+ }
+ try {
+ client.reconnectTelemetry(endpoints);
+ } catch (RuntimeException e) {
+ log.warn("Failed to rebuild telemetry while recovering
transport, endpoints={}, reason={}, "
+ + "clientId={}", endpoints, reason, client.getClientId(),
e);
+ }
+ } finally {
+ recoveryState.recovering.set(false);
+ }
+ }
+
+ private static final class TransportRecoveryState {
+ private static final long NO_RECOVERY_NANO_TIME = Long.MIN_VALUE;
+
+ private final AtomicLong lastRecoveryNanoTime;
+ private final AtomicBoolean recovering;
+
+ private TransportRecoveryState() {
+ this.lastRecoveryNanoTime = new AtomicLong(NO_RECOVERY_NANO_TIME);
+ this.recovering = new AtomicBoolean();
+ }
+ }
+
@Override
public RpcFuture<SendMessageRequest, SendMessageResponse>
sendMessage(Endpoints endpoints,
SendMessageRequest request, Duration duration) {
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientSessionImpl.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientSessionImpl.java
index f0c7de3e..de6fbf36 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientSessionImpl.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/impl/ClientSessionImpl.java
@@ -27,9 +27,11 @@ import apache.rocketmq.v2.VerifyMessageCommand;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
+import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.rocketmq.client.apis.ClientException;
import org.apache.rocketmq.client.java.impl.producer.ClientSessionHandler;
import org.apache.rocketmq.client.java.misc.ClientId;
@@ -48,6 +50,8 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
private final ClientSessionHandler sessionHandler;
private final Endpoints endpoints;
private final SettableFuture<Settings> settingsInitFuture;
+ private final Object requestObserverLock;
+ private final AtomicBoolean reconnecting;
private volatile StreamObserver<TelemetryCommand> requestObserver;
@SuppressWarnings("UnstableApiUsage")
@@ -56,6 +60,8 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
this.sessionHandler = sessionHandler;
this.endpoints = endpoints;
this.settingsInitFuture = SettableFuture.create();
+ this.requestObserverLock = new Object();
+ this.reconnecting = new AtomicBoolean();
Futures.withTimeout(settingsInitFuture,
SETTINGS_INITIALIZATION_TIMEOUT.plus(tolerance).toMillis(),
TimeUnit.MILLISECONDS, sessionHandler.getScheduler());
this.requestObserver = sessionHandler.telemetry(endpoints, this);
@@ -68,10 +74,15 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
log.info("Endpoints is deprecated, no longer to renew
requestObserver, endpoints={}, clientId={}",
endpoints, clientId);
sessionHandler.removeClientSession(endpoints, this);
+ reconnecting.set(false);
return;
}
log.info("Try to renew requestObserver, endpoints={},
clientId={}", endpoints, clientId);
- this.requestObserver = sessionHandler.telemetry(endpoints, this);
+ final StreamObserver<TelemetryCommand> requestObserver =
sessionHandler.telemetry(endpoints, this);
+ synchronized (requestObserverLock) {
+ this.requestObserver = requestObserver;
+ reconnecting.set(false);
+ }
} catch (Throwable t) {
log.error("Failed to renew requestObserver, attempt to renew
later, endpoints={}, delay={}, clientId={}",
endpoints, REQUEST_OBSERVER_RENEW_BACKOFF_DELAY, clientId, t);
@@ -99,26 +110,54 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
*/
public void release() {
final ClientId clientId = sessionHandler.getClientId();
- if (null == requestObserver) {
- log.error("[Bug] request observer does not exist, no need to
release, endpoints={}, clientId={}",
- endpoints, clientId);
- return;
- }
- log.info("Begin to release client session, endpoints={}, clientId={}",
endpoints, clientId);
- try {
- requestObserver.onCompleted();
- } catch (Throwable ignore) {
- // Ignore exception on purpose.
+ synchronized (requestObserverLock) {
+ if (null == requestObserver) {
+ log.error("[Bug] request observer does not exist, no need to
release, endpoints={}, clientId={}",
+ endpoints, clientId);
+ return;
+ }
+ log.info("Begin to release client session, endpoints={},
clientId={}", endpoints, clientId);
+ try {
+ requestObserver.onCompleted();
+ } catch (Throwable ignore) {
+ // Ignore exception on purpose.
+ }
}
}
void write(TelemetryCommand command) {
- if (null == requestObserver) {
- log.error("[Bug] Request observer does not exist, ignore current
command, endpoints={}, command={}, "
- + "clientId={}", endpoints, command,
sessionHandler.getClientId());
+ synchronized (requestObserverLock) {
+ if (null == requestObserver) {
+ log.error("[Bug] Request observer does not exist, ignore
current command, endpoints={}, command={}, "
+ + "clientId={}", endpoints, command,
sessionHandler.getClientId());
+ return;
+ }
+ requestObserver.onNext(command);
+ }
+ }
+
+ void reconnect() {
+ final ClientId clientId = sessionHandler.getClientId();
+ if (!reconnecting.compareAndSet(false, true)) {
+ log.info("Telemetry is already reconnecting, endpoints={},
clientId={}", endpoints, clientId);
return;
}
- requestObserver.onNext(command);
+ synchronized (requestObserverLock) {
+ if (null == requestObserver) {
+ reconnecting.set(false);
+ log.error("[Bug] request observer does not exist, failed to
reconnect telemetry, endpoints={}, "
+ + "clientId={}", endpoints, clientId);
+ return;
+ }
+ try {
+ requestObserver.onError(Status.CANCELLED.withDescription(
+ "Reconnect telemetry on transport
recovery").asRuntimeException());
+ } catch (Throwable t) {
+ reconnecting.set(false);
+ log.warn("Failed to cancel telemetry while reconnecting,
endpoints={}, clientId={}",
+ endpoints, clientId, t);
+ }
+ }
}
@Override
@@ -187,10 +226,21 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
@Override
public void onError(Throwable throwable) {
final ClientId clientId = sessionHandler.getClientId();
- log.error("Exception raised from stream response observer,
clientId={}, endpoints={}", clientId, endpoints,
- throwable);
- release();
+ if (reconnecting.get()) {
+ if (Status.Code.CANCELLED ==
Status.fromThrowable(throwable).getCode()) {
+ log.info("Telemetry stream is cancelled for reconnecting,
clientId={}, endpoints={}",
+ clientId, endpoints);
+ } else {
+ log.warn("Telemetry stream failed while reconnecting,
clientId={}, endpoints={}",
+ clientId, endpoints, throwable);
+ }
+ } else {
+ log.error("Exception raised from stream response observer,
clientId={}, endpoints={}", clientId, endpoints,
+ throwable);
+ release();
+ }
if (!sessionHandler.isRunning()) {
+ reconnecting.set(false);
// first time to sync settings, forward the exception to upper
layer
settingsInitFuture.setException(throwable);
log.info("Session handler is not running, forgive to renew request
observer, clientId={}, "
@@ -205,8 +255,11 @@ public class ClientSessionImpl implements
StreamObserver<TelemetryCommand> {
public void onCompleted() {
final ClientId clientId = sessionHandler.getClientId();
log.info("Receive completion for stream response observer,
clientId={}, endpoints={}", clientId, endpoints);
- release();
+ if (!reconnecting.get()) {
+ release();
+ }
if (!sessionHandler.isRunning()) {
+ reconnecting.set(false);
log.info("Session handler is not running, forgive to renew request
observer, clientId={}, "
+ "endpoints={}", clientId, endpoints);
return;
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClient.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClient.java
index 98f252a5..9aaac5a5 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClient.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClient.java
@@ -43,6 +43,7 @@ import apache.rocketmq.v2.SyncLiteSubscriptionRequest;
import apache.rocketmq.v2.SyncLiteSubscriptionResponse;
import apache.rocketmq.v2.TelemetryCommand;
import com.google.common.util.concurrent.ListenableFuture;
+import io.grpc.ConnectivityState;
import io.grpc.Metadata;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
@@ -69,6 +70,19 @@ public interface RpcClient {
*/
void shutdown() throws InterruptedException;
+ /**
+ * Move the channel into idle mode so that new RPCs create a new transport.
+ */
+ void enterIdle();
+
+ /**
+ * Get the current connectivity state.
+ *
+ * @param requestConnection if true, the channel will try to connect if it
is currently idle.
+ * @return current connectivity state.
+ */
+ ConnectivityState getState(boolean requestConnection);
+
/**
* Query topic route asynchronously.
*
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClientImpl.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClientImpl.java
index 1e0225a1..328ac582 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClientImpl.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcClientImpl.java
@@ -46,6 +46,7 @@ import apache.rocketmq.v2.TelemetryCommand;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import io.grpc.ClientInterceptor;
+import io.grpc.ConnectivityState;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
@@ -122,6 +123,16 @@ public class RpcClientImpl implements RpcClient {
channel.shutdown().awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
}
+ @Override
+ public void enterIdle() {
+ channel.enterIdle();
+ }
+
+ @Override
+ public ConnectivityState getState(boolean requestConnection) {
+ return channel.getState(requestConnection);
+ }
+
@Override
public ListenableFuture<QueryRouteResponse> queryRoute(Metadata metadata,
QueryRouteRequest request, Executor executor, Duration duration) {
diff --git
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcFuture.java
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcFuture.java
index 83904a13..1fc8fbb7 100644
---
a/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcFuture.java
+++
b/java/client/src/main/java/org/apache/rocketmq/client/java/rpc/RpcFuture.java
@@ -17,12 +17,17 @@
package org.apache.rocketmq.client.java.rpc;
+import apache.rocketmq.v2.Code;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import org.apache.rocketmq.client.java.exception.TooManyRequestsException;
@SuppressWarnings("NullableProblems")
public class RpcFuture<R, T> implements ListenableFuture<T> {
@@ -33,7 +38,7 @@ public class RpcFuture<R, T> implements ListenableFuture<T> {
public RpcFuture(Context context, R request, ListenableFuture<T>
responseFuture) {
this.request = request;
this.context = context;
- this.responseFuture = responseFuture;
+ this.responseFuture = normalizeTransportException(context,
responseFuture);
}
public RpcFuture(Throwable t) {
@@ -50,6 +55,25 @@ public class RpcFuture<R, T> implements ListenableFuture<T> {
return context;
}
+ private static <T> ListenableFuture<T> normalizeTransportException(Context
context,
+ ListenableFuture<T> responseFuture) {
+ if (null == responseFuture) {
+ return null;
+ }
+ return Futures.catchingAsync(responseFuture,
StatusRuntimeException.class, exception -> {
+ if (Status.Code.RESOURCE_EXHAUSTED !=
exception.getStatus().getCode()) {
+ return Futures.immediateFailedFuture(exception);
+ }
+ final String requestId = null == context ? null :
context.getRequestId();
+ final String description = null ==
exception.getStatus().getDescription()
+ ? exception.getMessage() :
exception.getStatus().getDescription();
+ final TooManyRequestsException tooManyRequestsException = new
TooManyRequestsException(
+ Code.TOO_MANY_REQUESTS.getNumber(), requestId, description);
+ tooManyRequestsException.initCause(exception);
+ return Futures.immediateFailedFuture(tooManyRequestsException);
+ }, MoreExecutors.directExecutor());
+ }
+
@Override
public void addListener(Runnable listener, Executor executor) {
responseFuture.addListener(listener, executor);
diff --git
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientImplTest.java
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientImplTest.java
index 5f7ea7a8..ec1d8c00 100644
---
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientImplTest.java
+++
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientImplTest.java
@@ -33,6 +33,7 @@ import apache.rocketmq.v2.MessageType;
import apache.rocketmq.v2.NotifyClientTerminationRequest;
import apache.rocketmq.v2.Permission;
import apache.rocketmq.v2.PrintThreadStackTraceCommand;
+import apache.rocketmq.v2.ReconnectEndpointsCommand;
import apache.rocketmq.v2.Resource;
import apache.rocketmq.v2.TelemetryCommand;
import apache.rocketmq.v2.VerifyMessageCommand;
@@ -117,6 +118,19 @@ public class ClientImplTest extends TestBase {
verify(observer, times(1)).onNext(any(TelemetryCommand.class));
}
+ @Test
+ public void testOnReconnectEndpointsCommand() {
+ final Endpoints endpoints = fakeEndpoints();
+ final ReconnectEndpointsCommand command =
ReconnectEndpointsCommand.newBuilder().build();
+ final ClientManager clientManager = Mockito.mock(ClientManager.class);
+ final ClientImpl client = createClient();
+ doReturn(clientManager).when(client).getClientManager();
+
+ client.onReconnectEndpointsCommand(endpoints, command);
+
+ verify(clientManager, times(1)).reconnect(eq(endpoints));
+ }
+
@Test
public void testOnTopicRouteDataFetchedFailure() throws ClientException {
String topic = FAKE_TOPIC_0;
@@ -158,4 +172,4 @@ public class ClientImplTest extends TestBase {
doReturn(false).when(client).isRunning();
client.checkRunning();
}
-}
\ No newline at end of file
+}
diff --git
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientManagerImplTest.java
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientManagerImplTest.java
index a36c60c7..1dbdb0c3 100644
---
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientManagerImplTest.java
+++
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientManagerImplTest.java
@@ -22,6 +22,7 @@ import apache.rocketmq.v2.ChangeInvisibleDurationRequest;
import apache.rocketmq.v2.EndTransactionRequest;
import apache.rocketmq.v2.ForwardMessageToDeadLetterQueueRequest;
import apache.rocketmq.v2.HeartbeatRequest;
+import apache.rocketmq.v2.HeartbeatResponse;
import apache.rocketmq.v2.NotifyClientTerminationRequest;
import apache.rocketmq.v2.QueryAssignmentRequest;
import apache.rocketmq.v2.QueryRouteRequest;
@@ -29,11 +30,21 @@ import apache.rocketmq.v2.RecallMessageRequest;
import apache.rocketmq.v2.ReceiveMessageRequest;
import apache.rocketmq.v2.SendMessageRequest;
import apache.rocketmq.v2.SyncLiteSubscriptionRequest;
+import com.google.common.util.concurrent.Futures;
+import io.grpc.ConnectivityState;
import io.grpc.Metadata;
+import io.grpc.Status;
import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import org.apache.rocketmq.client.java.misc.ClientId;
+import org.apache.rocketmq.client.java.route.Endpoints;
+import org.apache.rocketmq.client.java.rpc.RpcClient;
import org.apache.rocketmq.client.java.tool.TestBase;
import org.junit.AfterClass;
+import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.Mockito;
@@ -154,4 +165,158 @@ public class ClientManagerImplTest extends TestBase {
// Expect no exception thrown.
}
-}
\ No newline at end of file
+ @Test
+ public void testHeartbeatDeadlineExceededTriggersRecoveryAfterThreshold() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ Mockito.verify(rpcClient, Mockito.never()).enterIdle();
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ Mockito.verify(rpcClient, Mockito.times(1)).enterIdle();
+ }
+
+ @Test
+ public void testHeartbeatSuccessResetsFailureAttempts() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+ Futures.immediateFuture(HeartbeatResponse.getDefaultInstance()));
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+
+ Mockito.verify(rpcClient, Mockito.never()).enterIdle();
+ }
+
+ @Test
+ public void testHeartbeatUnavailableTriggersRecoveryImmediately() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
Mockito.when(rpcClient.getState(false)).thenReturn(ConnectivityState.READY);
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.UNAVAILABLE.asRuntimeException()));
+
+ Mockito.verify(rpcClient, Mockito.times(1)).enterIdle();
+ }
+
+ @Test
+ public void testHeartbeatUnavailableDoesNotRecoverNonReadyChannel() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
Mockito.when(rpcClient.getState(false)).thenReturn(ConnectivityState.TRANSIENT_FAILURE);
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.UNAVAILABLE.asRuntimeException()));
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+
+ Mockito.verify(rpcClient, Mockito.never()).enterIdle();
+ }
+
+ @Test
+ public void testHeartbeatResourceExhaustedDoesNotTriggerRecovery() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.RESOURCE_EXHAUSTED.asRuntimeException()));
+
+ Mockito.verify(rpcClient, Mockito.never()).enterIdle();
+ }
+
+ @Test
+ public void testHeartbeatRecoveryHasCooldown() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+
+ for (int i = 0; i < 2 *
ClientManagerImpl.HEART_BEAT_FAILURE_THRESHOLD; i++) {
+ clientManager.monitorHeartbeat(fakeEndpoints(), rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ }
+
+ Mockito.verify(rpcClient, Mockito.times(1)).enterIdle();
+ }
+
+ @Test
+ public void testServerReconnectIgnoresHeartbeatCooldown() {
+ final ClientManagerImpl clientManager = createClientManager();
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+ final Endpoints endpoints = fakeEndpoints();
+
+ for (int i = 0; i < ClientManagerImpl.HEART_BEAT_FAILURE_THRESHOLD;
i++) {
+ clientManager.monitorHeartbeat(endpoints, rpcClient,
+
Futures.immediateFailedFuture(Status.DEADLINE_EXCEEDED.asRuntimeException()));
+ }
+ clientManager.reconnect(endpoints, rpcClient);
+
+ Mockito.verify(rpcClient, Mockito.times(2)).enterIdle();
+ }
+
+ @Test
+ public void testConcurrentReconnectOnlyRecoversOnce() throws
InterruptedException {
+ final Client client = Mockito.mock(Client.class);
+ Mockito.when(client.getClientId()).thenReturn(FAKE_CLIENT_ID);
+ final ClientManagerImpl clientManager = new ClientManagerImpl(client);
+ final RpcClient rpcClient = Mockito.mock(RpcClient.class);
+ final Endpoints endpoints = fakeEndpoints();
+ final int threadCount = 8;
+ final CountDownLatch ready = new CountDownLatch(threadCount);
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch recoveryStarted = new CountDownLatch(1);
+ final CountDownLatch allowRecoveryToComplete = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(threadCount);
+ final ExecutorService executor =
Executors.newFixedThreadPool(threadCount);
+ Mockito.doAnswer(invocation -> {
+ recoveryStarted.countDown();
+ Assert.assertTrue(allowRecoveryToComplete.await(5,
TimeUnit.SECONDS));
+ return null;
+ }).when(rpcClient).enterIdle();
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ executor.execute(() -> {
+ ready.countDown();
+ try {
+ start.await();
+ clientManager.reconnect(endpoints, rpcClient);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ Assert.assertTrue(ready.await(5, TimeUnit.SECONDS));
+ start.countDown();
+ Assert.assertTrue(recoveryStarted.await(5, TimeUnit.SECONDS));
+ final long waitDeadlineNanos = System.nanoTime() +
TimeUnit.SECONDS.toNanos(5);
+ while (done.getCount() > 1 && System.nanoTime() <
waitDeadlineNanos) {
+ Thread.yield();
+ }
+ Assert.assertEquals(1, done.getCount());
+ allowRecoveryToComplete.countDown();
+ Assert.assertTrue(done.await(5, TimeUnit.SECONDS));
+ } finally {
+ allowRecoveryToComplete.countDown();
+ executor.shutdownNow();
+ }
+
+ Mockito.verify(rpcClient, Mockito.times(1)).enterIdle();
+ Mockito.verify(client,
Mockito.times(1)).reconnectTelemetry(Mockito.eq(endpoints));
+ }
+
+ private ClientManagerImpl createClientManager() {
+ final Client client = Mockito.mock(Client.class);
+ Mockito.when(client.getClientId()).thenReturn(FAKE_CLIENT_ID);
+ return new ClientManagerImpl(client);
+ }
+
+}
diff --git
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientSessionImplTest.java
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientSessionImplTest.java
index 0b2e4bf3..5fcfa3af 100644
---
a/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientSessionImplTest.java
+++
b/java/client/src/test/java/org/apache/rocketmq/client/java/impl/ClientSessionImplTest.java
@@ -25,13 +25,18 @@ import static org.mockito.Mockito.times;
import apache.rocketmq.v2.NotifyUnsubscribeLiteCommand;
import apache.rocketmq.v2.PrintThreadStackTraceCommand;
+import apache.rocketmq.v2.ReconnectEndpointsCommand;
import apache.rocketmq.v2.RecoverOrphanedTransactionCommand;
import apache.rocketmq.v2.Settings;
import apache.rocketmq.v2.TelemetryCommand;
import apache.rocketmq.v2.VerifyMessageCommand;
+import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@@ -41,6 +46,7 @@ import
org.apache.rocketmq.client.java.impl.producer.ClientSessionHandler;
import org.apache.rocketmq.client.java.route.Endpoints;
import org.apache.rocketmq.client.java.tool.TestBase;
import org.awaitility.Durations;
+import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
@@ -265,4 +271,137 @@ public class ClientSessionImplTest extends TestBase {
Mockito.verify(sessionHandler,
times(1)).onNotifyUnsubscribeLiteCommand(eq(endpoints), eq(command0));
}
-}
\ No newline at end of file
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testConcurrentReconnectOnlyCancelsTelemetryOnce() throws
ClientException, InterruptedException {
+ final Endpoints endpoints = fakeEndpoints();
+ final ClientSessionHandler sessionHandler =
Mockito.mock(ClientSessionHandler.class);
+ Mockito.when(sessionHandler.getScheduler()).thenReturn(new
ScheduledThreadPoolExecutor(1));
+ final StreamObserver<TelemetryCommand> requestObserver =
Mockito.mock(StreamObserver.class);
+
Mockito.doReturn(requestObserver).when(sessionHandler).telemetry(any(Endpoints.class),
+ any(StreamObserver.class));
+ Mockito.doReturn(FAKE_CLIENT_ID).when(sessionHandler).getClientId();
+ final ClientSessionImpl clientSession = new
ClientSessionImpl(sessionHandler, Duration.ofSeconds(3), endpoints);
+ final int threadCount = 8;
+ final CountDownLatch ready = new CountDownLatch(threadCount);
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(threadCount);
+ final ExecutorService executor =
Executors.newFixedThreadPool(threadCount);
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ executor.execute(() -> {
+ ready.countDown();
+ try {
+ start.await();
+ clientSession.reconnect();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ Assert.assertTrue(ready.await(5, TimeUnit.SECONDS));
+ start.countDown();
+ Assert.assertTrue(done.await(5, TimeUnit.SECONDS));
+ } finally {
+ executor.shutdownNow();
+ }
+
+ Mockito.verify(requestObserver,
times(1)).onError(any(Throwable.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testReconnectRenewsTelemetry() throws ClientException {
+ final Endpoints endpoints = fakeEndpoints();
+ final ClientSessionHandler sessionHandler =
Mockito.mock(ClientSessionHandler.class);
+ Mockito.doReturn(SCHEDULER).when(sessionHandler).getScheduler();
+ final StreamObserver<TelemetryCommand> firstRequestObserver =
Mockito.mock(StreamObserver.class);
+ final StreamObserver<TelemetryCommand> secondRequestObserver =
Mockito.mock(StreamObserver.class);
+ Mockito.doReturn(firstRequestObserver,
secondRequestObserver).when(sessionHandler).telemetry(
+ any(Endpoints.class), any(StreamObserver.class));
+ Mockito.doReturn(FAKE_CLIENT_ID).when(sessionHandler).getClientId();
+ Mockito.doReturn(true).when(sessionHandler).isRunning();
+
Mockito.doReturn(false).when(sessionHandler).isEndpointsDeprecated(endpoints);
+
Mockito.doReturn(TelemetryCommand.getDefaultInstance()).when(sessionHandler).settingsCommand();
+ final ClientSessionImpl clientSession = new
ClientSessionImpl(sessionHandler, Duration.ofSeconds(3), endpoints);
+
+ clientSession.reconnect();
+ clientSession.onError(Status.CANCELLED.asRuntimeException());
+
+
await().atMost(ClientSessionImpl.REQUEST_OBSERVER_RENEW_BACKOFF_DELAY.plus(Durations.ONE_SECOND))
+ .untilAsserted(() -> {
+ Mockito.verify(sessionHandler,
times(2)).telemetry(eq(endpoints), eq(clientSession));
+ Mockito.verify(secondRequestObserver,
times(1)).onNext(TelemetryCommand.getDefaultInstance());
+ });
+ clientSession.reconnect();
+
+ Mockito.verify(firstRequestObserver,
times(1)).onError(any(Throwable.class));
+ Mockito.verify(secondRequestObserver,
times(1)).onError(any(Throwable.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testReconnectCanRetryAfterSessionHandlerStops() throws
ClientException {
+ final Endpoints endpoints = fakeEndpoints();
+ final ClientSessionHandler sessionHandler =
Mockito.mock(ClientSessionHandler.class);
+ Mockito.doReturn(SCHEDULER).when(sessionHandler).getScheduler();
+ final StreamObserver<TelemetryCommand> requestObserver =
Mockito.mock(StreamObserver.class);
+
Mockito.doReturn(requestObserver).when(sessionHandler).telemetry(any(Endpoints.class),
+ any(StreamObserver.class));
+ Mockito.doReturn(FAKE_CLIENT_ID).when(sessionHandler).getClientId();
+ Mockito.doReturn(false).when(sessionHandler).isRunning();
+ final ClientSessionImpl clientSession = new
ClientSessionImpl(sessionHandler, Duration.ofSeconds(3), endpoints);
+
+ clientSession.reconnect();
+ clientSession.onError(Status.CANCELLED.asRuntimeException());
+ clientSession.reconnect();
+
+ Mockito.verify(requestObserver,
times(2)).onError(any(Throwable.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testReconnectCanRetryAfterEndpointsAreDeprecated() throws
ClientException {
+ final Endpoints endpoints = fakeEndpoints();
+ final ClientSessionHandler sessionHandler =
Mockito.mock(ClientSessionHandler.class);
+ Mockito.doReturn(SCHEDULER).when(sessionHandler).getScheduler();
+ final StreamObserver<TelemetryCommand> requestObserver =
Mockito.mock(StreamObserver.class);
+
Mockito.doReturn(requestObserver).when(sessionHandler).telemetry(any(Endpoints.class),
+ any(StreamObserver.class));
+ Mockito.doReturn(FAKE_CLIENT_ID).when(sessionHandler).getClientId();
+ Mockito.doReturn(true).when(sessionHandler).isRunning();
+
Mockito.doReturn(true).when(sessionHandler).isEndpointsDeprecated(endpoints);
+ final ClientSessionImpl clientSession = new
ClientSessionImpl(sessionHandler, Duration.ofSeconds(3), endpoints);
+
+ clientSession.reconnect();
+ clientSession.onError(Status.CANCELLED.asRuntimeException());
+
+
await().atMost(ClientSessionImpl.REQUEST_OBSERVER_RENEW_BACKOFF_DELAY.plus(Durations.ONE_SECOND))
+ .untilAsserted(() -> Mockito.verify(sessionHandler, times(1))
+ .removeClientSession(eq(endpoints), eq(clientSession)));
+ clientSession.reconnect();
+
+ Mockito.verify(requestObserver,
times(2)).onError(any(Throwable.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testOnNextWithReconnectEndpointsCommand() throws
ClientException {
+ final Endpoints endpoints = fakeEndpoints();
+ final ClientSessionHandler sessionHandler =
Mockito.mock(ClientSessionHandler.class);
+ Mockito.doReturn(SCHEDULER).when(sessionHandler).getScheduler();
+ final StreamObserver<TelemetryCommand> requestObserver =
Mockito.mock(StreamObserver.class);
+
Mockito.doReturn(requestObserver).when(sessionHandler).telemetry(any(Endpoints.class),
+ any(StreamObserver.class));
+ Mockito.doReturn(FAKE_CLIENT_ID).when(sessionHandler).getClientId();
+ final ClientSessionImpl clientSession = new
ClientSessionImpl(sessionHandler, Duration.ofSeconds(3), endpoints);
+ final ReconnectEndpointsCommand reconnectCommand =
ReconnectEndpointsCommand.getDefaultInstance();
+
+
clientSession.onNext(TelemetryCommand.newBuilder().setReconnectEndpointsCommand(reconnectCommand).build());
+
+ Mockito.verify(sessionHandler,
times(1)).onReconnectEndpointsCommand(eq(endpoints), eq(reconnectCommand));
+ }
+
+}
diff --git
a/java/client/src/test/java/org/apache/rocketmq/client/java/rpc/RpcFutureTest.java
b/java/client/src/test/java/org/apache/rocketmq/client/java/rpc/RpcFutureTest.java
new file mode 100644
index 00000000..0d0e8320
--- /dev/null
+++
b/java/client/src/test/java/org/apache/rocketmq/client/java/rpc/RpcFutureTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.client.java.rpc;
+
+import com.google.common.util.concurrent.Futures;
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import java.util.concurrent.ExecutionException;
+import org.apache.rocketmq.client.java.exception.TooManyRequestsException;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RpcFutureTest {
+ private static final String REQUEST_ID = "fake-request-id";
+
+ private Context createContext() {
+ final Metadata metadata = new Metadata();
+ metadata.put(Metadata.Key.of(Signature.REQUEST_ID_KEY,
Metadata.ASCII_STRING_MARSHALLER), REQUEST_ID);
+ return new Context(null, metadata);
+ }
+
+ @Test
+ public void testResourceExhaustedIsNormalized() throws Exception {
+ final StatusRuntimeException statusRuntimeException =
+ Status.RESOURCE_EXHAUSTED.withDescription("flow
controlled").asRuntimeException();
+ final RpcFuture<Object, Object> future = new
RpcFuture<>(createContext(), null,
+ Futures.immediateFailedFuture(statusRuntimeException));
+
+ try {
+ future.get();
+ Assert.fail();
+ } catch (ExecutionException e) {
+ Assert.assertTrue(e.getCause() instanceof
TooManyRequestsException);
+
Assert.assertTrue(e.getCause().getMessage().contains("response-code=42900"));
+ Assert.assertTrue(e.getCause().getMessage().contains("request-id="
+ REQUEST_ID));
+ Assert.assertTrue(e.getCause().getMessage().contains("flow
controlled"));
+ Assert.assertSame(statusRuntimeException, e.getCause().getCause());
+ }
+ }
+
+ @Test
+ public void testOtherTransportExceptionIsUnchanged() throws Exception {
+ final StatusRuntimeException statusRuntimeException =
Status.UNAVAILABLE.asRuntimeException();
+ final RpcFuture<Object, Object> future = new
RpcFuture<>(createContext(), null,
+ Futures.immediateFailedFuture(statusRuntimeException));
+
+ try {
+ future.get();
+ Assert.fail();
+ } catch (ExecutionException e) {
+ Assert.assertSame(statusRuntimeException, e.getCause());
+ }
+ }
+
+ @Test
+ public void testSuccessfulResponseIsUnchanged() throws Exception {
+ final Object response = new Object();
+ final RpcFuture<Object, Object> future =
+ new RpcFuture<>(createContext(), null,
Futures.immediateFuture(response));
+
+ Assert.assertSame(response, future.get());
+ }
+}
diff --git
a/java/test/src/test/java/org/apache/rocketmq/test/client/ProducerHalfOpenTcpRecoveryIntegrationTest.java
b/java/test/src/test/java/org/apache/rocketmq/test/client/ProducerHalfOpenTcpRecoveryIntegrationTest.java
new file mode 100644
index 00000000..b8400f1c
--- /dev/null
+++
b/java/test/src/test/java/org/apache/rocketmq/test/client/ProducerHalfOpenTcpRecoveryIntegrationTest.java
@@ -0,0 +1,296 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.test.client;
+
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.SendMessageRequest;
+import apache.rocketmq.v2.SendMessageResponse;
+import apache.rocketmq.v2.SendResultEntry;
+import io.grpc.stub.StreamObserver;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.rocketmq.client.apis.ClientConfiguration;
+import org.apache.rocketmq.client.apis.ClientServiceProvider;
+import org.apache.rocketmq.client.apis.SessionCredentialsProvider;
+import org.apache.rocketmq.client.apis.StaticSessionCredentialsProvider;
+import org.apache.rocketmq.client.apis.message.Message;
+import org.apache.rocketmq.client.apis.producer.Producer;
+import org.apache.rocketmq.client.java.message.MessageIdCodec;
+import org.apache.rocketmq.test.server.BaseMockServerImpl;
+import org.apache.rocketmq.test.server.GrpcServerIntegrationTest;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ProducerHalfOpenTcpRecoveryIntegrationTest extends
GrpcServerIntegrationTest {
+ private static final String LOOPBACK = "127.0.0.1";
+ private static final String TOPIC = "topic";
+ private static final Duration MAX_RECOVERY_TIME = Duration.ofSeconds(40);
+
+ private BaseMockServerImpl serverImpl;
+
+ @Before
+ public void setUp() throws Exception {
+ serverImpl = new ProducerMockServer(TOPIC);
+ setUpServer(serverImpl, port);
+ }
+
+ @Test(timeout = 60000)
+ public void testProducerRecoversFromHalfOpenTcpAfterHeartbeatTimeouts()
throws Exception {
+ final BlackholeTcpProxy proxy = new BlackholeTcpProxy(port);
+ serverImpl.setPort(proxy.getPort());
+ final ClientServiceProvider provider =
ClientServiceProvider.loadService();
+ final SessionCredentialsProvider credentials =
+ new StaticSessionCredentialsProvider("accessKey", "secretKey");
+ final ClientConfiguration clientConfiguration =
ClientConfiguration.newBuilder()
+ .setEndpoints(LOOPBACK + ":" + proxy.getPort())
+ .setCredentialProvider(credentials)
+ .setRequestTimeout(Duration.ofSeconds(3))
+ .build();
+ final Producer producer = provider.newProducerBuilder()
+ .setClientConfiguration(clientConfiguration)
+ .setTopics(TOPIC)
+ .setMaxAttempts(1)
+ .build();
+ final Message message = provider.newMessageBuilder()
+ .setTopic(TOPIC)
+ .setBody("tcp-blackhole".getBytes(StandardCharsets.UTF_8))
+ .build();
+
+ try {
+ producer.send(message);
+ proxy.assertHealthy();
+ final int initialConnectionCount =
proxy.getAcceptedConnectionCount();
+ Assert.assertTrue(initialConnectionCount > 0);
+ proxy.blackholeExistingConnections();
+ final long blackholeStartNanoTime = System.nanoTime();
+
+ assertSendFailure(producer, message);
+ Assert.assertEquals(0, proxy.getClosedConnectionCount());
+
+ while (Duration.ofNanos(System.nanoTime() -
blackholeStartNanoTime).compareTo(MAX_RECOVERY_TIME) < 0) {
+ proxy.assertHealthy();
+ try {
+ producer.send(message);
+ } catch (Exception ignore) {
+ // Keep sending until heartbeat recovery replaces the
blackholed TCP connection.
+ continue;
+ }
+ Assert.assertTrue(proxy.getAcceptedConnectionCount() >
initialConnectionCount);
+ return;
+ }
+ proxy.assertHealthy();
+ Assert.fail("Producer did not recover from the half-open TCP
connection within " + MAX_RECOVERY_TIME);
+ } finally {
+ producer.close();
+ proxy.close();
+ }
+ }
+
+ private static void assertSendFailure(Producer producer, Message message) {
+ Exception failure = null;
+ try {
+ producer.send(message);
+ } catch (Exception t) {
+ failure = t;
+ }
+ Assert.assertNotNull("Message should time out on the blackholed TCP
connection", failure);
+ }
+
+ private static final class ProducerMockServer extends BaseMockServerImpl {
+ private ProducerMockServer(String topic) {
+ super(topic);
+ }
+
+ @Override
+ public void sendMessage(SendMessageRequest request,
StreamObserver<SendMessageResponse> responseObserver) {
+ final apache.rocketmq.v2.Status status =
+
apache.rocketmq.v2.Status.newBuilder().setCode(Code.OK).build();
+ final SendResultEntry entry = SendResultEntry.newBuilder()
+ .setStatus(status)
+
.setMessageId(MessageIdCodec.getInstance().nextMessageId().toString())
+ .setOffset(1)
+ .build();
+ responseObserver.onNext(SendMessageResponse.newBuilder()
+ .setStatus(status)
+ .addEntries(entry)
+ .build());
+ responseObserver.onCompleted();
+ }
+ }
+
+ private static final class BlackholeTcpProxy implements AutoCloseable {
+ private final int backendPort;
+ private final ServerSocket serverSocket;
+ private final ExecutorService executor;
+ private final AtomicBoolean running;
+ private final AtomicInteger acceptedConnectionCount;
+ private final AtomicInteger closedConnectionCount;
+ private final AtomicInteger blackholeThroughConnectionId;
+ private final AtomicReference<IOException> acceptFailure;
+ private final List<SocketPair> connections;
+
+ private BlackholeTcpProxy(int backendPort) throws IOException {
+ this.backendPort = backendPort;
+ this.serverSocket = new ServerSocket();
+ this.serverSocket.bind(new
InetSocketAddress(InetAddress.getByName(LOOPBACK), 0));
+ this.executor = Executors.newCachedThreadPool(runnable -> {
+ final Thread thread = new Thread(runnable,
"BlackholeTcpProxy");
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.running = new AtomicBoolean(true);
+ this.acceptedConnectionCount = new AtomicInteger();
+ this.closedConnectionCount = new AtomicInteger();
+ this.blackholeThroughConnectionId = new AtomicInteger();
+ this.acceptFailure = new AtomicReference<>();
+ this.connections = new CopyOnWriteArrayList<>();
+ this.executor.execute(this::acceptConnections);
+ }
+
+ private int getPort() {
+ return serverSocket.getLocalPort();
+ }
+
+ private int getAcceptedConnectionCount() {
+ return acceptedConnectionCount.get();
+ }
+
+ private int getClosedConnectionCount() {
+ return closedConnectionCount.get();
+ }
+
+ private void blackholeExistingConnections() {
+ blackholeThroughConnectionId.set(acceptedConnectionCount.get());
+ }
+
+ private void assertHealthy() {
+ final IOException failure = acceptFailure.get();
+ if (null == failure) {
+ return;
+ }
+ final AssertionError error = new AssertionError("TCP proxy stopped
accepting connections");
+ error.initCause(failure);
+ throw error;
+ }
+
+ private void acceptConnections() {
+ while (running.get()) {
+ try {
+ final Socket downstream = serverSocket.accept();
+ downstream.setTcpNoDelay(true);
+ final Socket upstream = new Socket();
+ upstream.setTcpNoDelay(true);
+ upstream.connect(new InetSocketAddress(LOOPBACK,
backendPort));
+ final int connectionId =
acceptedConnectionCount.incrementAndGet();
+ final SocketPair connection = new SocketPair(connectionId,
downstream, upstream);
+ connections.add(connection);
+ executor.execute(() -> forward(connection, downstream,
upstream));
+ executor.execute(() -> forward(connection, upstream,
downstream));
+ } catch (IOException e) {
+ if (running.get()) {
+ acceptFailure.compareAndSet(null, e);
+ return;
+ }
+ }
+ }
+ }
+
+ private void forward(SocketPair connection, Socket source, Socket
destination) {
+ final byte[] buffer = new byte[8192];
+ try {
+ final InputStream input = source.getInputStream();
+ final OutputStream output = destination.getOutputStream();
+ while (running.get()) {
+ final int length = input.read(buffer);
+ if (length < 0) {
+ break;
+ }
+ if (connection.id <= blackholeThroughConnectionId.get()) {
+ continue;
+ }
+ output.write(buffer, 0, length);
+ output.flush();
+ }
+ } catch (IOException ignore) {
+ // Socket closure is expected during transport recovery and
test cleanup.
+ } finally {
+ connection.close();
+ }
+ }
+
+ @Override
+ public void close() {
+ if (!running.compareAndSet(true, false)) {
+ return;
+ }
+ try {
+ serverSocket.close();
+ } catch (IOException ignore) {
+ // Ignore exception on purpose.
+ }
+ connections.forEach(SocketPair::close);
+ executor.shutdownNow();
+ }
+
+ private final class SocketPair {
+ private final int id;
+ private final Socket downstream;
+ private final Socket upstream;
+ private final AtomicBoolean closed;
+
+ private SocketPair(int id, Socket downstream, Socket upstream) {
+ this.id = id;
+ this.downstream = downstream;
+ this.upstream = upstream;
+ this.closed = new AtomicBoolean();
+ }
+
+ private void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ closeSocket(downstream);
+ closeSocket(upstream);
+ closedConnectionCount.incrementAndGet();
+ }
+
+ private void closeSocket(Socket socket) {
+ try {
+ socket.close();
+ } catch (IOException ignore) {
+ // Ignore exception on purpose.
+ }
+ }
+ }
+ }
+}