David Smiley created SOLR-18401:
-----------------------------------

             Summary: HttpJettySolrClient: detect if request wasn't sent; means 
retry-able
                 Key: SOLR-18401
                 URL: https://issues.apache.org/jira/browse/SOLR-18401
             Project: Solr
          Issue Type: Improvement
          Components: SolrJ
            Reporter: David Smiley
            Assignee: David Smiley


h2. Problem

{{CloudSolrClient}} no longer retries a request that failed because the pooled 
HTTP connection it was handed had already been closed. The request never 
reached the server, yet the failure propagates to the caller instead of failing 
over to another replica.

This is a regression from SOLR-18188, which removed {{CloudLegacySolrClient}} 
(Apache HttpClient) and left the Jetty-based client as the only implementation. 
The legacy client classified this case as a communication error:
{code:java}
@Override
protected boolean wasCommError(Throwable rootCause) {
  return super.wasCommError(rootCause)
      || rootCause instanceof ConnectTimeoutException
      || rootCause instanceof NoHttpResponseException;
}
{code}
{{NoHttpResponseException}} is Apache HttpClient's signal for "the server 
closed the pooled connection before reading the request" – provably never 
processed, therefore safe to replay. The Jetty transport has no equivalent, and 
the base implementation does not cover it:
{code:java}
/** Is this a communication error? We will retry if so. */
protected boolean wasCommError(Throwable t) {
  return t instanceof SocketException || t instanceof UnknownHostException;
}
{code}
Over the Jetty transport the same condition surfaces as 
{{{}ClosedChannelException{}}}, {{{}EofException: Connection reset by peer{}}}, 
or {{{}IOException: Broken pipe{}}}. All are plain {{{}IOException{}}}, none is 
a {{{}SocketException{}}}, so {{wasCommError}} returns false and 
{{CloudSolrClient}} gives up with retries still available:
{noformat}
CloudSolrClient request was not communication error it seems
CloudSolrClient Request to collection [collection1] failed due to (0) 
java.io.IOException: Broken pipe, retry=0 maxRetries=5 commError=false 
errorCode=0
{noformat}
{{LBSolrClient}} also declines, because updates are non-retryable there unless 
the cause is a {{{}ConnectException{}}}:
{code:java}
} else if (isNonRetryable && isConnectException(rootCause)) {
  ex = (!isZombie) ? makeServerAZombie(baseUrl, e) : e;
}
{code}
The outcome therefore depends on a race. If Jetty's connection pool has already 
evicted the dead connection, the next attempt gets a {{ConnectException}} (a 
{{{}SocketException{}}}), both layers retry, and the request succeeds. If the 
pool hands out the not-yet-evicted dead connection, the request fails hard.
h2. User impact

Any {{CloudSolrClient}} deployment where idle pooled connections are reaped – a 
server idle timeout, a load balancer, a firewall – now sees intermittent hard 
failures on requests that never reached Solr. Solr 10.x and earlier retried 
these.
h2. How it was found

{{RecoveryAfterSoftCommitTest}} went from zero CI failures to a sustained 3-10% 
failure rate. Per fucit.org weekly failure rates:
{noformat}
2026-18 .. 2026-23   no failures
2026-24              3.4%  (17/502)
2026-26              5.7%
2026-28              6.9%
2026-30              7.2%
2026-32              9.8%
2026-34              6.6%
{noformat}
ISO week 24 is 2026-06-08..14. SOLR-18188 landed 2026-06-10.

The test partitions the non-leader replica with a {{{}SocketProxy{}}}, then 
keeps indexing. {{SolrTestCaseJ4.RandomizingCloudSolrClientBuilder}} randomizes 
{{{}shardLeadersOnly{}}}, so roughly half of runs use a client that sends 
updates to any replica – including the partitioned one. Archived CI stacks show 
the client POSTing directly to the follower:
{noformat}
SolrServerException: IOException occurred when talking to server at:
  http://127.0.0.1:35661/solr/collection1_shard1_replica_n3/update
{noformat}
Reproduced locally at a comparable rate (1 failure in 12 seeds); the failure is 
a race, not seed-deterministic.
h2. Proposal

Rather than broadening {{wasCommError}} to all {{IOException}} – which would 
replay updates that may have been applied – determine structurally whether any 
bytes of the request reached the wire.

Jetty's {{Request.onRequestCommit(Request.CommitListener)}} fires from 
{{{}HttpSender.headersToCommit(){}}}, which runs in the write callback's 
{{onSuccess()}} – after the header bytes were successfully written to the 
{{{}EndPoint{}}}. If commit never fired, nothing of the request was sent, so 
the server cannot have seen it and a retry is safe even for a non-idempotent 
update. This is exactly the {{NoHttpResponseException}} invariant, recovered on 
the Jetty transport.

Note the converse does not hold: "committed" only means the bytes entered the 
kernel send buffer, not that the server processed them. That errs conservative 
– we still do not retry – so there is no behavior regression.

Sketch:
 * In {{{}HttpJettySolrClient{}}}, attach a commit listener when the request is 
built (alongside the existing {{req.attribute(REQ_PRINCIPAL_KEY, ...)}} and 
listener wiring) and record whether commit fired.
 * In the {{catch (ExecutionException)}} block, when the cause is an 
{{IOException}} and the request was never committed, throw a distinct exception 
rather than the generic {{{}"IOException occurred when talking to server at: 
..."{}}}.
 * Teach {{CloudSolrClient.wasCommError}} and {{LBSolrClient}} to treat that 
exception as retryable.

Two options for the exception type:
 # Reuse {{{}ConnectException{}}}. No changes outside {{{}solrj-jetty{}}}: it 
extends {{SocketException}} so {{wasCommError}} passes, and 
{{LBSolrClient.isConnectException}} already accepts it. Minimal, but 
semantically inaccurate – nothing refused the connection.
 # Add a marker type in solrj core, e.g. {{{}SolrRequestNotSentException 
extends IOException{}}}, and reference it from {{wasCommError}} and 
{{{}LBSolrClient{}}}. {{solrj-jetty}} already depends on solrj. Slightly larger 
diff, but it names the invariant that makes the retry safe. Preferred.

h2. Also in scope
 * {{HttpJettySolrClient}} wraps Jetty's HTTP/2 "session closed" 
{{IllegalStateException}} in a bare {{{}IOException{}}}, which hits the same 
classification gap. A lost session is by definition a request that was never 
sent.
 * The async send path needs the same handling, or async callers keep the 
current behavior.

h2. Not proposed

Configuring {{{}CloudSolrClient{}}}'s internal client to use HTTP/1.1 was 
considered. Both transports pool keep-alive connections under 
{{{}setIdleTimeout(-1){}}}, so a reused dead connection yields the same untyped 
{{IOException}} either way; it changes the odds, not the failure mode.

_(disclaimer: written by AI, of course)_



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to