SOLR-18402: Consolidate wasRequestUnsent / wasCommError (retry) logic - #4829
SOLR-18402: Consolidate wasRequestUnsent / wasCommError (retry) logic#4829chan-dx wants to merge 20 commits into
Conversation
dsmiley
left a comment
There was a problem hiding this comment.
Thanks for contributing!
There was a problem hiding this comment.
These seem HttpSolrClient worthy and not generalized to any SolrClient (e.g. not EmbeddedSolrServer). Even not worthy of CloudSolrClient since it's really the backing HttpSolrClient, which CSC exposes.
There was a problem hiding this comment.
Agreed, both now on HttpSolrClient - lines 370, overridden per transport. CloudSolrClient's overrides are gone. It calls getHttpClient().wasCommError(...) directly CloudSolrClient - lines 712.
Notes:
-
The
falsedefault onSolrClientwas what let the LB ask without aninstanceof, per the sketch on SOLR-18402; with it gone I narrowedgetClient(Endpoint)to returnHttpSolrClient. No in-tree change:Builder<C extends HttpSolrClient>andLBAsyncSolrClient.getClientalready guaranteed it. However, an out-of-tree subclass declaringSolrClientgetsAbstractMethodErroruntil it recompiles. Reachable, sinceLBSolrClient(List<Endpoint>)bypasses the Builder; my own test fixture had to change. Happy to reverse it if you'd rather. -
If the new
getClientsignature stands, let me know if you want a line inmajor-changes-in-solr-10.adocin case anyone subclasses it out-of-tree? -
That also made the private
doRequesthelper'sinstanceofconstant-true and its fallback unreachable, and its// TODO SOLR-17541was already stale. Deleted in its own commit (LBSolrClient - lines 614). Happy to drop that commit if you'd rather keep this PR narrower.
| } else { | ||
| throw e; | ||
| } | ||
| } catch (IOException e) { |
There was a problem hiding this comment.
An implicit outcome of SOLR-18402, I think, is to massively simplify catch blocks that currently are overly complex. Adding an IOException here and not simplifying or generalizing the previous ones is counter to this direction.
There was a problem hiding this comment.
Fixed, four catch blocks to one, via a new protected mayFailOver(...) (LBSolrClient lines 645, 662) LBAsyncSolrClient shares it, so its near-copy is gone too.
Notes:
Behaviour-identical except one row. wasRequestUnsent sat behind isNonRetryable, so && short-circuited it away for retryable requests and only getRootCause() decided. That misses HttpJettySolrClient lines 525's pre-commit shape: SolrServerException -> RequestNotSentException -> IllegalStateException, where the root cause is the IllegalStateException. Effect: an update proven unsent fails over, an identical query doesn't; testQueryIsRetriedWhenUnsentButRootCauseIsNotIO covers it. That's a behaviour improvement, separable from the refactor. Let me know if you'd rather I reverse it.
| /** | ||
| * Whether the failure proves the request never reached the server, making a replay safe even when | ||
| * the request isn't idempotent. Only the transport can answer this; the default is {@code false}, | ||
| * meaning "cannot tell" rather than "the request was sent". | ||
| */ | ||
| public boolean wasRequestUnsent(Throwable t) { | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Whether this is a transport-level communication failure rather than a response from the server. | ||
| * Implementations must keep {@link #wasRequestUnsent} a subset of this. | ||
| */ | ||
| public boolean wasCommError(Throwable t) { | ||
| return false; | ||
| } | ||
|
|
There was a problem hiding this comment.
I generated this JIRA description with AI, and I did read it. But I confess now (and I recall then as well), I'm confused on the distinction between these 2 methods. It's not clear to me why we need a distinction between these two. Feel free to help me figure this out ;-)
There was a problem hiding this comment.
Happy to explain! My understanding is: wasRequestUnsent is proof of non-delivery. wasCommError includes wasRequestUnsent as one of its causes, plus the other types listed in HttpSolrClient. So wasCommError can be true for a reason that is not proof of non-delivery. For example, SocketException, which can happen right after the server received the request.
Now imagine if we collapsed them into one big wasCommError, we'd lose that distinction. We'd know the socket went wrong, but not whether we have proof the request never landed, so we wouldn't know whether a replay is safe.
They are now in HttpSolrClient.
…ture Collect both transports' cases in one SolrTestCase against a dead URL, so the classification is checked without booting anything. The negative cases are the point: a bare IOException and a post-commit EofException are communication failures that prove nothing about delivery.
… as communication failures without falsely claiming the request was unsent, even when wrapped or raised after the request was already committed.
Per review, these are HTTP transport concerns, not something every SolrClient can answer. LBSolrClient.getClient now declares HttpSolrClient, which every implementation already returned.
Per review, four near-identical blocks become a single catch delegating to a shared mayFailOver, used by both the sync and async paths.
…etClient declared HttpSolrClient the instanceof was always true, so the helper now calls requestWithBaseUrl directly and its stale SOLR-17541 TODO goes with it.
82aabf7 to
84ebe75
Compare
https://issues.apache.org/jira/browse/SOLR-18402
Description
Please find the problem statement in SOLR-18402. Per my comment on the issue, this PR covers
CloudSolrClientandLBSolrClient, leavingSolrCmdDistributorand the streaming clients as a follow-up.Solution
Two predicates on
HttpSolrClient, wherefalsemeans "cannot tell", never "the request was sent":HttpSolrClient(shared HTTP types) → per-transport overrides.CloudSolrClientdelegates togetHttpClient(); the LB clients ask viagetClient(endpoint), now declared to returnHttpSolrClient.wasCommErrorcallswasRequestUnsent, so the subset relation cannot drift.Three behaviour changes ride on that:
CloudSolrClientreplays an update only when the transport proves it unsent delegatingwasCommErrorto the transport widens its comm-error set, and ungated that would resend updates on failures it previously left alone. State invalidation stays unconditional; only the resend is gated, at both paths a comm error reaches one inrequestWithRetryOnStaleState. That method'sINVALID_STATE/404 retry is untouched.HttpSolrCallrejects those before dispatch.HttpJettySolrClientclassifies its own transport failures.EofExceptionandClosedChannelExceptionextendIOException, notSocketException, so a query hitting one failed hard instead of failing over. The HTTP/2 "session closed" path now raises anEofExceptioninstead of an opaquenew IOException(e), so one predicate covers all three spellings of "connection lost" this class emits.LBSolrClientfails over on a bareIOException, asLBAsyncSolrClientalready does. Over Jetty this is narrower than it may look: network failures there are wrapped in aSolrServerException, and a bareIOExceptionescapes only frommakeRequest. It now propagates as-is instead of being wrapped by the catch-all; both types were already on the signature.Left as follow-ups:
SolrCmdDistributorand the streaming clients, per my comment on the issue. AlsorequestAsync, which has no commit listener (this PR is a no-op there), so async behaviour is unchanged; SOLR-18401 lists it under "Also in scope". AndHttpJdkSolrClient, which has no commit-listener equivalent, so it still cannot prove a request unsent.Decisions worth a second look
Flag anything you disagree with; otherwise no action needed.
CloudSolrClientno longer replays an update on an ambiguous comm error — a dropped connection or aSocketException. Long-standing behaviour; flag it if you would rather keep it.RouteExceptioncarrying 503. Also long-standing. Same offer.INVALID_STATE/ 404 retry is left as-is. It exists because those codes indicate stale routing state: it re-reads from ZK and re-routes before replaying. Narrowing it is a separate change.CloudSolrClientCacheTest.testCachingnow injects aConnectExceptionwhere it injected aSocketException. Its injected failures are scaffolding for a fetch-count assertion rather than the subject of the test, and aSocketExceptionon an update is exactly what no longer replays.HttpJettySolrClientoverrideswasCommErrorbut notwasRequestUnsent. Its commit listener answers the latter better:committedis per-request state, the predicate sees only aThrowable, andEofExceptionoccurs both before and after commit.mayFailOver, soLBAsyncSolrClient's near-copy goes with it. One behaviour change rides along:wasRequestUnsentpreviously sat behindisNonRetryable, so&&short-circuited it away for retryable requests and onlygetRootCause()decided. A query proven unsent now fails over even when the root cause isn't anIOException(testQueryIsRetriedWhenUnsentButRootCauseIsNotIO).LBSolrClient.getClient(Endpoint)narrowed fromSolrClienttoHttpSolrClient. Every in-tree implementation already returned one, but an out-of-tree subclass declaringSolrClientgets anAbstractMethodErroruntil it recompiles — reachable, sinceLBSolrClient(List<Endpoint>)bypasses theBuilder. Happy to reverse it. This also madedoRequest'sinstanceofconstant-true; that branch and its stale// TODO SOLR-17541are removed.Tests
SolrClientErrorClassificationTest(new) — asserts each transport's answers directly, with noserver. The
RequestNotSentExceptionand Jetty cases are also checked wrapped in aSolrServerException, pinning the cause-chain walk. The negative cases are the point: a bareIOExceptionand a post-commitEofExceptionprove nothing about delivery. Gap: the HTTP/2 case pins the classification of the shape the throw site produces, not the throw site itself —sendRequestis private, and a lost session is not reproducible in a unit test.LBSolrClientRetryUnsentTest— three cases for the bare-IOExceptionpath.CloudSolrClientCacheTest— two new cases, both confirmed to fail without their production change: an update not replayed on an ambiguous comm error, and one on a 503RouteException.testCaching's injected failures changed with it, noted above. Gap: the second retry site has no regression test — it fires only when another thread's cache refresh lands mid-request.AI disclosure: AI coding assistant was used for code review and PR message preparation.
Checklist
Please review the following and check all that apply:
mainbranch../gradlew check.