SOLR-18402: Consolidate wasRequestUnsent / wasCommError (retry) logic - #4829
SOLR-18402: Consolidate wasRequestUnsent / wasCommError (retry) logic#4829chan-dx wants to merge 15 commits into
Conversation
…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.
dsmiley
left a comment
There was a problem hiding this comment.
Thanks for contributing!
| `CloudSolrClient` now retries a failed update only when the transport can prove the request never reached the server. | ||
| Previously any communication error, or a 503, caused a retry, which could re-send an update that had already been partially applied. | ||
|
|
||
| `SolrClient` gains `wasRequestUnsent(Throwable)` and `wasCommError(Throwable)`, both defaulting to `false` and overridden per transport. |
There was a problem hiding this comment.
I don't think this is worth putting in the ref guide. It's a detail and doesn't change how people use SolrJ.
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.
| } 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.
| /** | ||
| * 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 ;-)
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 SolrClient, defaulting to false meaning "cannot tell": never "the request was sent":
SolrClient(false) →HttpSolrClient(shared HTTP types) → per-transport overrides.CloudSolrClientdelegates togetHttpClient();the LB clients ask via the existinggetClient(endpoint)— that is what the false default buys, noinstanceofin the LB. wasCommError calls wasRequestUnsent, 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.LBSolrClientandLBAsyncSolrClientkeep their own control flow. I read "remove the asymmetry rather than patch it in both places" as "no bespoke classifier in each", and each now makes onewasRequestUnsentcall. Merging the two near-duplicate methods would be a larger change.CloudSolrClient.wasCommErrorwidenedprotected→public, forced since it now overridesSolrClient.wasCommError.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.