The "exact hits count" flag reaches only Luke's unsorted search path. A sorted search goes through core's searchAfter, which counts exactly only up to 1,000 hits, so 1,001 matches come back as "1001+".
- To save work, a Lucene search may stop counting matches early. The total is then a lower bound (
TotalHitsrelationGREATER_THAN_OR_EQUAL_TO), printed with a "+": "1001+ hits". IndexSearcher.searchAfter(after, query, n, sort)counts exactly up to 1,000 hits. A collector built with the thresholdInteger.MAX_VALUEcounts every hit.- Luke's Search tab has an "exact hits count" box to ask for the full count. Choosing a sort field ticks the box and disables it.
- Index 1,001 documents with a numeric doc-values field
n, open the index in Luke and picknin the Search tab's Sort sub-tab. Luke ticks the "exact hits count" box and greys it out. - Search
*:*. You expect "Total docs: 1001 hits": Luke's sortedSearch.searchdocuments that with the flag set "the exact total hits count is returned". - You get "1001+ hits", a lower bound. With a
LongFieldsort field (points and doc values) and a term query matching all 20,000 documents, it shows "8301+ hits". - Why:
SearchImpluses the flag only for unsorted searches. The sorted branch callsIndexSearcher.searchAfter(after, query, 10, sort), which since 8.7.0 counts exactly only up to 1,000 hits. - The total is wrong, and its "+" also keeps Next enabled on the last page, where the companion bug LFV-1035 shows an empty page; the listed pages are correct. Giving the sorted branch the threshold the unsorted branch already uses fixes it; the Luke test suite passes.
People using the Luke desktop tool who sort a Search-tab query matching more than 1,000 documents: every such search since 8.7.0, with no setting that gives an exact sorted count. With points-indexed sort fields the number is far off. Result pages are correct; the index and core searches are unaffected.
Mechanism, terms and numbers508 words
Luke's Search tab ticks and disables its "exact hits count" checkbox whenever a sort field is chosen, and passes the flag to Search.search(query, simConfig, sort, fieldsToLoad, pageSize, exactHitsCount), whose javadoc says that with the flag set "the exact total hits count is returned". SearchImpl reads the flag only in its unsorted branch. The sorted branch calls IndexSearcher.searchAfter(after, query, pageSize, sort), which always counts with core's 1,000-hit threshold, so a sorted search over 1,001 documents shows "1001+ hits" next to the ticked, greyed-out box; with a points-indexed sort field (LongField) the shown value itself is far too low ("8301+ hits" for 20,000 matches). The Luke code dates from the module's import (8.1.0, 2019), when field-sorted counts were still exact; core's LUCENE-9449 made them a lower bound in 8.7.0, as documented, and Luke was not adapted. 8.6.3 passes; 8.7.0 to 10.6.0 and main fail. Building the sorted search's TopFieldCollectorManager with the unsorted branch's threshold fixes it; the whole Luke module's tests pass. Low priority: a displayed count in a desktop tool; the listed result pages, the index and core searches are unaffected, but the "+" also keeps Next enabled on the last page.
- The GUI forces the flag on. Choosing a sort field calls
resetExactHitsCnt()(SortPaneProvider.java:201), which disables and ticks the box (:257-258). The Search tab passes the sort, page size 10 andexactHitsCntCB.isSelected()to the model (SearchPanelProvider.java:530-532). - The contract. The Sort overload of
Search.searchsays "exactHitsCount- if set to true, the exact total hits count is returned." (Search.java:104; the unsorted overload says the same at:85). - The flag is read in one branch only.
SearchImpl.search()(SearchImpl.java:308-320): the unsorted branch buildsTopScoreDocCollectorManager(pageSize, after, exactHitsCount ? Integer.MAX_VALUE : 1000)(:316-318); the sorted branch callssearcher.searchAfter(after, query, pageSize, sort)(:314) and never looks at the flag. - Core counts exactly only up to 1,000.
searchAfterbuildsTopFieldCollectorManager(sort, n, after, TOTAL_HITS_THRESHOLD)withTOTAL_HITS_THRESHOLD = 1000(IndexSearcher.java:100,:733). BelowInteger.MAX_VALUEthe collector is non-exhaustive (TopFieldCollector.java:349-350), and at hit 1,001countHitsetsGREATER_THAN_OR_EQUAL_TO(:83-88). - The display.
populateResultsshowsString.valueOf(res.getTotalHits())(SearchPanelProvider.java:615), andTotalHits.toString()appends "+" for a lower bound (TotalHits.java:53-54): "Total docs: 1001+ hits".
- Doc-values-only sort field (
NumericDocValuesField, the repro): every hit is still counted, so the value is right (1001) and only the "+" is wrong. - Sort field with points and doc values (
LongField): once the threshold is reached the sort skips non-competitive documents, so counting stops early. ATermQuerymatching every document, sorted with the Search tab's sort type, page size 10, exact on: "1010+ hits" for 1,010, "4102+ hits" for 5,000 and "8301+ hits" for 20,000 matching documents (a seeded shuffle of the values; the exact figures depend on the insertion order; the same with a core jar from upstream main2f424fd428d).
Clear the sort fields and tick the box: the unsorted branch counts exactly.
Exposure and likelihood286 words
- Who. Users of the Luke desktop tool (
bin/luke.sh,bin/luke.cmdin the binary distribution) who choose a sort field in the Search tab. Also code that calls Luke'sSearchmodel with aSortandexactHitsCount=true, although that package calls itself "Models and internal APIs for Luke" and the Luke module exports no package. - Default vs opt-in. Sorting is the user's choice; once a sort field is chosen the exact count is requested automatically and cannot be turned off, so every sorted search over 1,000 hits is affected.
- Scale. 1,001 matching documents are enough, a modest threshold for many indexes.
- What is wrong. The total in "Total docs:", and, because of its "+", the end-of-results checks: Next stays enabled on the last page (
SearchPanelProvider.java:621-623enables it for any lower-bound total), andnextPageskips its end-of-results check, which runs only for an exact total (SearchImpl.java:344-346). So on the last page of a sorted search with more than 1,000 hits, clicking Next runs another search; that is where the companion LFV-1035 shows its empty page. With the recommended fix the total is exact and both checks work again. The listed pages are correct: the hunt's bounded model checks each returned page against a reference ranking in its configured scenarios, and fixing this root alone removes exactly the exact-count mismatches. Nothing in the index changes; core searches are not affected. - More than a "+". With a points-indexed sort field (for example
LongField) the number itself is misleading: "8301+ hits" for 20,000 matches. Counting matches is a basic question people open Luke to answer. - Likelihood: plausible. Sorting is a secondary but normal Luke action, and 1,001 matching documents is a modest threshold for many indexes.
One JUnit test: 1,001 documents with a numeric doc-values field, MatchAllDocsQuery sorted by it, called through Luke's Search model with the Search tab's sort, page size 10 and exact count on. It asserts TotalHits(1001, EQUAL_TO).
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.luke.models.search;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TotalHits;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSortedSearchExactHitsCount extends LuceneTestCase {
public void testSortedSearchReturnsExactCount() throws Exception {
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
for (int i = 0; i < 1001; i++) {
Document doc = new Document();
doc.add(new NumericDocValuesField("n", i));
w.addDocument(doc);
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
Search search = new SearchFactory().newInstance(reader);
SearchResults res =
search.search(
new MatchAllDocsQuery(),
new SimilarityConfig.Builder().build(),
new Sort(new SortField("n", SortField.Type.LONG)),
null,
10,
true);
// expected 1001 (EQUAL_TO) because exactHitsCount=true; actual 1001+ (GREATER_THAN_OR_EQUAL_TO)
assertEquals(new TotalHits(1001, TotalHits.Relation.EQUAL_TO), res.getTotalHits());
}
}
}
}
cd $LUCENE && ./gradlew :lucene:luke:test --tests org.apache.lucene.luke.models.search.TestSortedSearchExactHitsCount -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
TotalHits(1001, EQUAL_TO), shown in Luke as "1001 hits".- Observed
AssertionError: expected:<1001 hits> but was:<1001+ hits>, with assertions on and off, on main and 10.6.0.
More files, environment, full output246 words
One test class, one method, 1,001 documents, single thread. The outcome does not depend on the seed (5 of 5 iterations under a random master seed fail); the test framework still picks the directory and default codec from it. The call matches the Search tab's for a sorted search: the sort, page size 10 and exactHitsCount=true. fieldsToLoad is null (load all stored fields; the test documents store none), where the Search tab passes the field set of the Field Values sub-tab; it does not affect the count.
Regression test: this same test. It fails on stock main and passes with both validated fixes.
Format: the inline comment on line 52 is wider than google-java-format allows (checkGoogleJavaFormat flags it), and the MatchAllDocsQuery constructor the test uses is deprecated on main (MatchAllDocsQuery.java:31-34).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also 10.6.0, 10.5.2, 9.12.3; with the recommended fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run) |
cp $REPRO/TestSortedSearchExactHitsCount.java $LUCENE/lucene/luke/src/test/org/apache/lucene/luke/models/search/
cd $LUCENE && ./gradlew :lucene:luke:test --tests org.apache.lucene.luke.models.search.TestSortedSearchExactHitsCount -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestSortedSearchExactHitsCount > testSortedSearchReturnsExactCount FAILED
java.lang.AssertionError: expected:<1001 hits> but was:<1001+ hits>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:3CC0B0E20DB42F81]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.luke.models.search.TestSortedSearchExactHitsCount.testSortedSearchReturnsExactCount(TestSortedSearchExactHitsCount.java:53)
1 test completed, 1 failed
# -Ptests.asserts=false: the same message
# released 10.6.0 jars, -ea and -da: the same message
# with the recommended fix: the test passes, and :lucene:luke:test runs 98 tests with 0 failures- The javadoc of the sorted
Search.searchsays that with the flag set "the exact total hits count is returned", with no exception for sorts. - Luke ticks and locks the box for every sort, then shows "1001+ hits" next to it.
- The code dates from 2019, when sorted counts were exact; core changed that in 8.7.0 and Luke was not adapted.
The full argument215 words
- The contract. Both
Search.searchoverloads document the flag the same way, "if set to true, the exact total hits count is returned" (Search.java:85,:104). - The GUI depends on it.
SortPaneProvider.resetExactHitsCntticks and disables the box whenever a sort field is chosen (:256-258), so a ticked, greyed-out "exact hits count" sits next to "1001+ hits". - The intent, from history. The checkbox, the threshold in the unsorted branch only and the forced tick for sorts came together in the standalone Luke's DmitryKey/luke#163 (mocobeta, 2019-03-21; its body: "This also add a checkbox to the Search tab to return exact hit count (for backward compatibility)."), imported with LUCENE-2562 (
3a78c1999a87, 2019-04-10, first release 8.1.0). Then a field-sortedsearchAfterstill counted every hit (unless it early-terminated on an index sort). LUCENE-9449 (99220677fe40, backport75421682100, first release 8.7.0) madeTopFieldCollectorreportGREATER_THAN_OR_EQUAL_TOfor field sorts once the threshold is reached, as its CHANGES entry (8.7.0, API Changes) states; it touched no Luke file. - The announcement. The Luke 8.0.0 announcement on java-user@ describes the checkbox as being there "to return the accurate total hit count".
- No test covers it.
TestSearchImpl's index has 50 documents andtestSearchWithSort's query matches 10 of them, far below the 1,000-hit threshold (TestSearchImpl.java:73-143,:305-315).
The devil's-advocate check191 words
The strongest case for "works as intended" (rated weak by the cross-model reviews, the independent reproducer and the devil's-advocate verifier; a later cross-model fact check rated it moderate): core's 1,000-hit threshold for sorted searchAfter is intended and documented, the "+" honestly marks the total as a lower bound, and Luke's models package calls itself internal.
Why it fails: that defends core, not Luke. Luke's own javadoc promises the exact count when the flag is set, Luke's own GUI sets the flag and locks it, and Luke's unsorted branch shows the intended handling of the same flag. The user asked for the exact count, is told they get it, and has no way to get it for a sorted search. "Internal" does not help either: the defect is visible in the GUI, and the repro uses the GUI's sort, page size and flag. The later fact check agreed that core's policy does not override Luke's own contract and locked box, but noted that the argument is fair grounds for Low priority, or for changing Luke's contract instead (the third fix option), rather than proof that the recommended fix is the only answer.
Recommended Give the sorted branch the unsorted branch's threshold (validated)
In SearchImpl.search(), run the sorted search through a TopFieldCollectorManager built with the threshold the unsorted branch already uses (Integer.MAX_VALUE when the exact count is requested) instead of calling searchAfter. +10/-2 lines; the repro and the whole Luke test suite (98 tests, 0 failures) pass with assertions on and off.
int hitsThreshold = exactHitsCount ? Integer.MAX_VALUE : DEFAULT_TOTAL_HITS_THRESHOLD;
if (sort != null) {
// searchAfter(after, query, n, sort) would count exactly only up to 1,000 hits
int numHits = Math.min(pageSize, Math.max(1, searcher.getIndexReader().maxDoc()));
topDocs =
searcher.search(
query,
new TopFieldCollectorManager(
sort.rewrite(searcher), numHits, (FieldDoc) after, hitsThreshold));
} else {All options and trade-offs3 options ยท 458 words
Two fixes were run through gradle in a scratch worktree of 645d4fe14a20; the recommended one also passes the module's ECJ and forbidden-apis checks and is format-clean. A third, contract-only option was not run.
SearchImpl.java +10/-2 (two imports, FieldDoc and TopFieldCollectorManager, plus the branch below; hitsThreshold moves above the if):
int hitsThreshold = exactHitsCount ? Integer.MAX_VALUE : DEFAULT_TOTAL_HITS_THRESHOLD;
if (sort != null) {
// searchAfter(after, query, n, sort) would count exactly only up to 1,000 hits
int numHits = Math.min(pageSize, Math.max(1, searcher.getIndexReader().maxDoc()));
topDocs =
searcher.search(
query,
new TopFieldCollectorManager(
sort.rewrite(searcher), numHits, (FieldDoc) after, hitsThreshold));
} else {Trade-offs
- One threshold rule for both branches;
numHitsandsort.rewritemirror whatsearchAfterdoes internally. - With the exact count requested the collector is exhaustive, so sort skipping is off and a large sorted search is slower;
TopFieldCollectorManager's javadoc warns exactly that, and the unsorted branch already pays the same cost when the box is ticked. With the flag off nothing changes (1,000 is core's threshold too). - Drops
searchAfter'safter.docrange check, which cannot fire here:afteris the session's own last hit.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle--offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off. - With the fix: the whole
:lucene:luke:test(12 suites, 98 tests, 17 skipped byTestDocumentsImpl's class-level@SuppressCodecsunder this seed) passes with 0 failures with assertions on and off,TestSearchImpl17 of 17 and the repro included;ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestpass;checkGoogleJavaFormatflags only the test's comment line, notSearchImpl.java. - The hunt's form of this fix (fully qualified class names) passed the repro and
TestSearchImpl, and together with the companion paging fix made all 524,640 API call sequences and 37,952 GUI click sequences of the bounded model clean.
SearchImpl.java +4, after the searchAfter call: if exactHitsCount is set and the total is a lower bound, replace it with new TotalHits(searcher.count(query), TotalHits.Relation.EQUAL_TO).
Trade-offs
Keeps the fast, skipping top-k collection, but runs the query a second time whenever there are more than 1,000 hits (IndexSearcher.count is cheap for some queries through Weight#count, a full pass otherwise), again on every next page that has to be fetched; it overwrites the TopDocs field.
What the checks validated
The repro and TestSearchImpl with assertions on: 18 tests, 0 failures.
Stop forcing the box on for sorts in SortPaneProvider and document on Search.search that sorted searches count exactly only up to 1,000 hits.
Trade-offs
Removes a documented feature: there would be no way to get an exact sorted count in Luke.
No downstream search path is affected: core IndexSearcher behaves as documented; only Luke's Search tab and code that calls Luke's internal Search model see the lower bound.
A probe that clicks only the buttons Luke's Search tab enables saw sorted totals stay a lower bound with "exact hits count" on. A bounded-exhaustive enumeration of Luke's search and paging calls, checked against a reference written from the javadoc, then pinned it to the sorted branch.
The method in full159 words
Method: non-formal first signal, then semi-formal bounded-exhaustive checking.
- Lead. An earlier probe drove Luke's Search model through only the clicks the Search tab enables and saw sorted totals stay
GREATER_THAN_OR_EQUAL_TOwith the exact flag on past 1,000 hits. - Enumeration. The finder enumerated every call sequence up to length 6 over search, next page and previous page (524,640 sequences over 480 configurations of hit count, page size, sort and segments) and every GUI click sequence up to length 5, on the stock jars, against a pager reference written from the javadoc and the GUI code. Every sorted configuration over 1,000 hits ignored the exact count. Fix variants separated this root from a companion paging bug, and a mutant reference showed the check is not vacuous.
- Then executing cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier, an adjudication, version-history runs from 8.6.3 to main, a prior-report search, a security screen, a credibility audit and this doc's gradle runs followed.
No prior report found (GitHub issues, PRs and commits incl. the JIRA archive, four Lucene mailing lists, the pre-module Luke tracker, downstream trackers and the web, 2026-10-29; refreshed 2026-11-01).
Searches2 searches
Verdict new: in the searches below, as of 2026-11-01, no issue, PR, list post, JIRA entry, downstream or pre-module Luke tracker item or web page was found that reports that Luke's sorted search ignores the exact-count flag. Related, not reports of it: DmitryKey/luke#163 and the Luke 8.0.0 announcement (the feature's intent); #3636 (LUCENE-2562, the module import); LUCENE-9449, LUCENE-9280 and LUCENE-8060 (#9108), the core changes, none of which mentions Luke; open PR #16847 (long doc ids) makes one- to three-line changes in four Luke files, SearchResults.java and MLTConfig.java among them, but none in SearchImpl, Search, SortPaneProvider or SearchPanelProvider; PR #16857 (merged 2026-10-29, after 645d4fe14a20) consolidates core sort pruning and keeps the 1,000-hit default and the relation flip in countHit, so upstream main still has the bug; #15828 and #15837 are refactors. Gap: private reports to the security list are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), Elasticsearch, OpenSearch, Solr, dev@ / java-user@ / issues@ / commits@, JIRA, DmitryKey/luke, apache/lucenenet, neoluke, the web | 2026-10-29 | scripted prior-report search (55 queries), manual GitHub searches (exactHitsCount, resetExactHitsCnt, exactHitsCntCB, luke "exact hits", "hits count", "total hits", SortPaneProvider, SearchImpl), module:luke items, open Luke PRs and their file lists, all items created 2026-10-26..29, commits under the Luke search model | no report; related items only |
| GitHub issues and PRs (apache/lucene); commits under lucene/luke/.../models/search | 2026-11-01 | exactHitsCount; luke "exact hits"; luke sort total hits; luke updated since 2026-10-28; SearchImpl sort; luke "hits count"; newest commit on the path | only #1869 (LUCENE-794) and #3636, both unrelated; newest commit still 30c6deb8b65c (#15837, refactor) |
The sorted branch is as old as the Luke module (8.1.0, 2019), written when sorted counts were exact. Core's LUCENE-9449 made them a lower bound past 1,000 hits in 8.7.0; 8.6.3 passes, 8.7.0 through 10.6.0 and main fail. Not fixed upstream.
Versions tested9 rows
Luke code: LUCENE-2562 "Add Luke as a Lucene module" (3a78c1999a87 on main, 9915056cbc83 on branch_8x, Tomoko Uchida (mocobeta), 2019-04-10; first release 8.1.0), written in DmitryKey/luke#163 (2019-03-21). Exposed by LUCENE-9449 "Skip docs with _doc sort and 'after'" (99220677fe40, apache/lucene-solr#1725, mayya-sharipova, 2020-09-08; branch_8x backport 75421682100, #1856, first released in 8.7.0), an intended, documented core change that touched no Luke file. That #1856, not the LUCENE-9384 backport #1610 of the same release, causes the flip is by reading: #1610 is opt-in through SortField#setCanUsePoints, which Luke never calls. Between 8.6.3 and 8.7.0 Luke changes only one test file. On an index sorted by the sort field, early termination gave a lower bound before 8.7.0 too, so that narrower case reaches back to 8.1.0. Not fixed upstream: SearchImpl.java is identical on upstream main 2f424fd428d (2026-10-30) and branch_11x. Core's sort pruning changed there with PR #16857 (70a428b5ba9, merged 2026-10-29), which keeps IndexSearcher's 1,000-hit default and the relation flip in TopFieldCollector.countHit; with a core jar built from 2f424fd428d the repro still fails (fact check, mixed classpath).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails through gradle with assertions on and off: "1001+ hits" |
| 10.6.0 | affected | fails with -ea and -da (released jars) |
| 10.5.2, 9.12.3 | affected | fails, -ea and -da |
| 9.0.0, 8.11.4 | affected | fails (one-line import port) |
| 8.7.0 | affected | fails with -ea and -da: first affected release |
| 8.6.3 | not affected | passes with -ea and -da: TotalHits(1001, EQUAL_TO), same Luke code |
8.6.3, index sorted by n | variant | "1001+ hits" through index-sort early termination (history-only variant, not the repro) |
upstream main 2f424fd428d (its core jar) | mixed classpath | the repro still fails ("1001+ hits") and the side check is unchanged, with lucene-core built from it on the identical Luke code (fact check) |
| branch_11x, branch_10_6 | read | same sorted branch and the same 1,000-hit searchAfter |
Reproduced by the finder, executing cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier, the adjudication, the registry re-runs and this doc's gradle runs. All say real; the adjudication classed it minor, Low.
Checks and verdicts511 words
Every reproduction ran on stock builds; the fix checks ran with the patched SearchImpl. The cross-model reviews ran on the earlier main ea57360912ed, where the Luke search code and the core collector code are identical to 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| Finder (Luke search unit), bounded-exhaustive model | candidate, Low | The one test fails on main (-ea, -da, 5 of 5 random-seed iterations), 10.5.2 and 9.12.3; the model finds the exact count ignored in every sorted configuration over 1,000 hits; a mutant reference shows the check is not vacuous. |
| Cross-model refute review (OpenAI GPT-5.6, executed) | real, high | Own test (reverse STRING sort, TermQuery, page size 7, 1,009 matches) returns a lower bound under three seeds and -da; controls pass; with the fix TestSearchImpl passes 17 of 17. |
| Independent reproducer | real | Own test written from the claim only (1,200 documents, STRING sort built like the Search tab) fails on main, 10.5.2 and 9.12.3 ("1200+ hits"); passes with the fix; side check "8301+" for 20,000. |
| Cross-model adversary, intended lens (OpenAI GPT-5.6, executed) | real, high | The reviewer's own test ("1005+ hits" for 1,005) fails on main, 10.5.2 and 9.12.3 (on 9.12.3 through a compatibility variant of the test; the plain version did not compile there); three controls pass. Its own reading was that the mismatch existed when Luke was imported. The verifier, checking that evidence, ran a core-only history check: exact on 8.1.0-8.6.3 and a lower bound from 8.7.0; only on an index sorted by the sort field was it a lower bound already on 8.1.0 and 8.6.0, so the reviewer's reading holds only for that case. |
| Devil's advocate | real, Low | Re-runs fail on main and 10.5.2; a core-only bisect and the history point to an unadapted dependency on a core detail, not a design choice. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test (-ea) and the finder's (-da) on main: both fail; corrected the first affected release to 8.7.0. |
| Security screen | none | A hit-count display in a local desktop tool; no guarantee for untrusted input is involved. |
| Registry re-runs and audit | confirmed, Low | Re-runs fail on main with -ea and -da; the one-test minimal repro fails on main and passes with the fix. |
| This doc's runs (2026-11-01) | reproduced, fixes validated | Gradle on 645d4fe14a20: the repro fails with assertions on and off; the recommended fix passes the Luke test suite (98 tests, 0 failures) both ways and the ECJ and forbidden-apis checks; 10.6.0 fails. |
| Fact checks (2026-11-01) | real; text errors fixed | An independent fact check re-ran the repro on 645d4fe14a20 (-ea, -da, two seeds), the 10.6.0 jars and both fixes (the repro and TestSearchImpl, 18 of 18), and, with a core jar from upstream main 2f424fd428d, the repro and the side check (unchanged). A cross-model fact check (OpenAI GPT-5.6) read the code and history and ran no Java. Their 6 and 3 text errors are fixed in this doc. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,648 words
- Registry title
- Luke: a sorted search ignores exactHitsCount, so a sorted Search-tab query with more than 1,000 hits shows a lower-bound total ('1001+ hits') although the Sort tab forces 'exact hits count' on
- Area
- luke / Search model, sorted search (SearchImpl.search) behind the Luke Search tab
- Priority
- Low, rank 1079: This Luke-only cosmetic bug, a lower-bound '+' hit total on sorted searches even though the exact-count box is ticked, is a harmless display issue.
- Impact class
- minor
- Impact note
- Hits only people using the Luke desktop tool (lucene/luke, started with bin/luke.sh or bin/luke.cmd from the binary distribution) who choose a sort field in the Sort tab and run a search that matches more than 1,000 documents: the total is shown as a lower bound ('1001+ hits') next to a ticked, greyed-out 'exact hits count' box, and with a selective query on a sort field that also has points the number can be far below the real count (a scratch run by the independent reproducer on main: '8301+ hits' for 20,000 matches); the sorted result pages are correct, nothing in the index changes, core searches are not affected, no scale beyond 1,000 hits is needed, and every release since 8.7.0 has it.
- Summary
- You open an index in Luke, pick a sort field in the Sort tab (Luke then ticks and greys out 'exact hits count') and run a search that matches more than 1,000 documents; you expect the exact total, as the javadoc of the Sort overload of Search.search promises ('if set to true, the exact total hits count is returned'). Instead Luke shows a lower bound such as '1001+ hits', and there is no way to get the exact count of a sorted search. The cause is that SearchImpl.search() passes exactHitsCount only to the unsorted collector; the sorted branch calls IndexSearcher.searchAfter(after, query, pageSize, sort), which counts exactly only up to 1,000 hits.
- Introduced
- Exposed by 99220677fe40 (main, LUCENE-9449 'Skip docs with _doc sort and "after"', apache/lucene-solr#1725, mayya-sharipova; migrated issue #10489) and its branch_8x backport 75421682100 (apache/lucene-solr#1856), first released in 8.7.0 [exec bracket: the same test and the same Luke code pass on 8.6.3 and fail on 8.7.0; that #1856 rather than the LUCENE-9384 backport #1610 of the same release causes the flip is read, not executed: #1610 is opt-in through SortField#setCanUsePoints, which Luke never calls]. The core change is intended and documented (CHANGES 8.7.0, API Changes) and touched no Luke file. The defective Luke code is older: the sorted branch without the threshold came into Lucene with LUCENE-2562 'Add Luke as a Lucene module' (3a78c1999a87 main / 9915056cbc83 branch_8x, Tomoko Uchida (mocobeta), 2019-04-10; issue #3636) and was written in the standalone Luke's DmitryKey/luke#163 (2019-03-21), which added exactHitsCount, applied it only to the unsorted branch and made the Sort pane force it on [read: blame on 645d4fe14a20; the 2019 design matched core then, because a field-sorted searchAfter still counted exactly]. (2020-09-08 (99220677fe40, LUCENE-9449 on main); 2020-09-17 (backport 75421682100, first release 8.7.0); the Luke code dates from 2019-04-10 (LUCENE-2562 import) and 2019-03-21 (DmitryKey/luke#163))
- First affected version
- 8.7.0 [exec: 8.6.3 passes, 8.7.0 fails, same Luke code] for the default case (an index not sorted by the sort field); on an index sorted by the sort field, early termination already gave a lower bound from Luke's first release 8.1.0 [exec on 8.6.3 with an index-sorted variant; read for 8.1.0-8.6.2]
- Affected versions
- [exec] fail with the finder's test: 8.7.0 (-ea, -da), 8.11.4, 9.0.0, 9.12.3 (-ea, -da), 10.5.2 (-ea, -da) and main 645d4fe14a20 (-ea, -da; re-run at the registration check 2026-10-29: fails 1 of 1 with -ea, seed 5EED5A01, and -da, seed 5EED5A02; the independent reproduction's test fails 1 of 1, seed 5EED5A03); pass on 8.6.3 (-ea, -da). Core-only bisects of the same searchAfter call (the cross-model review, the intended-behaviour check): exact on 8.1.0-8.6.3, lower bound from 8.7.0 on. [read] 8.8.x-8.10.x and the intermediate 9.x/10.x releases (the sorted branch is unchanged since 2019 and TopFieldCollector has flipped the relation since 8.7.0); branch_10x (657014b3c579) has the same sorted branch and the same 1,000-hit IndexSearcher.searchAfter. 10.6.0 (released from branch_10_6 4ae946e5a9b) [exec 2026-11-01]: the one-test minimal repro fails on the release jars (SHA-1 checked against Maven Central) with -ea and -da. Not affected in the default case: 8.1.0-8.6.3. Not fixed upstream: main HEAD is still 645d4fe14a20 and the newest commit under lucene/luke/.../models/search is 30c6deb8b65c (2026-04-01, #15837, a refactor) [GitHub API, 2026-10-29]. All runs: JDK 25 on aarch64 Linux; 8.x use the Luke jar of the official binary distribution (Lucene 8.x published no Luke Maven artifact) and 8.x/9.0.0 a one-line test-import port.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (searched 2026-10-29, read-only; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree; refreshed the same day): a scripted prior-report search (55 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; no hit reports the bug), manual GitHub searches (exactHitsCount, resetExactHitsCnt, exactHitsCntCB, luke 'exact hits', 'hits count', 'total hits', sort threshold, GREATER_THAN_OR_EQUAL_TO, SortPaneProvider, SearchImpl: 0 related items), the module:luke items, the pre-module tracker DmitryKey/luke, apache/lucenenet and paulirwin/neoluke, every issue and PR created 2026-10-26..29, the open PRs with Luke in title or labels and their file lists (none touches SearchImpl.java, SortPaneProvider.java or SearchPanelProvider.java), and the commits under lucene/luke/.../models/search on main and branch_10x. Related, not reports: DmitryKey/luke#163 (2019, origin of the flag and of the forced exact=true for sorts) and the Luke 8.0.0 announcement on java-user@ (the checkbox is there 'to return the accurate total hit count'); #3636 (LUCENE-2562, the module import); LUCENE-9449 (#10489), LUCENE-9280 and LUCENE-8060 (#9108), the core changes that made sorted totals a lower bound, none of which mentions Luke; open PR #16847 (long doc ids) makes one- to three-line changes in four Luke files (DocumentsImpl.java, OverviewImpl.java, MLTConfig.java and SearchResults.java), none in SearchImpl.java, Search.java, SortPaneProvider.java or SearchPanelProvider.java [GitHub API, 2026-11-01]; PR #16857 (merged 2026-10-29 as 70a428b5ba9, after 645d4fe14a20) consolidates core sort pruning and does not change the 1,000-hit default of searchAfter; #15828 and #15837 are refactors.
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (expected:<1001 hits> but was:<1001+ hits>); with the recommended fix it passes, the whole:lucene:luke:test(98 tests, 17 skipped by a class-level codec suppression) passes both ways, and the module's ECJ and forbidden-apis checks pass. The released 10.6.0 jars fail the test with-eaand-da. Earlier, it ran on 8.6.3 (pass) and 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 (fail), and standalone on main with-eaand-da.SearchImpl.javais identical on upstream main2f424fd428dandbranch_11x(read); core's sort pruning there changed with PR #16857 (merged 2026-10-29), which keeps the 1,000-hit default and the relation flip, and the repro still fails with a core jar built from2f424fd428d(fact check, mixed classpath).
- Finding record
lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:308-320 (at 645d4fe14a20) (search(): sorted branch 'topDocs = searcher.searchAfter(after, query, pageSize, sort);' at :314; the exactHitsCount threshold 'exactHitsCount ? Integer.MAX_VALUE : DEFAULT_TOTAL_HITS_THRESHOLD' only in the unsorted branch at :316), :74 (DEFAULT_TOTAL_HITS_THRESHOLD = 1000)lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:96-114 (at 645d4fe14a20) (Sort overload of search: '@param exactHitsCount - if set to true, the exact total hits count is returned.' at :104)lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:247-261 (at 645d4fe14a20) (resetExactHitsCnt: a chosen sort field calls enableExactHitsCB(false) and setExactHits(true))lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:523-532 (at 645d4fe14a20) (passes the Sort tab's Sort, DEFAULT_PAGE_SIZE and exactHitsCntCB.isSelected()), :614-615 (populateResults shows String.valueOf(res.getTotalHits()), e.g. '1001+ hits')lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:719-733 (at 645d4fe14a20) (searchAfter with a Sort: new TopFieldCollectorManager(rewrittenSort, cappedNumHits, after, TOTAL_HITS_THRESHOLD)) and lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:76-90 (countHit: a non-exhaustive collection past the threshold sets GREATER_THAN_OR_EQUAL_TO)lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java (at 645d4fe14a20) ('Models and internal APIs for Luke'); lucene/luke/src/java/module-info.java (no exports); lucene/luke/src/test/org/apache/lucene/luke/models/search/TestSearchImpl.java testSearchWithSort (10 documents, below the threshold)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:195-202, 247-261a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:221 (Sort sub-tab), 392-396 ("Total docs:"), 523-532, 614-615, 739-747a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:85, 96-114a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:74, 295, 308-320a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 691-692, 719-740a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:77-89, 336-356a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHits.java:53-55a Lucene main checkout (645d4fe14a20) lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/messages/messages.properties:219, 226
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/distribution/src/binary-release/bin/ (luke.sh, luke.cmd)a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java; lucene/luke/src/java/module-info.java (no exports)a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:247-261a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:621-623; lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:344-346, 353-360 (Next and the end-of-results check)
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:525-532; .../fragments/search/FieldValuesPaneProvider.java:111-122; lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java:71-73 (fieldsToLoad)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MatchAllDocsQuery.java:31-34 (deprecated constructor)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:85, 104a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:247-261a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:314, 316a Lucene main checkout (645d4fe14a20) lucene/luke/src/test/org/apache/lucene/luke/models/search/TestSearchImpl.java:73-143 (50 documents), 305-315
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollectorManager.java:50-54 ("Integer#MAX_VALUE may be used to make the hit count accurate, but this will also make query processing slower")a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:349-352 (COMPLETE_NO_SCORES at Integer.MAX_VALUE)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:719-733
- Prior reports
GitHub API, 2026-11-01: apache/lucene pulls/16847/files (four lucene/luke files: DocumentsImpl.java, OverviewImpl.java, search/MLTConfig.java, search/SearchResults.java)
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:314, 316; a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:256-258; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 733; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:83-88
- TestSortedSearchExactHitsCount.java (one test class):
repro/LFV-1036/TestSortedSearchExactHitsCount.java(57 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed repro; see the diagram's own notes for what each frame shows.
assets/LFV-1036/bug.png(503 KB)
