Skip to content

Commit fbe31cd

Browse files
authored
Merge pull request #2880 from kishansinghifs1/feature/getNextAliveNode
Feat: getNextAliveNode() to failover to alternate endpoints on retryable failures.
2 parents aa60118 + 08d9e79 commit fbe31cd

11 files changed

Lines changed: 661 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ like `ch_db_01`. This is mostly used in k8s environment. (https://github.com/Cli
149149

150150
- **[repo]** Added a contribution guide. Please review and send us your feedback. (https://github.com/ClickHouse/clickhouse-java/pull/2859)
151151

152+
- **[client-v2]** Added endpoint failover support: when multiple endpoints are configured and a request fails with a retryable error (connect timeout, connection refused, HTTP 503, etc.), the client now automatically retries against the next available endpoint instead of always targeting the first one. Failed endpoints are quarantined for 30 seconds before being retried. (https://github.com/ClickHouse/clickhouse-java/issues/2855)
153+
152154
### Bug Fixes
153155

154156
- **[jdbc-v2, client-v2]** Fixed error handling for responses that not a ClickHouse error, like `404` response. (https://github.com/ClickHouse/clickhouse-java/issues/2803)

client-v2/src/main/java/com/clickhouse/client/api/Client.java

Lines changed: 43 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.clickhouse.client.api.serde.POJOFieldDeserializer;
3737
import com.clickhouse.client.api.serde.POJOFieldSerializer;
3838
import com.clickhouse.client.api.serde.POJOSerDe;
39+
import com.clickhouse.client.api.transport.ClientNodeSelector;
3940
import com.clickhouse.client.api.transport.Endpoint;
4041
import com.clickhouse.client.api.transport.HttpEndpoint;
4142
import com.clickhouse.client.config.ClickHouseClientOption;
@@ -62,8 +63,8 @@
6263
import java.util.Collection;
6364
import java.util.Collections;
6465
import java.util.HashMap;
65-
import java.util.HashSet;
6666
import java.util.LinkedHashMap;
67+
import java.util.LinkedHashSet;
6768
import java.util.List;
6869
import java.util.Map;
6970
import java.util.Set;
@@ -140,9 +141,9 @@ public class Client implements AutoCloseable {
140141
private String dbUser;
141142
private String serverVersion;
142143
private final Object metricsRegistry;
143-
private final int retries;
144144
private LZ4Factory lz4Factory = null;
145145
private final Supplier<String> queryIdGenerator;
146+
private final ClientNodeSelector nodeSelector;
146147
private final CredentialsManager credentialsManager;
147148

148149
private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
@@ -187,9 +188,8 @@ private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
187188
}
188189

189190
this.endpoints = tmpEndpoints.build();
191+
this.nodeSelector = new ClientNodeSelector(this.endpoints);
190192

191-
String retry = configuration.get(ClientConfigProperties.RETRY_ON_FAILURE.getKey());
192-
this.retries = retry == null ? 0 : Integer.parseInt(retry);
193193
boolean useNativeCompression = !MapUtils.getFlag(configuration, ClientConfigProperties.DISABLE_NATIVE_COMPRESSION.getKey(), false);
194194
if (useNativeCompression) {
195195
this.lz4Factory = LZ4Factory.fastestInstance();
@@ -272,7 +272,7 @@ public static class Builder {
272272
private Supplier<String> queryIdGenerator;
273273

274274
public Builder() {
275-
this.endpoints = new HashSet<>();
275+
this.endpoints = new LinkedHashSet<>();
276276
this.configuration = new HashMap<>();
277277

278278
for (ClientConfigProperties p : ClientConfigProperties.values()) {
@@ -1364,8 +1364,8 @@ public CompletableFuture<InsertResponse> insert(String tableName, List<?> data,
13641364
}
13651365

13661366

1367-
Integer retry = (Integer) configuration.get(ClientConfigProperties.RETRY_ON_FAILURE.getKey());
1368-
final int maxRetries = retry == null ? 0 : retry;
1367+
final int maxRetries = ClientConfigProperties.RETRY_ON_FAILURE.getOrDefault(requestSettings.getAllSettings());
1368+
final int maxAttempts = Math.max(maxRetries, endpoints.size() - 1);
13691369

13701370
requestSettings.setOption(ClientConfigProperties.INPUT_OUTPUT_FORMAT.getKey(), format);
13711371
if (requestSettings.getQueryId() == null && queryIdGenerator != null) {
@@ -1374,10 +1374,10 @@ public CompletableFuture<InsertResponse> insert(String tableName, List<?> data,
13741374
Supplier<InsertResponse> supplier = () -> {
13751375
long startTime = System.nanoTime();
13761376
// Selecting some node
1377-
Endpoint selectedEndpoint = getNextAliveNode();
1377+
Endpoint selectedEndpoint = nodeSelector.getEndpoint();
13781378

13791379
RuntimeException lastException = null;
1380-
for (int i = 0; i <= maxRetries; i++) {
1380+
for (int i = 0; i <= maxAttempts; i++) {
13811381
// Execute request
13821382
try (ClassicHttpResponse httpResponse =
13831383
httpClientHelper.executeRequest(selectedEndpoint, requestSettings.getAllSettings(),
@@ -1400,14 +1400,6 @@ public CompletableFuture<InsertResponse> insert(String tableName, List<?> data,
14001400
out.close();
14011401
})) {
14021402

1403-
1404-
// Check response
1405-
if (httpResponse.getCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
1406-
LOG.warn("Failed to get response. Server returned {}. Retrying. (Duration: {})", httpResponse.getCode(), durationSince(startTime));
1407-
selectedEndpoint = getNextAliveNode();
1408-
continue;
1409-
}
1410-
14111403
ClientStatisticsHolder clientStats = globalClientStats.remove(operationId);
14121404
OperationMetrics metrics = new OperationMetrics(clientStats);
14131405
String summary = HttpAPIClientHelper.getHeaderVal(httpResponse.getFirstHeader(ClickHouseHttpProto.HEADER_SRV_SUMMARY), "{}");
@@ -1420,15 +1412,19 @@ public CompletableFuture<InsertResponse> insert(String tableName, List<?> data,
14201412
String msg = requestExMsg("Insert", (i + 1), durationSince(startTime).toMillis(), requestSettings.getQueryId());
14211413
lastException = httpClientHelper.wrapException(msg, e, requestSettings.getQueryId());
14221414
if (httpClientHelper.shouldRetry(e, requestSettings.getAllSettings())) {
1423-
LOG.warn("Retrying.", e);
1424-
selectedEndpoint = getNextAliveNode();
1415+
if (i < maxAttempts) {
1416+
LOG.warn("Retrying.", e);
1417+
selectedEndpoint = nodeSelector.getNextAliveNode(selectedEndpoint);
1418+
} else {
1419+
nodeSelector.getNextAliveNode(selectedEndpoint);
1420+
}
14251421
} else {
14261422
throw lastException;
14271423
}
14281424
}
14291425
}
14301426

1431-
String errMsg = requestExMsg("Insert", retries, durationSince(startTime).toMillis(), requestSettings.getQueryId());
1427+
String errMsg = requestExMsg("Insert", maxAttempts + 1, durationSince(startTime).toMillis(), requestSettings.getQueryId());
14321428
LOG.warn(errMsg);
14331429
throw (lastException == null ? new ClientException(errMsg) : lastException); };
14341430

@@ -1593,13 +1589,15 @@ public CompletableFuture<InsertResponse> insert(String tableName,
15931589
if (requestSettings.getQueryId() == null && queryIdGenerator != null) {
15941590
requestSettings.setQueryId(queryIdGenerator.get());
15951591
}
1592+
final int maxRetries = ClientConfigProperties.RETRY_ON_FAILURE.getOrDefault(requestSettings.getAllSettings());
1593+
final int maxAttempts = Math.max(maxRetries, endpoints.size() - 1);
15961594
responseSupplier = () -> {
15971595
long startTime = System.nanoTime();
15981596
// Selecting some node
1599-
Endpoint selectedEndpoint = getNextAliveNode();
1597+
Endpoint selectedEndpoint = nodeSelector.getEndpoint();
16001598

16011599
RuntimeException lastException = null;
1602-
for (int i = 0; i <= retries; i++) {
1600+
for (int i = 0; i <= maxAttempts; i++) {
16031601
// Execute request
16041602
try (ClassicHttpResponse httpResponse =
16051603
httpClientHelper.executeRequest(selectedEndpoint, requestSettings.getAllSettings(),
@@ -1608,14 +1606,6 @@ public CompletableFuture<InsertResponse> insert(String tableName,
16081606
out.close();
16091607
})) {
16101608

1611-
1612-
// Check response
1613-
if (httpResponse.getCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
1614-
LOG.warn("Failed to get response. Server returned {}. Retrying. (Duration: {})", httpResponse.getCode(), durationSince(startTime));
1615-
selectedEndpoint = getNextAliveNode();
1616-
continue;
1617-
}
1618-
16191609
OperationMetrics metrics = new OperationMetrics(finalClientStats);
16201610
String summary = HttpAPIClientHelper.getHeaderVal(httpResponse.getFirstHeader(ClickHouseHttpProto.HEADER_SRV_SUMMARY), "{}");
16211611
ProcessParser.parseSummary(summary, metrics);
@@ -1627,22 +1617,26 @@ public CompletableFuture<InsertResponse> insert(String tableName,
16271617
String msg = requestExMsg("Insert", (i + 1), durationSince(startTime).toMillis(), requestSettings.getQueryId());
16281618
lastException = httpClientHelper.wrapException(msg, e, requestSettings.getQueryId());
16291619
if (httpClientHelper.shouldRetry(e, requestSettings.getAllSettings())) {
1630-
LOG.warn("Retrying.", e);
1631-
selectedEndpoint = getNextAliveNode();
1620+
if (i < maxAttempts) {
1621+
LOG.warn("Retrying.", e);
1622+
selectedEndpoint = nodeSelector.getNextAliveNode(selectedEndpoint);
1623+
} else {
1624+
nodeSelector.getNextAliveNode(selectedEndpoint);
1625+
}
16321626
} else {
16331627
throw lastException;
16341628
}
16351629
}
16361630

1637-
if (i < retries) {
1631+
if (i < maxAttempts) {
16381632
try {
16391633
writer.onRetry();
16401634
} catch (IOException ioe) {
16411635
throw new ClientException("Failed to reset stream before next attempt", ioe);
16421636
}
16431637
}
16441638
}
1645-
String errMsg = requestExMsg("Insert", retries, durationSince(startTime).toMillis(), requestSettings.getQueryId());
1639+
String errMsg = requestExMsg("Insert", maxAttempts + 1, durationSince(startTime).toMillis(), requestSettings.getQueryId());
16461640
LOG.warn(errMsg);
16471641
throw (lastException == null ? new ClientException(errMsg) : lastException);
16481642
};
@@ -1731,24 +1725,19 @@ public CompletableFuture<QueryResponse> query(String sqlQuery, Map<String, Objec
17311725
requestSettings.setQueryId(queryIdGenerator.get());
17321726
}
17331727

1728+
final int maxRetries = ClientConfigProperties.RETRY_ON_FAILURE.getOrDefault(requestSettings.getAllSettings());
1729+
final int maxAttempts = Math.max(maxRetries, endpoints.size() - 1);
17341730
Supplier<QueryResponse> responseSupplier = () -> {
17351731
long startTime = System.nanoTime();
17361732
// Selecting some node
1737-
Endpoint selectedEndpoint = getNextAliveNode();
1733+
Endpoint selectedEndpoint = nodeSelector.getEndpoint();
17381734
RuntimeException lastException = null;
1739-
for (int i = 0; i <= retries; i++) {
1735+
for (int i = 0; i <= maxAttempts; i++) {
17401736
ClassicHttpResponse httpResponse = null;
17411737
try {
17421738
httpResponse = httpClientHelper.executeRequest(selectedEndpoint,
1743-
requestSettings.getAllSettings(),
1744-
sqlQuery);
1745-
// Check response
1746-
if (httpResponse.getCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
1747-
LOG.warn("Failed to get response. Server returned {}. Retrying. (Duration: {})", httpResponse.getCode(), durationSince(startTime));
1748-
selectedEndpoint = getNextAliveNode();
1749-
HttpAPIClientHelper.closeQuietly(httpResponse);
1750-
continue;
1751-
}
1739+
requestSettings.getAllSettings(),
1740+
sqlQuery);
17521741

17531742
OperationMetrics metrics = new OperationMetrics(clientStats);
17541743
String summary = HttpAPIClientHelper.getHeaderVal(httpResponse
@@ -1772,14 +1761,18 @@ public CompletableFuture<QueryResponse> query(String sqlQuery, Map<String, Objec
17721761
String msg = requestExMsg("Query", (i + 1), durationSince(startTime).toMillis(), requestSettings.getQueryId());
17731762
lastException = httpClientHelper.wrapException(msg, e, requestSettings.getQueryId());
17741763
if (httpClientHelper.shouldRetry(e, requestSettings.getAllSettings())) {
1775-
LOG.warn("Retrying.", e);
1776-
selectedEndpoint = getNextAliveNode();
1764+
if (i < maxAttempts) {
1765+
LOG.warn("Retrying.", e);
1766+
selectedEndpoint = nodeSelector.getNextAliveNode(selectedEndpoint);
1767+
} else {
1768+
nodeSelector.getNextAliveNode(selectedEndpoint);
1769+
}
17771770
} else {
17781771
throw lastException;
17791772
}
17801773
}
17811774
}
1782-
String errMsg = requestExMsg("Query", retries, durationSince(startTime).toMillis(), requestSettings.getQueryId());
1775+
String errMsg = requestExMsg("Query", maxAttempts + 1, durationSince(startTime).toMillis(), requestSettings.getQueryId());
17831776
LOG.warn(errMsg);
17841777
throw (lastException == null ? new ClientException(errMsg) : lastException);
17851778
};
@@ -2285,9 +2278,7 @@ public void updateAccessToken(String accessToken) {
22852278
this.credentialsManager.setAccessToken(accessToken);
22862279
}
22872280

2288-
private Endpoint getNextAliveNode() {
2289-
return endpoints.get(0);
2290-
}
2281+
22912282

22922283
public static final String VALUES_LIST_DELIMITER = ",";
22932284

@@ -2313,7 +2304,7 @@ private Map<String, Object> buildRequestSettings(Map<String, Object> opSettings)
23132304
* <p>For {@link ClickHouseFormat#JSONEachRow}, callers may opt in to plain JSON numbers by setting
23142305
* {@link ClientConfigProperties#JSON_DISABLE_NUMBER_QUOTING}. Explicit server settings are otherwise
23152306
* left untouched.</p>
2316-
* <ul>
2307+
* <ul>
23172308
* <li>{@code output_format_json_quote_64bit_integers}</li>
23182309
* <li>{@code output_format_json_quote_64bit_floats}</li>
23192310
* <li>{@code output_format_json_quote_decimals}</li>

client-v2/src/main/java/com/clickhouse/client/api/ServerException.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ public String getQueryId() {
5454
}
5555

5656
private boolean discoverIsRetryable(int code, String message, int transportProtocolCode) {
57-
//Let's check if we have a ServerException to reference the error code
58-
//https://github.com/ClickHouse/ClickHouse/blob/master/src/Common/ErrorCodes.cpp
57+
// Let's check if we have a ServerException to reference the error code
58+
// https://github.com/ClickHouse/ClickHouse/blob/master/src/Common/ErrorCodes.cpp
5959
switch (code) { // UNEXPECTED_END_OF_FILE
6060
case 3: // UNEXPECTED_END_OF_FILE
6161
case 107: // FILE_DOESNT_EXIST

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,10 @@ private ClassicHttpResponse doPostRequest(Map<String, Object> requestConfig, Htt
594594
throw new ClientMisconfigurationException("Proxy authentication required. Please check your proxy settings.");
595595
} else if (httpResponse.getCode() == HttpStatus.SC_BAD_GATEWAY) {
596596
httpResponse.close();
597-
throw new ClientException("Server returned '502 Bad gateway'. Check network and proxy settings.");
597+
throw new ConnectException("Server returned '502 Bad gateway'. Check network and proxy settings.");
598+
} else if (httpResponse.getCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
599+
httpResponse.close();
600+
throw new ConnectException("Server returned '503 Service unavailable'.");
598601
} else if (httpResponse.getCode() >= HttpStatus.SC_BAD_REQUEST || httpResponse.containsHeader(ClickHouseHttpProto.HEADER_EXCEPTION_CODE)) {
599602
try {
600603
throw readError(req, httpResponse);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.clickhouse.client.api.transport;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
6+
import java.util.ArrayList;
7+
import java.util.Collections;
8+
import java.util.List;
9+
10+
/**
11+
* So we will look up for the first-alive node and then assign that node to
12+
* client to talk.
13+
*
14+
* <p>Endpoints are tried in the order they were registered (index 0 is the
15+
* "primary"). When a request fails, the failed endpoint is quarantined for
16+
* {@link #DEFAULT_QUARANTINE_MS} milliseconds and the next alive endpoint
17+
* is returned. Once the quarantine expires the node automatically becomes
18+
* eligible again, so traffic returns to the primary without any explicit
19+
* reset.</p>
20+
*
21+
* <p>If all endpoints are quarantined, the primary (index 0) is returned
22+
* as a fallback to avoid a complete lockout.</p>
23+
*
24+
* <p>This class is thread-safe: concurrent callers may invoke
25+
* {@link #getEndpoint()} and {@link #getNextAliveNode(Endpoint)}
26+
* from different threads.</p>
27+
*/
28+
public class ClientNodeSelector {
29+
30+
private static final Logger LOG = LoggerFactory.getLogger(ClientNodeSelector.class);
31+
32+
static final long DEFAULT_QUARANTINE_MS = 30_000;
33+
34+
private final List<EndpointState> endpointStates;
35+
36+
public ClientNodeSelector(List<Endpoint> endpoints) {
37+
List<EndpointState> states = new ArrayList<>(endpoints.size());
38+
for (Endpoint ep : endpoints) {
39+
states.add(new EndpointState(ep));
40+
}
41+
this.endpointStates = Collections.unmodifiableList(states);
42+
}
43+
44+
public Endpoint getEndpoint() {
45+
for (EndpointState state : endpointStates) {
46+
if (state.isAlive()) {
47+
return state.getEndpoint();
48+
}
49+
}
50+
LOG.warn("All endpoints are non-responsive, falling back to primary endpoint");
51+
return endpointStates.get(0).getEndpoint();
52+
}
53+
54+
public Endpoint getNextAliveNode(Endpoint failedEndpoint) {
55+
for (EndpointState state : endpointStates) {
56+
if (state.getEndpoint().equals(failedEndpoint)) {
57+
state.markFailed(DEFAULT_QUARANTINE_MS);
58+
LOG.warn("Endpoint {} quarantined for {} ms", failedEndpoint.getHost(), DEFAULT_QUARANTINE_MS);
59+
break;
60+
}
61+
}
62+
return getEndpoint();
63+
}
64+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.clickhouse.client.api.transport;
2+
3+
/**
4+
*{@link Endpoint} is wrapped to track for failover.
5+
* When a node fails, it can be quarantined for a fixed duration and after
6+
* it is expired, it will be considered as alive again.
7+
*/
8+
class EndpointState {
9+
10+
private final Endpoint endpoint;
11+
12+
private volatile long failedUntil;
13+
14+
EndpointState(Endpoint endpoint) {
15+
this.endpoint = endpoint;
16+
this.failedUntil = 0;
17+
}
18+
19+
Endpoint getEndpoint() {
20+
return endpoint;
21+
}
22+
23+
void markFailed(long quarantineMs) {
24+
if (quarantineMs <= 0) {
25+
throw new IllegalArgumentException("Quarantine duration must be positive: " + quarantineMs);
26+
}
27+
this.failedUntil = System.currentTimeMillis() + quarantineMs;
28+
}
29+
30+
boolean isAlive() {
31+
return System.currentTimeMillis() >= failedUntil;
32+
}
33+
}

0 commit comments

Comments
 (0)