All findings · Lucene finding · LFV-1036 · luke-sorted-search-ignores-exacthitscount

Luke: a sorted search ignores "exact hits count" and shows a lower-bound total ("1001+ hits")

In Luke's Search tab, picking a sort field ticks and locks the "exact hits count" box, yet a sorted search with more than 1,000 hits still shows a lower bound: "1001+ hits" for 1,001 matches, "8301+ hits" for 20,000. Since 8.7.0; the listed result pages are correct.

Summary

Priority
Low, rank 1079This 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
minor
Since
8.7.0 (core LUCENE-9449, 2020); 8.6.3 counts exactlyintroduced 2020-09-08 · about 6 years
Affected
8.7.0 to 10.6.0, and main
Status
documented
Found by
a Search-tab GUI probe, then a bounded-exhaustive enumeration of Luke's search calls

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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+".

Background: exact and lower-bound hit counts

  1. To save work, a Lucene search may stop counting matches early. The total is then a lower bound (TotalHits relation GREATER_THAN_OR_EQUAL_TO), printed with a "+": "1001+ hits".
  2. IndexSearcher.searchAfter(after, query, n, sort) counts exactly up to 1,000 hits. A collector built with the threshold Integer.MAX_VALUE counts every hit.
  3. 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.
Diagram of the bug: Luke's Search tab with a sort field chosen, the 'exact hits count' box ticked and greyed out, and 1,001 matching documents. SearchImpl.search() uses the exact flag only in the unsorted branch; the sorted branch calls IndexSearcher.searchAfter, whose collector stops counting exactly after 1,000 hits, so the total reads '1001+ hits'. Giving the sorted branch the same threshold as the unsorted one gives '1001 hits'.
A sorted search over 1,001 documents with "exact hits count" forced on: the flag reaches only the unsorted branch, the sorted branch uses core's 1,000-hit threshold, and the total shows "1001+ hits". Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1,001 documents with a numeric doc-values field n, open the index in Luke and pick n in the Search tab's Sort sub-tab. Luke ticks the "exact hits count" box and greys it out.
  2. Search *:*. You expect "Total docs: 1001 hits": Luke's sorted Search.search documents that with the flag set "the exact total hits count is returned".
  3. You get "1001+ hits", a lower bound. With a LongField sort field (points and doc values) and a term query matching all 20,000 documents, it shows "8301+ hits".
  4. Why: SearchImpl uses the flag only for unsorted searches. The sorted branch calls IndexSearcher.searchAfter(after, query, 10, sort), which since 8.7.0 counts exactly only up to 1,000 hits.
  5. 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.

Who is affected

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.

Mechanism (Lucene main 645d4fe14a20)

  1. 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 and exactHitsCntCB.isSelected() to the model (SearchPanelProvider.java:530-532).
  2. The contract. The Sort overload of Search.search says "exactHitsCount - if set to true, the exact total hits count is returned." (Search.java:104; the unsorted overload says the same at :85).
  3. The flag is read in one branch only. SearchImpl.search() (SearchImpl.java:308-320): the unsorted branch builds TopScoreDocCollectorManager(pageSize, after, exactHitsCount ? Integer.MAX_VALUE : 1000) (:316-318); the sorted branch calls searcher.searchAfter(after, query, pageSize, sort) (:314) and never looks at the flag.
  4. Core counts exactly only up to 1,000. searchAfter builds TopFieldCollectorManager(sort, n, after, TOTAL_HITS_THRESHOLD) with TOTAL_HITS_THRESHOLD = 1000 (IndexSearcher.java:100, :733). Below Integer.MAX_VALUE the collector is non-exhaustive (TopFieldCollector.java:349-350), and at hit 1,001 countHit sets GREATER_THAN_OR_EQUAL_TO (:83-88).
  5. The display. populateResults shows String.valueOf(res.getTotalHits()) (SearchPanelProvider.java:615), and TotalHits.toString() appends "+" for a lower bound (TotalHits.java:53-54): "Total docs: 1001+ hits".

How far off the number is

  • 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. A TermQuery matching 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 main 2f424fd428d).

Workaround

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.cmd in the binary distribution) who choose a sort field in the Search tab. Also code that calls Luke's Search model with a Sort and exactHitsCount=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-623 enables it for any lower-bound total), and nextPage skips 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.

Minimal repro

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

TestSortedSearchExactHitsCount.java (one test class)57 lines
/*
 * 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());
      }
    }
  }
}
Command
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.

All files of this repro: repro/LFV-1036/

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also 10.6.0, 10.5.2, 9.12.3; with the recommended fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • The javadoc of the sorted Search.search says 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.search overloads 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.resetExactHitsCnt ticks 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-sorted searchAfter still counted every hit (unless it early-terminated on an index sort). LUCENE-9449 (99220677fe40, backport 75421682100, first release 8.7.0) made TopFieldCollector report GREATER_THAN_OR_EQUAL_TO for 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 and testSearchWithSort'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.

Suggested fix

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.

Give the sorted branch the unsorted branch's threshold (validated) (recommended)

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; numHits and sort.rewrite mirror what searchAfter does 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's after.doc range check, which cannot fire here: after is 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 by TestDocumentsImpl's class-level @SuppressCodecs under this seed) passes with 0 failures with assertions on and off, TestSearchImpl 17 of 17 and the repro included; ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest pass; checkGoogleJavaFormat flags only the test's comment line, not SearchImpl.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.

Keep searchAfter, then count exactly when needed (validated)

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.

Change the contract instead (not run, not recommended)

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.

Downstream note

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.

How it was found

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_TO with 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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 web2026-10-29scripted 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 modelno report; related items only
GitHub issues and PRs (apache/lucene); commits under lucene/luke/.../models/search2026-11-01exactHitsCount; luke "exact hits"; luke sort total hits; luke updated since 2026-10-28; SearchImpl sort; luke "hits count"; newest commit on the pathonly #1869 (LUCENE-794) and #3636, both unrelated; newest commit still 30c6deb8b65c (#15837, refactor)

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails through gradle with assertions on and off: "1001+ hits"
10.6.0 affectedfails with -ea and -da (released jars)
10.5.2, 9.12.3 affectedfails, -ea and -da
9.0.0, 8.11.4 affectedfails (one-line import port)
8.7.0 affectedfails with -ea and -da: first affected release
8.6.3 not affectedpasses 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 classpaththe 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 readsame sorted branch and the same 1,000-hit searchAfter

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (Luke search unit), bounded-exhaustive model candidate, LowThe 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, highOwn 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 realOwn 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, highThe 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, LowRe-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, highRe-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 noneA hit-count display in a local desktop tool; no guarantee for untrusted input is involved.
Registry re-runs and audit confirmed, LowRe-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 validatedGradle 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 fixedAn 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,648 words

Finding record

LFV-1036, snapshot of 2026-11-06

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 -ea and -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 -ea and -da. SearchImpl.java is identical on upstream main 2f424fd428d and branch_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 from 2f424fd428d (fact check, mixed classpath).

Lucene and upstream sources

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-261
  • a 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-747
  • a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:85, 96-114
  • a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:74, 295, 308-320
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 691-692, 719-740
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:77-89, 336-356
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHits.java:53-55
  • a 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-261
  • a 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, 104
  • a Lucene main checkout (645d4fe14a20) lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java:247-261
  • 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/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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.