All findings · Lucene finding · LFV-1035 · luke-nextpage-past-last-page-returns-empty-page

Luke: 'next' after the last full page of a search with more than 1,000 hits shows an empty page and disables 'previous'

In Luke's Search tab with default settings, a search with more than 1,000 hits and a full last page (say 1,010) keeps 'next' enabled there, and one more click shows an empty page with 'previous' disabled. Search.nextPage() returns a 0-hit page with offset 0 instead of an empty result.

Summary

Priority
Low, rank 1078A recoverable display glitch in the Luke desktop tool, where paging to the end of more than 1,000 hits shows an empty page while results and the index stay correct.
Impact
minor
Since
8.1.0 (LUCENE-2562, the Luke module, 2019-04-10)introduced 2019-04-10 · about 7 years
Affected
8.1.0-10.6.0 and main
Status
documented
Found by
non-formal code probe, then an exhaustive enumeration of paging 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

Luke's model hands the Search tab an empty page when a lower-bound total hides where the results end.

Background: how Luke's Search tab pages

  1. The Search tab shows 10 hits per page. Its 'next' and 'previous' buttons ask Luke's search model, Search.nextPage() and prevPage(), for another page; when a page comes back, it replaces the table, the range label and the buttons' state.
  2. The model remembers the hits fetched so far. Past the last remembered hit it runs the query again, asking for the 10 hits after that hit.
  3. Unless 'exact hits count' is ticked, Lucene stops counting at 1,000 hits once a page is full and reports a lower bound, shown as '1001+ hits'. With such a total nobody knows where the results end, so the tab keeps 'next' enabled.
Diagram of the bug: Luke's Search tab pages through a 1,010-hit search 10 hits at a time. On page 101 (hits 1001 to 1010) the total reads '1010+ hits', a lower bound, so 'next' stays enabled. Clicking it makes nextPage() move to page 102, search, find 0 hits and return that as a page with offset 0; the tab shows an empty table, the range '1 ~ 0' and 'previous' disabled. With the fix the click returns empty and page 101 stays on screen.
Paging a 1,010-hit search in Luke: the lower-bound total keeps 'next' enabled on page 101, and the extra click returns an empty page with offset 0, shown as '1 ~ 0' with 'previous' disabled. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. In Luke you open an index with 1,010 documents and search *:* with the Search tab's defaults: page size 10, no sort, 'exact hits count' unticked. Past 1,000 hits Lucene stops counting exactly, so the total reads '1001+ hits'.
  2. You click 'next' 100 times and reach page 101, hits 1001 ~ 1010, total '1010+ hits'. Because the total is only a lower bound, Luke keeps 'next' enabled.
  3. You click 'next' once more and expect nothing to happen: there is no page 102, and nextPage() promises an empty result when there are no more results.
  4. Instead nextPage() has already moved to page 102, runs a search that finds 0 hits and returns it as a page, and that empty page reports offset 0. The tab shows an empty table, the range '1 ~ 0' and 'previous' disabled: the 1,010 results are reachable only by searching again.
  5. Only a full last page past 1,000 hits strands: 1,010, 1,500 or 2,000 hits do. 1,000 hits does not (never more than 1,000 hits, so the total is exact), nor do 1,005 or 1,234 (a partial last page comes back with an exact total); there Luke disables 'next' on the last page.

Who is affected

People using the Luke desktop tool who click 'next' to the end of a search with default settings: more than 1,000 hits, a multiple of 10, so 101 or more clicks. 'More Like This' takes the same path; sorted searches strand through a companion bug. Results shown were right; the index is untouched.

Mechanism, terms and numbers607 words

Luke's Search tab pages through results 10 at a time. Unless 'exact hits count' is ticked, Lucene counts hits exactly only up to 1,000, so a bigger result reports a lower-bound total such as '1010+ hits', and Luke keeps 'next' enabled because it cannot tell from the total where the results end. On the last page of a result whose last page is full (more than 1,000 hits, a multiple of 10), one more 'next' click calls SearchImpl.nextPage(), which first adds 1 to its page counter, skips its end-of-results check (that check runs only for exact totals), searches after the last hit, finds nothing and returns the 0-hit result as a page. SearchResults.of sets a page's offset only inside its loop over the hits, so that page reports offset 0. The tab shows it: an empty table, the range '1 ~ 0', 'previous' disabled. Nothing in the index changes and the results shown before were right, but the user has to re-run the search. The javadoc of Search.nextPage() promises an empty result when there are no more results. The code came in with the Luke module in 8.1.0 and every release run since has it; a fix of about 20 lines in two files moves the page counter only to a page that exists and sets the offset outside the loop.

The bug

  1. SearchImpl.nextPage() adds 1 to its page counter first: currentPage += 1; (SearchImpl.java:342).
  2. Its end check, totalHits.value() == 0 || (relation == EQUAL_TO && currentPage * pageSize >= value) (:344-349), fires only when the total is exact.
  3. Past the last remembered hit it runs a new search and returns Optional.of(search()) (:360), also when that search found 0 hits. search() builds the page with offset currentPage * pageSize (:331-332).
  4. SearchResults.of sets res.offset = offset; inside for (ScoreDoc sd : docs) (SearchResults.java:69-76, the assignment at :75), and the field starts at 0 (:40). A page with no hits therefore reports offset 0.
  5. The tab shows every page it gets: searchModel.nextPage().ifPresent(this::populateResults); (SearchPanelProvider.java:546-548). populateResults (:614-623) shows the range offset + 1 ~ offset + size, enables 'previous' only when offset > 0, and keeps 'next' enabled for a lower-bound total.

Why only a full last page

Without a sort and with 'exact hits count' off, Luke collects with a total-hits threshold of 1,000 (SearchImpl.java:74, :316-318). The collector reports a lower bound once it has counted more than 1,000 hits and its 10 slots are full (TopScoreDocCollector.java:154-163). A partial last page never fills them, so it comes back with an exact total ('1005 hits') and the tab disables 'next'. A full last page comes back as '1010+ hits', and the next click asks for a page that does not exist.

After the empty page

The empty page's own search counted every hit, so its total is exact ('1010 hits'). Later 'next' clicks return empty and do nothing; 'previous' stays disabled. For sorted searches the total stays a lower bound because of the companion bug LFV-1036, and every 'next' returns another empty page.

Same root, model API only (the tab's buttons never reach these): prevPage() also moves before it checks (SearchImpl.java:375), so prev after an empty next returns the last page again, next or prev after a prev on the first page throws LukeException ('Search session not started.'), and, when the last page is partial (e.g. 1 hit, page size 10), prev after two nexts past the end throws NegativeArraySizeException; with a full last page, as for 1,010 hits, that prev returns another 0-hit page.

Exposure and likelihood173 words
  • Who is hit. Users of Luke (lucene/luke, started with bin/luke.sh or bin/luke.cmd from the binary distribution) who page to the very end of a search.
  • Default vs opt-in. The defaults: the page size is fixed at 10 (SearchPanelProvider.java:98), no sort, and 'exact hits count' unticked (a plain checkbox, :142). The 'More Like This' search passes no sort and exactHitsCount false (:578-579), so it takes the same path [read]. Ticking 'exact hits count' avoids it for unsorted searches. Sorted searches over 1,000 hits strand for any hit count, through the companion LFV-1036 (the sorted path ignores exactHitsCount).
  • Scale. More than 1,000 hits, a full last page, and 101 or more 'next' clicks.
  • Effect. The tab loses its place: an empty table, '1 ~ 0', 'previous' disabled; re-running the search recovers. No exception in the GUI path, no wrong hits, no index change. Assertions on or off make no difference.
  • Likelihood. Rare: paging by hand to the end of more than 1,000 hits is unusual. Priority Low.

Minimal repro

One JUnit test on Luke's search model with the Search tab's paging arguments: 1,010 empty documents, MatchAllDocsQuery, no sort, page size 10, exact count off. The first 100 nextPage() calls return pages 2 to 101; the 101st must return empty. Deterministic, single thread, the luke module only.

TestNextPagePastLastPage.java (JUnit, one test)55 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 java.util.Optional;
import org.apache.lucene.document.Document;
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.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNextPagePastLastPage extends LuceneTestCase {

  // Expected: nextPage() past the last full page is empty; actual: a 0-hit page with offset 0.
  public void testNextPageAfterLastPage() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 1010; i++) {
          w.addDocument(new Document());
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Search search = new SearchFactory().newInstance(reader);
        search.search(
            new MatchAllDocsQuery(), new SimilarityConfig.Builder().build(), null, 10, false);
        for (int page = 1; page < 101; page++) {
          assertTrue(search.nextPage().isPresent());
        }
        Optional<SearchResults> pastEnd = search.nextPage();
        assertTrue(
            "got a page: size="
                + pastEnd.map(SearchResults::size).orElse(-1)
                + " offset="
                + pastEnd.map(SearchResults::getOffset).orElse(-1),
            pastEnd.isEmpty());
      }
    }
  }
}
Command
cp TestNextPagePastLastPage.java $LUCENE/lucene/luke/src/test/org/apache/lucene/luke/models/search/
cd $LUCENE && ./gradlew :lucene:luke:test --tests TestNextPagePastLastPage -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)
Expected
The 101st nextPage() call, past page 101 (hits 1001-1010), returns Optional.empty().
Observed
It returns a page with 0 hits and offset 0: AssertionError: got a page: size=0 offset=0, with assertions on and off.

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

More files, environment, full output246 words

The test uses the public SearchFactory and Search API; it sits in Luke's package only because TestSearchImpl does too. 1,010 is the smallest hit count past 1,000 with a full last page at page size 10. It passes null as the fields to load (all stored fields; the tab passes the fields ticked on its Field Values sub-tab); the documents store no fields, so this does not change the result. The file uses the deprecated MatchAllDocsQuery constructor so it also runs on older releases; on main, MatchAllDocsQuery.INSTANCE replaces it. As the regression test: this same test, for example as a method next to testNextPageNoMoreResults in TestSearchImpl.

Not shown by the test (the tab's button logic replayed on the model shows it; the Swing tab itself was not driven): the '1 ~ 0' range and the disabled 'previous' button.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 release; passes with the fix
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestNextPagePastLastPage.java $LUCENE/lucene/luke/src/test/org/apache/lucene/luke/models/search/
cd $LUCENE && ./gradlew :lucene:luke:test --tests TestNextPagePastLastPage -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; asserts=false identical)
   >     java.lang.AssertionError: got a page: size=0 offset=0
   >         at __randomizedtesting.SeedInfo.seed([DEADBEEF:207F0A726039F0BB]:0)
   >         at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
   >         at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
   >         at org.apache.lucene.luke.models.search.TestNextPagePastLastPage.testNextPageAfterLastPage(TestNextPagePastLastPage.java:46)
:lucene:luke:test (FAILURE): 1 test, 1  failure

# with the fix: :lucene:luke:test (SUCCESS): 1 test (assertions on and off)
# 10.6.0 release jars, assertions on and off: java.lang.AssertionError: got a page: size=0 offset=0

Why it looks unintended

  • Search.nextPage() promises 'search results, or empty if there are no more results', with no exception for lower-bound totals.
  • For exact totals it already returns empty after the last page, and the upstream test testNextPageNoMoreResults asserts that.
  • The lower-bound branch came with a Lucene 8.0 upgrade that never handled an empty next page and tested only exact counts.
The full argument183 words
  • Contract. Search.java:116-122: 'Returns the next page for the current query. @return search results, or empty if there are no more results'. SearchResults.java:49 and :86: the offset is 'the offset of the current page'.
  • The exact branch and its test. The EQUAL_TO branch (SearchImpl.java:344-349) returns Optional.empty() after the last page; TestSearchImpl.testNextPageNoMoreResults (TestSearchImpl.java:335-341, 20 hits, page size 10) asserts that, with exactHitsCount=true. Every search(...) call in TestSearchImpl passes true.
  • History. The standalone Luke checked totalHits == 0 || currentPage * pageSize >= totalHits before Lucene 8.0, so a call past the end returned empty. DmitryKey/luke#163 ('Upgrade lucene 8.0.0', 2019-03-21) switched to the EQUAL_TO-only check when Lucene 8.0 made totals a lower bound past 1,000 hits (LUCENE-8060) and kept 'next' enabled for such totals, without handling an empty next page; every test it updated passes exactHitsCount=true. This is consistent with an overlooked case; it does not prove intent either way, and the captured record of #163 (PR title and body, patch, commit) does not mention the empty page. The standalone luke-swing-7.7.0 passes the test; luke-swing-8.0.0 fails it.
The devil's-advocate check180 words

The strongest case for 'intended' (built by the devil's advocate and the reviews from another model family, OpenAI GPT-5.6; rated moderate): Luke's module exports no package and its models package calls itself 'Models and internal APIs for Luke', so the Search javadoc binds only Luke; and with a lower-bound total the model can only learn that the results ended by searching once more, so an empty page could be read as that probe's honest answer.

Why it fails: the bug does not rest on the API. The Search tab itself, with its own arguments and button logic, ends on the empty page with 'previous' disabled, and the repro uses those paging arguments (it passes null fields to load, meaning all stored fields, where the tab passes its ticked fields; the documents store none, so it does not matter). The probe is legitimate, but once it finds 0 hits the model knows there are no more results and still returns the probe as a page, with a wrong offset. The fix keeps the probe internal. All checkers rated the bug real.

Suggested fix

Recommended Move only to a page that exists; set the offset outside the loop

Move the page counter only to a page that exists: when the extra search past the end finds 0 hits, undo the move and return empty. Make prevPage() refuse at the first page before moving, and set the page offset after the loop in SearchResults.of. About 20 lines in two files; validated with the repro and Luke's tests.

      } else {
-        return Optional.of(search());
+        int prev = currentPage;
+        currentPage = next;   // next = currentPage + 1, used by the end check above
+        SearchResults res = search();
+        if (res.size() == 0) {
+          currentPage = prev;   // no such page: stay where we are
+          log.warning("No more next search results are available.");
+          return Optional.empty();
+        }
+        return Optional.of(res);
       }
All options and trade-offs3 options ยท 605 words

The finder's patch was applied to a scratch worktree of 645d4fe14a20 and run through gradle. Moving the offset assignment alone is not a fix.

Move only to a page that exists; set the offset outside the loop (validated by the checks) (recommended)

In nextPage(), compute next = currentPage + 1 and use it for the end check and the cached path; on the search path, move, search, and if the result has no hits move back and return Optional.empty(). In prevPage(), return empty when currentPage <= 0 before decrementing. In SearchResults.of, move res.offset = offset; after the loop.

      } else {
-        return Optional.of(search());
+        int prev = currentPage;
+        currentPage = next;   // next = currentPage + 1, used by the end check above
+        SearchResults res = search();
+        if (res.size() == 0) {
+          currentPage = prev;   // no such page: stay where we are
+          log.warning("No more next search results are available.");
+          return Optional.empty();
+        }
+        return Optional.of(res);
       }

Trade-offs

The extra search at the end still runs (with a lower-bound total it is the only way to learn that the results ended); only its empty result stays internal, and it adds no hits to the cache. In the tab the extra click now leaves the last page on screen with 'next' still enabled, doing nothing, as for any empty answer; disabling 'next' then would be separate GUI polish (not run). The empty probe search still replaces the session's total with the exact one, so a later 'previous' shows '1010 hits' instead of '1010+ hits' (accurate and harmless). As on stock code, an IOException from the probe search leaves the page counter moved. Of the model-API-only problems, nextPage() staying put when it returns empty removes the NegativeArraySizeException and the repeated last page, and the prevPage() change removes the LukeException after prev on the first page; the offset change removes neither (executed by the independent fact check, one hunk at a time).

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (2026-11-01): the repro fails on stock with assertions on and off and passes with the fix, on and off; org.apache.lucene.luke.models.search.* (the repro and the 17 upstream TestSearchImpl tests) passes with seeds DEADBEEF and CAFEBABE; the whole :lucene:luke:test passes with seed DEADBEEF (12 classes, 98 tests, 0 failures; 17 tests skipped, all in TestDocumentsImpl per the JUnit XML reports of the independent fact check's re-run).
  • Earlier, with the fixed classes shadowed in: the minimal test and the finder's, the independent reproducer's and the cross-model adversary's tests pass; the refute-lens cross-model review ran the 17 upstream TestSearchImpl tests with the fix, 0 failures.
  • Exhaustive enumeration: with this fix only the companion's sorted exact-count mismatches remain; with both fixes, 0 of 524,640 call sequences and 0 of 37,952 click sequences diverge from the contract.

Only move res.offset = offset out of the loop (not a fix)

Looks like a one-liner.

Trade-offs

nextPage() still returns a 0-hit page. In the default unsorted case it now shows as '1011 ~ 1010' with 'previous' enabled and 'next' disabled (the probe's total is exact), so 'previous' gets back to page 101. For sorted searches over 1,000 hits, where the companion LFV-1036 keeps the total a lower bound, next, next, previous through the tab's own buttons then throws NegativeArraySizeException (exhaustive enumeration, variant C; re-run by the independent fact check).

Ignore 0-hit pages in the Search tab (not recommended)

Make the tab's nextPage or populateResults skip a page with no hits.

Trade-offs

Hides the symptom in the tab but leaves Search.nextPage() breaking its javadoc and the model-API exceptions in place. Not run.

How it was found

While checking Luke's search paging, a code probe first saw the page counter move before the end check, and sorted searches end on an empty page. An exhaustive enumeration of every short sequence of next, previous and search calls, and of Search-tab clicks, compared with the documented contract, then found this default-settings case.

The method in full191 words

Method: non-formal first signal, then a bounded-exhaustive enumeration. Earlier executed probes on the Luke search model (one through the model API, one clicking only the buttons the Search tab enables) showed the move-before-check pattern and sorted searches over 1,000 hits ending on an empty page with offset 0, but judged unsorted paging safe.

The hunt unit for Luke's search paging then enumerated the paging state machine on the stock build: every sequence of up to 6 calls over next, previous and search after an initial search, over 480 configurations (hit counts 0 to 2,000, page sizes 1 to 1,000, exact count on and off, three sorts, 1 or 3 segments), 524,640 sequences in all, compared after each call with a reference written from the javadoc; and every click sequence of up to 5 that uses only enabled buttons, compared with the tab's own button logic. It found the unsorted, default-settings trigger. Fixing this root alone removes every mismatch except the companion's; fixing both leaves 0 of 524,640 call sequences and 0 of 37,952 click sequences diverging. A deliberately wrong reference fails 229,548 and 15,532 sequences, so the check is not vacuous.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-29, refreshed 2026-11-01): GitHub issues and PRs including the JIRA archive, all 70 Luke-module items, the pre-module Luke tracker, the Lucene mailing lists and the web. Open PR #16847 edits the neighbouring line of SearchResults.of for another reason.

Searches4 searches

Closest items, none a report of this bug: #3636 (LUCENE-2562, the module import; its comments mention SearchImpl only for forbidden-apis and logging); DmitryKey/luke#163 (where the lower-bound branch came in, without a report); open PR #16847 (long global doc ids; changes Doc.of(...) next to res.offset = offset, not the offset or nextPage); #15828 and #15837 (refactors); #13573 (Luke spanNear parsing). A search cannot prove that no report exists.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues), commits, downstream repositories, mailing lists, web2026-10-29scripted prior-report searches, two runs (51 and 54 queries: GitHub, commits, Solr, Elasticsearch, OpenSearch, dev@, java-user@, issues@, commits@, JIRA archive, web; its GitHub queries include luke nextPage, luke prevPage and prevPage, 0 hits each)no report; the medium hits match only the generic word 'Search'
apache/lucene, by hand2026-10-2922 searches (SearchPanelProvider, TestSearchImpl, testNextPageNoMoreResults, 'Search session not started', luke 'lower bound', luke GREATER_THAN_OR_EQUAL_TO, ...); all 70 module:luke items; changed files of every open Luke PR and of the newest open PRsnothing about Luke paging; only #16847 touches SearchResults.java
Pre-module and related trackers2026-10-29DmitryKey/luke issues and SearchImpl history; apache/lucenenet; paulirwin/neolukeno report
Refresh2026-11-01GitHub: luke nextPage, luke 'empty page', luke issues and PRs created since 2026-10-28; commits on lucene/luke0 items; no Luke commit since 2026-09-01

History

Since 8.1.0, the first release with the Luke module (LUCENE-2562, 3a78c1999a87, 2019-04-10). The lines come from the standalone Luke's Lucene 8.0 upgrade (DmitryKey/luke#163), when totals became lower bounds past 1,000 hits. The test fails on 36 releases from 8.1.0 to 10.6.0 and on main.

Versions tested7 rows

Introduced by LUCENE-2562 'Add Luke as a Lucene module' (Tomoko Uchida; master 3a78c1999a87, branch_8x 9915056cbc83, both 2019-04-10; committed directly, issue #3636). git blame on 645d4fe14a20 still attributes currentPage += 1, return Optional.of(search()) and the in-loop res.offset = offset to it; later edits are mechanical. The lines were written in the standalone Luke repository by DmitryKey/luke#163 ('Upgrade lucene 8.0.0', a98e23ba8698, 2019-03-21), when Lucene 8.0 made totals a lower bound past 1,000 hits (LUCENE-8060, the enabling change, not a defect). 8.1.1 to 8.11.3 were not run; they lie between two failing releases with no known change to this code.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (standalone and through Gradle); passes with the fix
10.6.0 affectedminimal test fails with assertions on and off (release jars, SHA-1-checked)
10.0.0-10.5.2 (all 12 releases) affectedthe finder's 2,000-document test fails
9.0.0, 9.1.0-9.12.3 (all 20 releases) affectedthe finder's test fails (9.0.0 with a one-line test-import port)
8.1.0, 8.11.4 first affected: 8.1.0the finder's test fails (Luke jars from the official binary distributions; one-line test-import port)
8.0.0 no Luke modulecompile error: no Luke in this release
standalone Luke 7.7.0 / 8.0.0 origin7.7.0 (before DmitryKey/luke#163) passes; 8.0.0 (with it) fails

Independent checks

Confirmed with high confidence: an independent reproducer, a devil's advocate, reviews by a reviewer from another model family (OpenAI GPT-5.6) and an adjudication each reproduced it with their own test or the finder's; all rated it real and Low. The strongest 'intended' case (an internal API; the end needs one more search) was rejected.

Checks and verdicts284 words

Re-run at registry time, in the credibility audit and after minimisation to the 1,010-document test: still reproduces. The GPT reviews ran on the hunt's earlier main ea57360912ed, where lucene/luke is identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder (enumeration + JUnit) candidate, Low2,000-document test fails on main (assertions on and off, 5 of 5 iterations under one master seed), 10.5.2 and 9.12.3; the enumeration separates this root from the companion; the fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real, highOwn test (1,024 documents, page size 64) fails on main with two seeds and assertions off; the same input with exact count passes; the fix passes, TestSearchImpl 17 of 17.
independent reproducer realOwn test, written from the claim only, fails on main, 10.5.2 and 9.12.3 and passes with the fix; the replayed tab logic strands at 1,010 and 2,000 hits, not at 1,000 or 1,005.
cross-model adversary, intended lens (OpenAI GPT-5.6, executed) real, highOwn test fails on main, 10.5.2 and 9.12.3; its exact-count control passes; the fix passes.
intended-behaviour devil's advocate real, LowRe-ran the repro on main and 10.5.2; its tab-logic probe strands at 1,010, 1,500 and 2,000 hits, not at 1,001, 1,005, 1,009 or 1,234; history reading points to a migration oversight.
adjudication confirmed, highRe-ran the finder's test on main with assertions on and off; class minor, severity Low; corrected the 'repeated empty pages' wording (sorted searches only).
audit and this doc confirmed, LowConfirmed as minor; minimised to the one 1,010-document test; this doc re-ran it through gradle on 645d4fe14a20 (stock fails, fix passes) and on the 10.6.0 release.

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,341 words

Finding record

LFV-1035, snapshot of 2026-11-06

Registry title
Luke: Search.nextPage() after a full last page returns an empty page with offset 0 instead of Optional.empty() when the hit total is a lower bound, so with default settings and more than 1,000 hits the Search tab ends on an empty page with 'previous' disabled
Area
luke / Search model paging session (SearchImpl.nextPage, SearchResults.of) behind the Luke Search tab
Priority
Low, rank 1078: A recoverable display glitch in the Luke desktop tool, where paging to the end of more than 1,000 hits shows an empty page while results and the index stay correct.
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 click 'next' to the very end of a search: with the default settings (page size 10, no sort, 'exact hits count' unticked) a query, or a More-Like-This search [read], with more than 1,000 hits whose last page is full (the hit count a multiple of 10), and any sorted search with more than 1,000 hits through the companion lower-bound bug; the results already shown were right, nothing in the index changes, and re-running the search recovers, but the tab is left on an empty page with 'previous' disabled; no scale beyond 1,000 hits is needed, and every Luke release since 8.1.0 has it.
Summary
You open an index in Luke, run a search that matches more than 1,000 documents with the default settings and click 'next' up to the last page; when that page is full (for example 2,000 hits), the shown total is a lower bound ('2000+'), so 'next' stays enabled, and you expect one more click to do nothing, as Search.nextPage's javadoc says ('search results, or empty if there are no more results'). Instead nextPage() returns a page with no hits whose offset is 0: the results table empties, the range reads '1 ~ 0' and 'previous' is disabled, so the earlier pages can only be reached by running the search again. The cause is that nextPage moves its page counter before it knows the page exists and checks for the end only when the total is exact, and SearchResults.of sets the offset only inside its loop over the hits.
Introduced
3a78c1999a87 (master) and 9915056cbc83 (branch_8x), both 'LUCENE-2562: Add Luke as a Lucene module' by Tomoko Uchida (mocobeta), author date 2019-04-10, committed directly (issue #3636, no apache/lucene PR) [read: blame on 645d4fe14a20 attributes nextPage, search() and SearchResults.of's in-loop 'res.offset = offset' to it, later edits are mechanical (#13762, #544, reformatting, collector-manager API); exec: 8.0.0 has no Luke module, 8.1.0 fails]. The lines were written before the import in the standalone Luke repository: DmitryKey/luke#163 'Upgrade lucene 8.0.0' (commit a98e23ba8698, mocobeta, 2019-03-21) changed nextPage's end check from 'totalHits == 0 || currentPage * pageSize >= totalHits' to an EQUAL_TO-only check when Lucene 8.0 made totals a lower bound past 1,000 hits (LUCENE-8060, not a defect itself), without handling an empty next page; every test that commit updated passes exactHitsCount=true [exec: standalone luke-swing-7.7.0 passes, luke-swing-8.0.0 fails]. (2019-04-10 (LUCENE-2562 import, 3a78c1999a87 / 9915056cbc83); the code was first written 2019-03-21 in the standalone Luke repository (a98e23ba8698, DmitryKey/luke#163))
First affected version
8.1.0 [exec] (the first Lucene release with the Luke module; 8.0.0 and earlier have no Luke module [exec: compile error])
Affected versions
[exec] fail with the finder's test (seed DEADBEEF): 8.1.0, 8.11.4, 9.0.0, all 20 releases 9.1.0-9.12.3, all 12 releases 10.0.0-10.5.2, and main 645d4fe14a20 with -ea and -da (re-run at the registration check 2026-10-29: fails 1 of 1 with -ea, seed 5EED1A01, and -da, seed 5EED1A02; the independent reproduction's test fails 1 of 1, seed 5EED1A03). [read] 8.1.1-8.8.1 contain the import commit by tag; 8.8.2-8.11.3 lie between the failing 8.1.0 and 8.11.4 with no known change to this code; branch_10x 657014b3c57 has the same paging code as main. 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: 8.0.0 and earlier (no Luke module); the standalone luke-swing-7.7.0 passes. Not fixed upstream: upstream main is still 645d4fe14a20 and the newest commit under lucene/luke/.../models/search is 30c6deb8b65c (2026-04-01, #15837, a refactor) [read, GitHub API 2026-10-29]. All runs: JDK 25 on aarch64 Linux; 8.1.0 and 8.11.4 use the Luke jar of the official binary distribution (Lucene 8.x published no Luke Maven artifact), and 8.x/9.0.0 use 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): scripted prior-report searches (51 and 54 queries over GitHub issues, PRs and commits, Solr, Elasticsearch and OpenSearch, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; no hit reports the bug), manual GitHub searches (luke nextPage, prevPage, 'next page', 'empty page', paging, pagination, 'Search session not started', SearchPanelProvider, TestSearchImpl, lower bound: 0 related items), all 70 module:luke items, the pre-module tracker DmitryKey/luke, apache/lucenenet and paulirwin/neoluke, the changed files of every open Luke PR and of the 40 newest open apache/lucene PRs (none touches lucene/luke/ paging code), and the commits on main, branch_10x and branch_10_6 under lucene/luke/.../models/search. Related, not reports: #3636 (LUCENE-2562, the module import; its comments mention SearchImpl only for forbidden-apis and logging); open PR #16847 (long global doc ids) edits the line next to 'res.offset = offset' in SearchResults.of but not the offset assignment or nextPage; #15828 and #15837 are refactors; #13573 is about spanNear parsing.
Lucene commit tested
main 645d4fe14a20 (the minimal test standalone with assertions on and off, and through gradle in a scratch worktree of 645d4fe14a20, stock and with the fix); the 10.6.0 release jars, SHA-1-checked against Maven Central (the minimal test, assertions on and off). The history step ran the finder's 2,000-document test on 35 releases from 8.1.0 to 10.5.2 and on the standalone Luke 7.7.0 (passes) and 8.0.0 (fails).

Lucene and upstream sources

Finding record
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:308-333 (at 645d4fe14a20) (search(): page built with offset currentPage * pageSize), :336-366 (nextPage: 'currentPage += 1' at :342 before the checks; end check at :344-349 only for relation EQUAL_TO; :360 'return Optional.of(search())' also for a 0-hit page), :368-391 (prevPage: same move-before-check pattern), :74 (DEFAULT_TOTAL_HITS_THRESHOLD = 1000)
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java:47-50 (at 645d4fe14a20) ('@param offset - offset of the current page'), :69-76 ('res.offset = offset;' at :75 inside the loop over the hits), :86-89 ('Returns the offset of the current page')
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:116-122 (at 645d4fe14a20) (nextPage: '@return search results, or empty if there are no more results')
  • lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:98 (at 645d4fe14a20) (DEFAULT_PAGE_SIZE = 10), :532 (search with exactHitsCntCB.isSelected()), :546-548 (nextPage().ifPresent(this::populateResults)), :579 (More-Like-This search: no sort, exactHitsCount false), :614-623 (populateResults: range offset+1 .. offset+size; 'previous' enabled iff offset > 0; 'next' iff GREATER_THAN_OR_EQUAL_TO or value > offset + size)
  • 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/distribution/src/binary-release/bin/luke.sh and luke.cmd (Luke ships in the binary distribution); lucene/luke/src/test/org/apache/lucene/luke/models/search/TestSearchImpl.java:335-341 (testNextPageNoMoreResults: Optional.empty() after the last page, exact total only)
The finding in 30 seconds
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java:74, :308-333, :335-366, :368-391 (at 645d4fe14a20)
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java:40, :47-50, :69-76, :86-89 (at 645d4fe14a20)
  • lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:98, :142, :405-411, :546-548, :614-623 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:154-163 (at 645d4fe14a20)
Who is affected
  • lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:98, :142, :532, :578-579 (at 645d4fe14a20)
  • lucene/distribution/src/binary-release/bin/luke.sh, luke.cmd (at 645d4fe14a20)
Why it looks unintended
  • lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java:116-122; SearchResults.java:47-50, :86-89; SearchImpl.java:344-349 (at 645d4fe14a20)
  • lucene/luke/src/test/org/apache/lucene/luke/models/search/TestSearchImpl.java:294-341 (at 645d4fe14a20)
  • lucene/luke/src/java/module-info.java; lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java:18 (at 645d4fe14a20)

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; its numbers follow the minimal repro and the replayed tab logic in this doc.

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.