[ 
https://issues.apache.org/jira/browse/SOLR-18341?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

David Smiley updated SOLR-18341:
--------------------------------
    Priority: Critical  (was: Major)

> SolrJ: let each SolrRequest declare whether it may be retried
> -------------------------------------------------------------
>
>                 Key: SOLR-18341
>                 URL: https://issues.apache.org/jira/browse/SOLR-18341
>             Project: Solr
>          Issue Type: Improvement
>          Components: SolrCloud, SolrJ
>            Reporter: David Smiley
>            Priority: Critical
>
> h2. Problem
> {{CloudSolrClient}} and {{LBSolrClient}} both retry requests when they 
> believe the server never received them. Each infers retry-ability separately, 
> from a different signal, and the two disagree:
> * {{LBSolrClient}} derives it from the request type — 
> {{req.request.getRequestType() == SolrRequestType.UPDATE || isAdmin}} — and 
> then restricts update failover to connect-class failures.
> * {{CloudSolrClient}} uses {{wasCommError()}}, a top-level {{instanceof 
> SocketException || UnknownHostException}}, which includes connection resets 
> and broken pipes. Those occur *after* the request bytes were written, so the 
> update may well have been applied.
> Neither asks what the request actually contains. The result is that an atomic 
> {{inc}} caught by the {{CloudSolrClient}} comm-error path is replayed and 
> silently double-applies — wrong data, no error.  And lately (since the switch 
> to Jetty HttpClient with HTTP/2), some communication errors seen by 
> {{LBSolrClient}} defeat retry-ability that used to happen (with HTTP/1.1).
> Using instanceof checks is a code smell for the request is a code smell.  It 
> prevents customization.  There are four update request classes 
> ({{AbstractUpdateRequest}}, {{UpdateRequest}}, 
> {{ContentStreamUpdateRequest}}, {{StreamingUpdateRequest}}) and only one of 
> them holds a materialized document map. The rest wrap opaque streams that 
> SolrJ cannot introspect *and* cannot re-send.
> h2. Proposal
> Add a virtual method to {{SolrRequest}}:
> {code:java}
> /**
>  * Whether this request may be re-sent when the client does not know if the 
> server received it.
>  * Only consulted on the failure path.
>  */
> public boolean isRetriable() { ... }
> {code}
> Each request answers for itself. No casts, no {{instanceof}}, and the 
> requests that cannot introspect their own payload declare themselves 
> non-retriable rather than being silently misclassified.
> h3. Base implementation
> Derived from {{getRequestType()}}:
> * {{QUERY}} — retriable
> * {{ADMIN}}, {{SECURITY}}, {{UNSPECIFIED}} — not retriable (unchanged from 
> today's behavior)
> * {{UPDATE}} — not retriable at the base, so the {{UpdateRequest}} override 
> is the only thing that can permit it
> *The HTTP method is deliberately not consulted.* "GET implies safe" is the 
> natural assumption _and it is false in this codebase_, sadly: 
> {{SolrRequest:120}} declares {{private METHOD method = METHOD.GET}}, so GET 
> is the default value rather than a declaration, and every V1 admin family is 
> GET-by-construction — {{CollectionAdminRequest:87}}, 
> {{CoreAdminRequest:536,540}}, {{ConfigSetAdminRequest:54,58}}. CREATE, 
> DELETE, SPLITSHARD and UNLOAD are all issued as HTTP GET today! Correcting 
> those verbs is worth doing and will be filed separately; this issue doesn't 
> depend on it.
> h3. UpdateRequest override
> {{UpdateRequest}} inspects its own contents. Nearly everything is safe to 
> replay:
> ||Operation||Verdict||Rationale||
> |Full document add|permit|Absolute. Replay re-stamps {{_version_}}; final 
> document is identical.|
> |Delete by id|permit|Absolute. Second delete is a no-op.|
> |Delete by query|permit|Strictly idempotent — replay against an unchanged 
> index deletes nothing.|
> |Commit / optimize|permit|Idempotent by construction.|
> |Atomic {{set}}|permit|Absolute value. Includes set-to-null.|
> |Atomic {{inc}}|*reject*|Relative arithmetic. Replay double-applies.|
> |Atomic {{add}} of scalar values|*reject*|Appends without a distinctness 
> check. Replay duplicates values.|
> |Atomic {{add}} of child documents|permit|Upsert keyed by child id, not an 
> append — see below.|
> |Atomic {{add-distinct}}|permit|Adds only if absent; second application is a 
> no-op.|
> |Atomic {{remove}}|permit|Value is already gone on replay; second application 
> is a no-op.|
> |Atomic {{removeregex}}|permit|Same as {{remove}}.|
> The reject set is two cases: {{inc}}, and {{add}} of scalar values. Operators 
> are enumerated in {{AtomicUpdateDocumentMerger:162-188}}.
> {{add-distinct}}, {{remove}} and {{removeregex}} are worth calling out 
> because they read as "relative" but are idempotent — each is defined against 
> the field's current contents, so a second application finds nothing left to 
> do.
> Requests carrying a document iterator ({{UpdateRequest.setDocIterator}}) 
> return false: the iterator is exhausted after the first attempt, so a replay 
> would silently carry fewer documents. {{getRoutes()}} already returns null 
> for these ({{UpdateRequest:228-231}}), so they take the general streaming 
> path.
> h3. Nested documents
> Child documents are field values inside a {{SolrInputDocument}} 
> ({{AtomicUpdateDocumentMerger:693}}), and nested atomic updates merge the 
> child's operator map recursively ({{:115-130}}, {{:528}}). An operator map 
> can therefore be buried arbitrarily deep — {{\{"add": \{child doc with 
> \{"inc": 1\}\}\}}} presents {{add}} at the top level and hides an {{inc}} 
> inside. *The check must walk the document tree*, not scan top-level fields.
> h2. Exemptions
> Two cases skip the question entirely. Note the split: the caller decides 
> whether to ask, the request answers what it contains.
> * *Connect-class failures* — TCP connect never completed, so the server never 
> saw the request and replay is provably safe. A property of the failure, not 
> the request, so this stays in the caller. {{LBSolrClient}} already uses this 
> rule ({{isConnectException}}, {{:686-693}}).
> * *Operations carrying {{_version_} > 0}* — a replay after a successful first 
> attempt fails with 409 CONFLICT, which is already the desired behavior. A 
> property of the request, so it lives in the override.
> h2. Callers
> Both retry layers consult it:
> * {{LBSolrClient}} — endpoint failover within one shard's replica list.
> * {{CloudSolrClient.requestWithRetryOnStaleState}} — whole-request replay on 
> stale cluster state. Still needs the check: a leader move does not tell you 
> the pre-move attempt did not apply.
> Two callers at different layers asking one question is an additional argument 
> for putting it on the request. Today each has its own divergent answer, and 
> consolidating them is the same fix.
> h2. Testing
> * Unit tests over hand-built requests covering every row of the table, both 
> exemptions, the {{docIterator}} case, and an operator map buried inside a 
> child document.
> * Integration tests injecting mid-request faults with {{SocketProxy}} to 
> confirm the check fires on a real client-generated retry, and does *not* fire 
> on connect-refused failover.
> h2. Related
> * SOLR-9355 — the same "decide retriability from the wrong place" defect at 
> the leader-to-replica hop. Its fix scans the cause chain rather than checking 
> one fixed position; {{CloudSolrClient.wasCommError}} ({{:210-212}}) and 
> {{LBSolrClient.isConnectException}} ({{:686-693}}) have exactly that bug and 
> should share the extracted helper.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

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

Reply via email to