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

Nick Shanin updated SOLR-18541:
-------------------------------
    Description: 
🤖 _AI text below_ 🤖 _(posted on behalf of Nick Shanin)_

Combined queries merge their per-query shard results in 
{{CombinedQueryComponent.mergeIds}}, and three defects sit in the totals that 
merge reports. First, a partial shard that sends a per-query entry is counted 
in that query's totals before the partial skip is tested, so its documents are 
dropped but its hits are not: in a constructed partial-response case run on 
unmodified main, the merged {{numFound}} was 7 instead of 3 and 
{{approximateTotalHits}} was 15 instead of 5. Second, when a shard sends no 
{{approximateTotalHits}} header, the fallback adds the running per-query total, 
which already includes the current shard, so two shards with 2 and 3 documents 
merged to 7 instead of 5. Third, the {{hitCountIsExact}} fold is inverted: an 
all-exact merge reports {{numFoundExact=false}} and an all-inexact merge 
reports true, both confirmed by run on unmodified main.

The fix I recommend is to test the skip before accumulating, with the other two 
corrections in the same change: the fallback should add the shard's own count 
instead of the running total, and the fold should keep the merged flag true 
only when every shard reports its count exact. Correcting the totals after the 
skip fires instead would mean reconstructing what the skipped shard added, and 
the exactness fold cannot be un-folded without per-shard tracking. One question 
for the maintainer: when several sub-queries contribute, {{numFound}} is 
currently the largest single sub-query count; should it instead count the 
distinct documents that match any sub-query, and should {{numFoundExact}} be 
false whenever more than one sub-query contributes?

AI agents assisted with research, implementation, review, and drafting. Nick 
Shanin directed the work and takes responsibility for the above text.

  was:
h2. Symptom

Combined queries (the hybrid search path that fuses the result sets of several 
queries, for example with reciprocal rank fusion) merge their per-query shard 
results in \{{CombinedQueryComponent.mergeIds}} (from a grep, line 285). Three 
defects sit in the totals that merge reports:

 * When a shard response for a query is flagged partial and carries a per-query 
entry for that query but no \{{sort_values_<q>}}, and the sort needs 
unmarshalling, the merging node drops that shard's documents for the query, but 
the shard was already counted in the query's totals before the skip was tested 
(from code reading; confirmed by run only for constructed responses; see 
Reproduction). That query's \{{numFound}} includes hits from a shard that 
contributed no documents to it (7 instead of 3, confirmed by run, case P 
below), and its \{{approximateTotalHits}} includes the skipped shard's 
approximate count (15 instead of 5, confirmed by run, case P2 below). The real 
\{{timeAllowed}} exit is not the shape that produces this (from code reading, 
not run). In the one exit shape settled by code reading, where the exit stops 
the shard while it prepares the first query's sort values and the sort needs 
unmarshalling, the shard adds no per-query entry for that query at all, so the 
merging node folds neither the shard's \{{numFound}} nor its exactness for that 
query, and the \{{approximateTotalHits}} value carried in its response header, 
or with no header the running total, can still be added at the merge for it 
(from code reading, not run). Two other exit shapes are not settled: under a 
score-only sort an exit cannot stop in sort-value preparation and stops in the 
search itself (from code reading, not run), and an exit on a later query leaves 
that shard's per-query list shorter than the query count (from code reading, 
not run). Whether the merge folds, skips or fails in those shapes is not 
established, and neither outcome is claimed here.
 * When a shard response carries no \{{approximateTotalHits}} header, the merge 
falls back to adding the running per-query total, which already includes the 
current shard when that shard sent a per-query entry (from code reading; see 
Mechanism). Headerless shards therefore accumulate cumulatively instead of once 
each: two shards with 2 and 3 documents and no \{{approximateTotalHits}} header 
merge to an approximate total of 7 instead of 5 in arrival order A then B 
(confirmed by run, fallback case below, where a shard flags 
maxHitsTerminatedEarly so the total reaches the response; A the shard with 2 
documents, B the shard with 3; in the other order the total is 8, from code 
reading, not run). The \{{numFound}} for that query is correct in that case (5, 
confirmed by run, fallback case below); the approximate total is wrong there (7 
instead of 5, confirmed by run, same case); case F does not state the merged 
numFoundExact, so this sentence makes no claim about it.
 * The \{{hitCountIsExact}} fold is inverted: it keeps the merged flag true 
only while shards report their counts as not exact (from code reading, line 
343). Where a single sub-query contributes, an all-exact merge of its shards 
reports \{{numFoundExact=false}}, and an all-inexact merge reports 
\{{numFoundExact=true}} (confirmed by run, cases X1 and X2 below). Only a mixed 
merge comes out right (from code reading, not run). What the flag should report 
when several sub-queries contribute is part of the open question in Fix options.

The response header does carry \{{partialResults=true}} when a shard is 
partial, so callers know the result set is partial; nothing tells them the 
counts are not the counts of the merged result (from code reading, not run).

h2. Mechanism

{\{CombinedQueryComponent.mergeIds}} (main, 
\{{solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java}})
 loops over the queries to combine (line 328), and inside that over the shard 
responses (line 334). The per-query totals \{{queryNumFound}} and 
\{{queryApproximateTotalHits}} reset for each query (lines 330 to 331). 
\{{hitCountIsExact}} is declared once, outside the query loop (line 315), so 
one flag serves every query, and it is written to the response at line 449 
(\{{responseDocs.setNumFoundExact(hitCountIsExact)}}). The per-query totals 
fold into the merged totals at lines 441 to 442, numFound is written at line 
448, and approximateTotalHits reaches the response header only at lines 479 to 
487, when maxHitsTerminatedEarly is set, and each shard that sends a per-query 
entry has its contribution folded in before the partial-results skip (every 
line reference in this paragraph and in the list below is from code reading; 
see Reproduction):

 * \{{queryNumFound += docs.getNumFound()}} at line 342
 * the \{{hitCountIsExact}} fold at line 343: \{{hitCountIsExact = 
hitCountIsExact && Boolean.FALSE.equals(docs.getNumFoundExact())}}
 * the exception \{{continue}} at lines 348 to 350 also comes after those folds
 * the partial flag is read from the response header at lines 359 to 364
 * the approximate block at lines 381 to 387: the header value is added at line 
386, and when the header is absent the fallback at line 384 adds 
\{{queryNumFound}}
 * the \{{sort_values_<q>}} lookup is at lines 390 to 396, and 
\{{needsUnmarshalling}} is computed at line 398
 * the skip itself is at lines 399 to 400: \{{if (thisResponseIsPartial && 
sortFieldValues.size() == 0 && needsUnmarshalling) continue;}}

So a skipped shard that carries a per-query entry for the query has already 
contributed its count, its exactness and its approximate count to the per-query 
totals before the \{{continue}} fires (from code reading; confirmed by run only 
for constructed responses; see Reproduction).

The fallback operand is the second defect. \{{queryNumFound}} at line 384 
already includes the current shard when that shard sent a per-query entry, 
because line 342 ran earlier in the same iteration, so each headerless shard 
adds the running total over all shards seen so far, headered or not (from code 
reading; the resulting total is confirmed by run in the fallback case below). 
With only headerless shards the approximate total is the sum over shards of 
each shard's count multiplied by the number of headerless shards arriving at or 
after it that complete normally (no shard exception; a headerless partial 
response counts even when the skip drops it), which is why the value depends on 
arrival order (from code reading, not run; responses arrive in completion 
order, HttpShardHandler.java line 440). The sibling fallback in 
\{{QueryComponent}} has the same line shape with the opposite error: there the 
running total is read before the current shard is folded in, so two shards of 2 
and 3 in arrival order A then B give 2 instead of 5 (from code reading, not 
run); here the same order gives 7 instead of 5 (confirmed by run, fallback case 
below).

The fold at line 343 is the third defect. \{{SolrDocumentList.numFoundExact}} 
defaults to true 
(\{{solr/solrj/src/java/org/apache/solr/common/SolrDocumentList.java}}, line 
50), and a shard writes true when its count is exact. The written form keeps 
the merged flag true only while shards report FALSE, the inverse of the meaning 
the flag carries at line 449. \{{QueryComponent}} uses the other form (lines 
1173 to 1174 at main): \{{if (hitCountIsExact && 
Boolean.FALSE.equals(docs.getNumFoundExact())) hitCountIsExact = false;}}, 
which clears the flag when a shard reports not exact and otherwise leaves it. 
(from code reading, not run)

Nothing in the skip depends on a total (from code reading, not run): its inputs 
are the partial flag, the sort values and \{{needsUnmarshalling}}, so the skip 
can be decided inside each (query, shard) iteration before any fold, the same 
reorder shape as the \{{QueryComponent}} fix.

h2. Reproduction

Confirmed by run on unmodified main \{{d76fc14b9857}} with a scratch probe 
harness: the class \{{CombinedQueryComponentMergeTotalsTest}} in the 
component's own test package, driving fabricated shard responses through 
\{{mergeIds}}, seed \{{C0FFEE42C0FFEE42}}. The probe is a scratch harness used 
to confirm the defects; it is not a proposed test. All seven probe cases failed 
on unmodified main; for P, P2, F, X1 and X2 each failure was an assertion 
carrying the predicted wrong value (M1 and M2 outcomes are at line 68). The 
seven cases are P and P2 (the partial-skip case below, asserting \{{numFound}} 
and \{{approximateTotalHits}} as two cases), F (the fallback case below), X1 
(one sub-query; all shards exact, merged flag false) and X2 (one sub-query; all 
shards inexact, merged flag true), the exactness cases below and M1 and M2 (the 
two maximum-fold cases, whose outcomes are stated with the open question in Fix 
options):

 * Partial skip, one query, sorted on a field so sort values need 
unmarshalling: a partial shard with an \{{approximateTotalHits}} header of 10, 
\{{numFound=4}} and no \{{sort_values_0}}; a complete shard with a header of 5 
and \{{numFound=3}}. Merged \{{numFound}} was 7 instead of 3, and merged 
\{{approximateTotalHits}} was 15 instead of 5 (case P2). This case is 
constructed: it hands the merge a partial response that carries a per-query 
entry but no sort values, which is the skip condition itself, and it is not the 
shape a real \{{timeAllowed}} exit produces (from code reading, not run; see 
Symptom).
 * Fallback, two complete shards with 2 and 3 documents, neither sending an 
\{{approximateTotalHits}} header, the first flagging 
\{{maxHitsTerminatedEarly}}: merged \{{numFound}} was 5 (correct), and merged 
\{{approximateTotalHits}} was 7 instead of 5 (2, then the running 2 + 3 folded 
again).
 * Exactness, case X1 (one sub-query): two shards whose per-query lists both 
report \{{numFoundExact=true}}: the merged flag was false instead of true. Case 
X2 (one sub-query): with both reporting false, the merged flag was true instead 
of false.

None of the component's existing tests asserts \{{numFoundExact}} or 
\{{approximateTotalHits}} on the merged response (from a grep of the 
component's test classes, not run).

End to end, no traced shard path produces the constructed shape (from code 
reading, not run). In the exit shape settled by code reading, a shard's 
\{{timeAllowed}} expires while it prepares the first query's sort values and 
the sort needs unmarshalling: it stops before adding that query's per-query 
entry, the search handler returns the response flagged partial with no 
per-query entry for that query, and the merging node folds neither that shard's 
\{{numFound}} nor its exactness for the unprepared query, though the 
\{{approximateTotalHits}} value in the shard's response header, or with no 
header the running total, can still be added for it at the merge (from code 
reading, not run). Two other exit shapes are not settled, and nothing here 
claims their outcomes: under a score-only sort the exit cannot stop in 
sort-value preparation, so it stops in the search itself with the same search 
handler products (from code reading, not run); and an exit on a later query 
leaves the shard's per-query list shorter than the query count (from code 
reading, not run), where the merge reads it with no size check: the unguarded 
\{{docList.get(queryIndex)}} at line 340 throws IndexOutOfBoundsException at 
the first query the list lacks, for a List such as ArrayList (from code 
reading, not run; whether a caller of mergeIds catches it is not checked here). 
What the reproduction pins is the ordering in the merge itself: any response 
that does reach the merge flagged partial, with a per-query entry and no sort 
values for a query whose sort needs unmarshalling, is counted in that query's 
totals before it is skipped (from code reading; confirmed by run only for 
constructed responses). The fallback and the fold need no partial results at 
all: any shard response that completes without the \{{approximateTotalHits}} 
header exercises the fallback, and the folds at lines 342 and 343 fire on every 
merge in which a shard sends a per-query entry (from code reading, not run).

h2. Fix options

Three smallest changes present themselves, one per defect:

 * Test the skip condition before accumulating the shard's per-query totals 
(equivalently, move the folds at lines 342 and 343 and the approximate block at 
lines 381 to 387 below the skip at lines 399 to 400), so that a skipped shard 
would never enter that query's totals. One detail rides with it: after the 
reorder, a query whose shards are all skipped would contribute a count of zero, 
and how that case bears on the shared exactness flag needs the same care as the 
all-skipped case in the \{{QueryComponent}} fix.
 * Correct the fallback operand: when a shard sends no 
\{{approximateTotalHits}} header, add that shard's own count 
(\{{docs.getNumFound()}}) instead of the running \{{queryNumFound}}, with a 
null guard giving 0, because \{{docs}} can be null when a shard has no 
per-query entry (from code reading, not run).
 * Correct the fold direction at line 343: \{{hitCountIsExact = hitCountIsExact 
&& !Boolean.FALSE.equals(docs.getNumFoundExact())}}, equivalently the \{{if}} 
form \{{QueryComponent}} uses at lines 1173 to 1174 (from code reading, not 
run), so that, for the lists a single sub-query contributes, the merged flag 
would be true only when every contributing list reports its count exact. The 
flag is one flag shared by every query (from code reading, line 315), and what 
it should report when several sub-queries contribute is part of the open 
question below; this change settles the single-sub-query case, which the 
reproduction pins.

The first change is the recommended one, with the second and third as part of 
the same fix. Correcting the totals when the skip fires instead would mean 
reconstructing what the skipped shard added after the fact: for the approximate 
total that would mean knowing which branch contributed and, on the fallback 
branch, what the running total was at that shard's arrival position, and no 
subtraction can repair the exactness flag, which is a fold over every shard 
that sends a per-query entry and cannot be un-folded without per-shard tracking 
(from code reading, not run). Testing the skip before accumulating would leave 
each query's totals describing the same set of shards that query merges. The 
second change is required rather than optional: the fallback fires for complete 
shards too (confirmed by run, fallback case F in Reproduction), not only 
skipped ones, so a reorder alone would leave the double count. The third is one 
line in the same accumulation, and without it the merged response keeps 
reporting an exact merge as inexact (case X1 confirms this by run for one 
sub-query; that the one-line change corrects the fold for shards that are not 
skipped is from code reading, not run).

Open question for the maintainer: for a combined query with two or more 
sub-queries, the response \{{numFound}} is currently the largest single 
sub-query match count (the per-query totals fold into the combined totals by 
maximum, at lines 441 to 442), except that a failed queue push lowers the 
running numFound first (line 434), so it can end below that count (from code 
reading; the two maximum-fold cases below confirm the merged 
approximateTotalHits by run, and the draft does not state their per-query 
numFound inputs). Sub-queries match their own documents, overlapping or not, so 
this number can be smaller than the number of documents the response returns 
when the distinct documents the sub-queries match together outnumber the 
largest single sub-query match count (from code reading, not run). For example, 
two disjoint sub-queries with two hits each return four documents while 
\{{numFound}} is 2 (from code reading, not run). The maximum fold is also what 
the merged \{{approximateTotalHits}} passes through: in the two maximum-fold 
probe cases, per-query approximate totals of 7 and 21 merged to an 
\{{approximateTotalHits}} of 21 with a merged \{{numFound}} of 11, and 
per-query approximate totals of 7 and 3 merged to 7 with a merged \{{numFound}} 
of 5; in each case the merged approximate total equals the larger of the two 
per-query approximate totals (run result for the merged values: 
approximateTotalHits was read from the response header, which carries it only 
when a shard sets maxHitsTerminatedEarly, and numFound was read from the 
response docs; the per-query totals are code arithmetic from the probe inputs, 
not recorded in the export), so the smaller one does not appear in it (the max 
rule is from code reading, line 442, not run). Which count should \{{numFound}} 
report: (a) the number of distinct documents that match any sub-query, or (b) a 
lower bound such as the largest single sub-query count, floored at the number 
of distinct documents returned? Whichever is chosen, should \{{numFoundExact}} 
be false whenever more than one sub-query contributes? The answer sets the 
expected numbers for the fix and decides whether the exactness fix can land 
with it.

Related: SOLR-14758, SOLR-15595. 

AI agents assisted with research, implementation, review, and drafting. Nick 
Shanin directed the work and takes responsibility for the above text.


> CombinedQueryComponent.mergeIds counts a skipped partial shard that sends a 
> per-query entry in the per-query totals, adds the running total as the 
> approximateTotalHits fallback
> --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
>
>                 Key: SOLR-18541
>                 URL: https://issues.apache.org/jira/browse/SOLR-18541
>             Project: Solr
>          Issue Type: Bug
>          Components: query
>            Reporter: Nick Shanin
>            Priority: Major
>
> 🤖 _AI text below_ 🤖 _(posted on behalf of Nick Shanin)_
> Combined queries merge their per-query shard results in 
> {{CombinedQueryComponent.mergeIds}}, and three defects sit in the totals that 
> merge reports. First, a partial shard that sends a per-query entry is counted 
> in that query's totals before the partial skip is tested, so its documents 
> are dropped but its hits are not: in a constructed partial-response case run 
> on unmodified main, the merged {{numFound}} was 7 instead of 3 and 
> {{approximateTotalHits}} was 15 instead of 5. Second, when a shard sends no 
> {{approximateTotalHits}} header, the fallback adds the running per-query 
> total, which already includes the current shard, so two shards with 2 and 3 
> documents merged to 7 instead of 5. Third, the {{hitCountIsExact}} fold is 
> inverted: an all-exact merge reports {{numFoundExact=false}} and an 
> all-inexact merge reports true, both confirmed by run on unmodified main.
> The fix I recommend is to test the skip before accumulating, with the other 
> two corrections in the same change: the fallback should add the shard's own 
> count instead of the running total, and the fold should keep the merged flag 
> true only when every shard reports its count exact. Correcting the totals 
> after the skip fires instead would mean reconstructing what the skipped shard 
> added, and the exactness fold cannot be un-folded without per-shard tracking. 
> One question for the maintainer: when several sub-queries contribute, 
> {{numFound}} is currently the largest single sub-query count; should it 
> instead count the distinct documents that match any sub-query, and should 
> {{numFoundExact}} be false whenever more than one sub-query contributes?
> AI agents assisted with research, implementation, review, and drafting. Nick 
> Shanin directed the work and takes responsibility for the above text.



--
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