This is an automated email from the ASF dual-hosted git repository. dsmiley pushed a commit to branch branch_10x in repository https://gitbox.apache.org/repos/asf/solr.git
commit 0b7ace7f37ff54bbf039b7395bca2ba2a79969eb Author: David Smiley <[email protected]> AuthorDate: Wed Aug 26 09:28:21 2026 -0400 SOLR-18401: HttpJettySolrClient: detect if request wasn't sent; retry (#4805) HttpJettySolrClient: detect if request wasn't sent; throw a new RequestNotSentException. CloudSolrClient & LBSolrClient will detect it and retry -- typically on another replica. (cherry picked from commit a98481d92279942a19148e5bf27bd5e6d15349a5) --- .../unreleased/SOLR-18401-retry-unsent-request.yml | 9 ++ .../solr/cloud/TestMiniSolrCloudClusterSSL.java | 5 +- .../client/solrj/jetty/HttpJettySolrClient.java | 34 +++++- .../solr/client/solrj/RequestNotSentException.java | 36 ++++++ .../solr/client/solrj/impl/CloudSolrClient.java | 12 +- .../solr/client/solrj/impl/LBAsyncSolrClient.java | 9 +- .../solr/client/solrj/impl/LBSolrClient.java | 7 +- .../java/org/apache/solr/common/SolrException.java | 20 ++++ .../solrj/impl/LBSolrClientRetryUnsentTest.java | 122 +++++++++++++++++++++ 9 files changed, 242 insertions(+), 12 deletions(-) diff --git a/changelog/unreleased/SOLR-18401-retry-unsent-request.yml b/changelog/unreleased/SOLR-18401-retry-unsent-request.yml new file mode 100644 index 00000000000..919810e11bc --- /dev/null +++ b/changelog/unreleased/SOLR-18401-retry-unsent-request.yml @@ -0,0 +1,9 @@ +title: > + HttpJettySolrClient: detect if request wasn't sent; means retry-able. + CloudSolrClient & LBSolrClient will detect it. +type: changed +authors: + - name: David Smiley +links: + - name: SOLR-18401 + url: https://issues.apache.org/jira/browse/SOLR-18401 diff --git a/solr/core/src/test/org/apache/solr/cloud/TestMiniSolrCloudClusterSSL.java b/solr/core/src/test/org/apache/solr/cloud/TestMiniSolrCloudClusterSSL.java index 8fbbe214fe0..39d6561381c 100644 --- a/solr/core/src/test/org/apache/solr/cloud/TestMiniSolrCloudClusterSSL.java +++ b/solr/core/src/test/org/apache/solr/cloud/TestMiniSolrCloudClusterSSL.java @@ -31,6 +31,7 @@ import org.apache.solr.client.solrj.impl.SolrHttpConstants; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.client.solrj.request.CoreAdminRequest; +import org.apache.solr.common.SolrException; import org.apache.solr.common.params.CoreAdminParams.CoreAdminAction; import org.apache.solr.embedded.JettyConfig; import org.apache.solr.embedded.JettySolrRunner; @@ -205,8 +206,8 @@ public class TestMiniSolrCloudClusterSSL extends SolrTestCaseJ4 { } }); assertTrue( - "Expected an root cause SSL Exception, got: " + ex.toString(), - ex.getCause() instanceof SSLException); + "Expected an SSL Exception in the cause chain, got: " + ex, + SolrException.hasCause(ex, SSLException.class)); } } finally { cluster.shutdown(); diff --git a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java index e561dc4d056..77e1f7e3f1a 100644 --- a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java +++ b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java @@ -36,7 +36,9 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.solr.client.api.util.SolrVersion; +import org.apache.solr.client.solrj.RequestNotSentException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; @@ -473,9 +475,26 @@ public class HttpJettySolrClient extends HttpSolrClient { String url = getRequestUrl(solrRequest, collection); Throwable abortCause = null; Request req = null; + // Jetty notifies "commit" once the request headers have been written to the network. Until then + // nothing of the request has reached the server, so a failure is safe to retry elsewhere. + AtomicBoolean committed = new AtomicBoolean(); try { InputStreamResponseListener listener = new InputStreamReleaseTrackingResponseListener(); - req = sendRequest(makeRequest(solrRequest, url, false), listener); + MakeRequestReturnValue mrrv = makeRequest(solrRequest, url, false); + mrrv.request.onRequestCommit(r -> committed.set(true)); + try { + req = sendRequest(mrrv, listener); + } catch (IOException e) { + // Writing the body can fail on this thread rather than asynchronously, typically when the + // pooled connection was already closed. + abortCause = e; + req = mrrv.request; + throw committed.get() + ? new SolrServerException("IOException occurred when talking to server at: " + url, e) + : new SolrServerException( + "Connection failed before the request was sent to: " + url, + new RequestNotSentException(e.getMessage(), e)); + } // only waits for headers, so use the idle timeout Response response = listener.get(idleTimeoutMillis, TimeUnit.MILLISECONDS); url = req.getURI().toString(); @@ -497,14 +516,21 @@ public class HttpJettySolrClient extends HttpSolrClient { if (cause instanceof SolrServerException) { throw (SolrServerException) cause; } else if (cause instanceof IOException) { - throw new SolrServerException( - "IOException occurred when talking to server at: " + url, cause); + throw committed.get() + ? new SolrServerException( + "IOException occurred when talking to server at: " + url, cause) + : new SolrServerException( + "Connection failed before the request was sent to: " + url, + new RequestNotSentException(cause.getMessage(), cause)); } throw new SolrServerException(cause.getMessage(), cause); } catch (IllegalStateException e) { // Jetty HTTP/2 throws IllegalStateException ("session closed") when the connection is lost. abortCause = e; - throw new SolrServerException("Connection lost at: " + url, new IOException(e)); + throw committed.get() + ? new SolrServerException("Connection lost at: " + url, new IOException(e)) + : new SolrServerException( + "Connection lost at: " + url, new RequestNotSentException(e.getMessage(), e)); } catch (SolrServerException | RuntimeException sse) { abortCause = sse; throw sse; diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/RequestNotSentException.java b/solr/solrj/src/java/org/apache/solr/client/solrj/RequestNotSentException.java new file mode 100644 index 00000000000..566dd691a5d --- /dev/null +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/RequestNotSentException.java @@ -0,0 +1,36 @@ +/* + * 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.solr.client.solrj; + +import java.io.IOException; +import java.io.Serial; + +/** + * Indicates that a request failed before any of it was written to the network, so the server cannot + * have processed it. Retrying such a request on another node is safe even when it is not + * idempotent. + * + * <p>Typically a pooled connection that the server had already closed. + */ +public class RequestNotSentException extends IOException { + + @Serial private static final long serialVersionUID = 1L; + + public RequestNotSentException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java index 18b6fe01f98..376ee50ce60 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java @@ -48,6 +48,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.apache.solr.client.solrj.RequestNotSentException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; @@ -205,9 +206,14 @@ public abstract class CloudSolrClient extends SolrClient { return getClusterStateProvider().getClusterState(); } - /** Is this a communication error? We will retry if so. */ + /** + * Is this a communication error? We will retry if so. The whole cause chain is inspected, since a + * transport may report the underlying failure wrapped at any depth. + */ protected boolean wasCommError(Throwable t) { - return t instanceof SocketException || t instanceof UnknownHostException; + return SolrException.hasCause(t, SocketException.class) + || SolrException.hasCause(t, UnknownHostException.class) + || SolrException.hasCause(t, RequestNotSentException.class); } @Override @@ -712,7 +718,7 @@ public abstract class CloudSolrClient extends SolrClient { ? ((SolrException) rootCause).code() : SolrException.ErrorCode.UNKNOWN.code; - final boolean wasCommError = wasCommError(rootCause); + final boolean wasCommError = wasCommError(exc); if (wasCommError || (exc instanceof RouteException diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBAsyncSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBAsyncSolrClient.java index 5a91c0314ba..88657cbca14 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBAsyncSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBAsyncSolrClient.java @@ -25,6 +25,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.RequestNotSentException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; @@ -218,13 +219,17 @@ public abstract class LBAsyncSolrClient extends LBSolrClient { if (!isNonRetryable && (rootCause instanceof IOException || rootCause instanceof TimeoutException)) { listener.onFailure((!isZombie) ? makeServerAZombie(endpoint, e) : e, true); - } else if (isNonRetryable && isConnectException(rootCause)) { + } else if (isNonRetryable + && (isConnectException(rootCause) + || SolrException.hasCause(e, RequestNotSentException.class))) { + // Nothing of the request reached the server, so replaying it elsewhere is safe even though + // it isn't idempotent. listener.onFailure((!isZombie) ? makeServerAZombie(endpoint, e) : e, true); } else { listener.onFailure(e, false); } } catch (IOException e) { - if (!isNonRetryable || isConnectException(e)) { + if (!isNonRetryable || isConnectException(e) || e instanceof RequestNotSentException) { listener.onFailure((!isZombie) ? makeServerAZombie(endpoint, e) : e, true); } else { listener.onFailure(e, false); diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java index eefbd08b2b1..4a9d63cd375 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java @@ -43,6 +43,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.RequestNotSentException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; @@ -671,7 +672,11 @@ public abstract class LBSolrClient extends SolrClient { if (!isNonRetryable && (rootCause instanceof IOException || rootCause instanceof TimeoutException)) { ex = (!isZombie) ? makeServerAZombie(baseUrl, e) : e; - } else if (isNonRetryable && isConnectException(rootCause)) { + } else if (isNonRetryable + && (isConnectException(rootCause) + || SolrException.hasCause(e, RequestNotSentException.class))) { + // Nothing of the request reached the server, so replaying it elsewhere is safe even though + // it isn't idempotent. ex = (!isZombie) ? makeServerAZombie(baseUrl, e) : e; } else { throw e; diff --git a/solr/solrj/src/java/org/apache/solr/common/SolrException.java b/solr/solrj/src/java/org/apache/solr/common/SolrException.java index b8eceee17af..fd9f43f9bc4 100644 --- a/solr/solrj/src/java/org/apache/solr/common/SolrException.java +++ b/solr/solrj/src/java/org/apache/solr/common/SolrException.java @@ -154,6 +154,26 @@ public class SolrException extends RuntimeException { return t; } + /** Cause chains are shallow in practice; the cap only guards against a cyclic chain. */ + private static final int MAX_CAUSE_DEPTH = 100; + + /** + * Whether {@code t} or anything in its cause chain is of the given type. Prefer this to {@link + * #getRootCause} when classifying a failure, since a transport may report it wrapped at any + * depth. + */ + public static boolean hasCause(Throwable t, Class<? extends Throwable> type) { + int depth = 0; + for (Throwable cause = t; + cause != null && depth++ < MAX_CAUSE_DEPTH; + cause = cause.getCause()) { + if (type.isInstance(cause)) { + return true; + } + } + return false; + } + /** * Ensure that the provided tragic exception is wrapped in a 5xx SolrException * diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientRetryUnsentTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientRetryUnsentTest.java new file mode 100644 index 00000000000..3163616e272 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientRetryUnsentTest.java @@ -0,0 +1,122 @@ +/* + * 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.solr.client.solrj.impl; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.RequestNotSentException; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.SolrRequest.SolrRequestType; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.QueryRequest; +import org.apache.solr.client.solrj.request.UpdateRequest; +import org.apache.solr.common.util.NamedList; +import org.junit.Test; + +/** + * A failure that proves the request never reached the server is safe to replay even when + * LBSolrClient would otherwise refuse to retry the request. {@link RequestNotSentException} is that + * proof. + */ +public class LBSolrClientRetryUnsentTest extends SolrTestCase { + + private static final LBSolrClient.Endpoint DEAD_HOST_1 = + new LBSolrClient.Endpoint("http://127.0.0.1:1/solr"); + private static final LBSolrClient.Endpoint DEAD_HOST_2 = + new LBSolrClient.Endpoint("http://127.0.0.1:2/solr"); + + /** Fails whatever endpoint is tried first with {@code failure}; any later endpoint succeeds. */ + private static class FailFirstEndpoint extends LBSolrClient { + final List<String> attempted = new ArrayList<>(); + private final Exception failure; + + FailFirstEndpoint(Exception failure) { + super(List.of(DEAD_HOST_1, DEAD_HOST_2)); + this.failure = failure; + } + + @Override + protected SolrClient getClient(Endpoint endpoint) { + return new SolrClient() { + @Override + public NamedList<Object> request(SolrRequest<?> request, String collection) + throws SolrServerException, IOException { + attempted.add(endpoint.getBaseUrl()); + if (attempted.size() > 1) { + return new NamedList<>(); + } + if (failure instanceof SolrServerException sse) { + throw sse; + } + throw (IOException) failure; + } + + @Override + public void close() {} + }; + } + } + + private static SolrServerException unsentException() { + IOException onTheWire = new IOException("Broken pipe"); + return new SolrServerException( + "Connection failed before the request was sent to: " + DEAD_HOST_1.getUrl(), + new RequestNotSentException(onTheWire.getMessage(), onTheWire)); + } + + private static SolrServerException maybeSentException() { + return new SolrServerException( + "IOException occurred when talking to server at: " + DEAD_HOST_1.getUrl(), + new IOException("Broken pipe")); + } + + private static List<String> requestReturningAttemptedUrls( + Exception failure, SolrRequest<?> request) throws Exception { + try (FailFirstEndpoint client = new FailFirstEndpoint(failure)) { + client.request(new LBSolrClient.Req(request, List.of(DEAD_HOST_1, DEAD_HOST_2))); + return client.attempted; + } + } + + @Test + public void testUpdateIsRetriedWhenRequestWasNeverSent() throws Exception { + assertEquals( + List.of(DEAD_HOST_1.getBaseUrl(), DEAD_HOST_2.getBaseUrl()), + requestReturningAttemptedUrls(unsentException(), new UpdateRequest().add("id", "1"))); + } + + /** LBSolrClient classifies {@link SolrRequestType#UPDATE} as non-retryable. */ + @Test + public void testRequestThatMayHaveBeenReceivedIsNotRetried() { + LBSolrClient.Req req = + new LBSolrClient.Req(new UpdateRequest().add("id", "1"), List.of(DEAD_HOST_1, DEAD_HOST_2)); + try (FailFirstEndpoint client = new FailFirstEndpoint(maybeSentException())) { + expectThrows(SolrServerException.class, () -> client.request(req)); + assertEquals(List.of(DEAD_HOST_1.getBaseUrl()), client.attempted); + } + } + + @Test + public void testQueryIsStillRetriedOnAnyIOException() throws Exception { + assertEquals( + List.of(DEAD_HOST_1.getBaseUrl(), DEAD_HOST_2.getBaseUrl()), + requestReturningAttemptedUrls(maybeSentException(), new QueryRequest())); + } +}
