All findings · Lucene finding · LFV-202 · leafslice-rejects-zero-doc-leaf

IndexSearcher rejects any reader that contains an empty leaf

Since 10.0, IndexSearcher throws IllegalArgumentException: minDocId is greater than than maxDoc: [0] > [0] for any reader with a leaf of 0 documents, such as new ParallelLeafReader() with no sub-readers. 9.12.3 searches the same reader and counts 0 hits.

Summary

Priority
Low, rank 785Since 10.0.0 IndexSearcher rejects any reader that contains a 0-doc leaf (for example an empty ParallelLeafReader), loud and in practice only for readers assembled in memory.
Impact
crash
Since
10.0.0 (#13542, 2024-09-10)introduced 2024-09-10 · about 2 years
Affected
every release 10.0.0 to 10.6.0, and main; 9.x is not affected
Status
documented
Found by
semi-formal: bounded-exhaustive check of IndexSearcher.slices() on the real jar

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

One empty leaf makes a whole reader unsearchable, because a bounds check added for partition ranges also runs on the whole-leaf range.

Leaves, slices and partitions

  1. A reader is a list of leaves (segments). A leaf's maxDoc() is its number of document ids, and it can be 0: new ParallelLeafReader() with no sub-readers is a stock leaf with maxDoc() 0.
  2. IndexSearcher groups the leaves into slices, the units of work it runs, in parallel when it has an executor.
  3. Since 10.0 a slice holds partitions: a leaf plus a doc-id range. A whole leaf gets the range [0, NO_MORE_DOCS); intra-segment search can also split a big leaf into real sub-ranges such as [0, 500) and [500, 1000).
Diagram of the bug: new IndexSearcher over a ParallelLeafReader with no sub-readers, a reader whose one leaf has maxDoc 0. Slicing wraps the leaf with createForEntireSegment in a partition with minDocId 0 and maxDocId NO_MORE_DOCS; the partition constructor's check minDocId >= maxDoc reads 0 >= 0 and throws IllegalArgumentException 'minDocId is greater than than maxDoc: [0] > [0]', so count(MatchAllDocsQuery) never runs. On 9.12.3 the same count returns 0.
A reader whose only leaf has no documents: slicing wraps it in the whole-leaf range [0, NO_MORE_DOCS), the partition check 0 >= maxDoc() reads 0 >= 0, and IndexSearcher throws instead of counting 0 as 9.12.3 does. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a reader whose one leaf has no documents: new ParallelLeafReader(true) with no sub-readers has maxDoc() 0. Run new IndexSearcher(reader).count(new MatchAllDocsQuery()).
  2. You expect 0, as on 9.12.3. Put the same empty leaf next to a real 5-document index in a MultiReader and you expect 5.
  3. Instead the IndexSearcher constructor throws IllegalArgumentException: minDocId is greater than than maxDoc: [0] > [0], with assertions on or off. The MultiReader with 5 real documents fails the same way.
  4. Why: since 10.0 slicing wraps every leaf in a partition with the range [0, NO_MORE_DOCS), and the partition constructor rejects minDocId >= maxDoc(). The check also runs on that whole-leaf range, and for an empty leaf it reads 0 >= 0.
  5. Indexes that IndexWriter writes never contain an empty leaf, so in practice only readers assembled in memory hit it. It always fails loudly and never returns a wrong result.

Who is affected

Applications that build a reader in memory with an empty leaf: a ParallelLeafReader with no sub-readers (alone, wrapped or inside a MultiReader) or a custom LeafReader whose maxDoc() is 0. Every searcher with Lucene's own slicing fails over that reader, with or without an executor. Readers over indexes that IndexWriter wrote are not affected.

Mechanism, terms and numbers456 words

Since 10.0.0 IndexSearcher's own slicing wraps every leaf of its reader in a LeafReaderContextPartition, a leaf plus a doc-id range; a whole leaf gets [0, NO_MORE_DOCS) from createForEntireSegment. The partition constructor throws IllegalArgumentException when minDocId >= leaf.maxDoc(), a check that intra-segment concurrency (#13542) added to the partition constructor at a reviewer's request. It runs for the whole-leaf partition too, and for a leaf with maxDoc() == 0 it reads 0 >= 0. So a reader that contains an empty leaf cannot be searched: new IndexSearcher(new ParallelLeafReader(true)) throws where 9.12.3 counts 0, and a MultiReader of a 5-document index plus that leaf throws where 9.12.3 finds 5. A reader with no leaves at all is special-cased and works. Indexes that IndexWriter writes never contain an empty leaf, so in practice only readers assembled in memory hit it. Every release from 10.0.0 to 10.6.0 and main is affected; 9.x is not.

The code path (main 645d4fe14a20, IndexSearcher.java)

  1. new IndexSearcher(reader) (:189-191) delegates to IndexSearcher(IndexReaderContext, Executor) (:218-232). Without an executor it builds its one slice at once: leafContexts.isEmpty() ? new LeafSlice[0] : new LeafSlice[] {LeafSlice.entireSegments(leafContexts)} (:226-231). A reader with no leaves is special-cased; a leaf with no documents is not.
  2. LeafSlice.entireSegments wraps every leaf with LeafReaderContextPartition.createForEntireSegment(ctx) (:1021-1028).
  3. createForEntireSegment always passes minDocId 0 and maxDocId DocIdSetIterator.NO_MORE_DOCS (:1097-1100).
  4. The private constructor (:1068-1094) checks minDocId >= maxDocId, minDocId < 0 and minDocId >= leafReaderContext.reader().maxDoc() (:1081-1088). For an empty leaf the third reads 0 >= 0 and throws "minDocId is greater than than maxDoc: [0] > [0]" (the message says >; the check is >=).
  5. Every other stock slicing path reaches the same constructor: executor searchers lazily through getSlices() and computeAndCacheSlices() (:537-543, :545-565) on the first count() or search(), and the public static slices(leaves, maxDocsPerSlice, maxSegmentsPerSlice, allowSegmentPartitions) (:341-390) with partitions off (:385) and on (:422; an empty leaf always fits in a slice).

Before #13542 a slice held plain leaves and nothing was checked per leaf, so 9.x searched the empty leaf and found nothing in it.

The trigger

ParallelLeafReader's constructor accepts zero readers and then sets this.maxDoc = this.numDocs = 0 (ParallelLeafReader.java:96-98). Any custom LeafReader whose maxDoc() is 0 does the same. Wrapping it, nesting it or putting it in a MultiReader next to real indexes does not help: every leaf is wrapped.

Where it throws

On 10.0.0 the no-executor constructor still computed slices lazily, so the first count() or search() throws; from 10.1.0 (#13893) the constructor throws, and from 10.2.0 (#14343) through LeafSlice.entireSegments. With an executor it throws from getSlices() in every affected version.

Exposure and likelihood271 words
  • Who is hit: applications that assemble a reader in memory with an empty leaf. One empty leaf makes the whole MultiReader unsearchable, its non-empty leaves included.
  • Default vs opt-in: the default IndexSearcher, no setting involved; what is opt-in is the reader composition.
  • Scale: none; one empty leaf is enough and no index is needed.
  • Severity: a loud, deterministic IllegalArgumentException on every attempt. Nothing is persisted, no wrong result is ever returned, nothing hangs and no search task is submitted (executed probe).
  • Not hit: readers over indexes that IndexWriter wrote. IndexWriter never writes a 0-doc segment (LUCENE-5173: "Lucene never writes segments with 0 documents anymore", lucene/CHANGES.txt:13437), and CheckIndex rejects one (CheckIndex.java:1011-1013). A hand-written 0-doc segment does open (a fact-check probe: DirectoryReader.open gives one leaf with maxDoc() 0 and IndexSearcher throws the same exception), but CheckIndex rejects it as illegal and index files are trusted input (SECURITY.md:30-34). A fully hard-deleted segment kept by the merge policy, a fully soft-deleted segment (SoftDeletesDirectoryReaderWrapper drops the leaf) and a FilterDirectoryReader that hides every document through live docs all keep maxDoc() > 0 and search fine (executed). A ParallelCompositeReader with no readers has no leaves; MemoryIndex and TermVectorLeafReader report maxDoc() 1.
  • Downstream: a read-only code search found no readerless ParallelLeafReader in Elasticsearch, OpenSearch or Solr; Solr's one ParallelLeafReader always has inputs and feeds a merge. A spot check, not proof.
  • Exposure so far: 13 releases over two years (10.0.0 to 10.6.0) and no report found.
  • Workaround: leave zero-doc leaves out of the reader. Overriding the protected slices(List) cannot help the no-executor constructor, which builds its slice directly (IndexSearcher.java:226-231).

Minimal repro

One JUnit class on Lucene's test framework, one test method, no index and no directory, deterministic and single-threaded, public API only. It uses a plain IndexSearcher, and the try/catch only turns the exception into an expected-vs-actual message.

TestSearchZeroDocLeaf.java (one test class)36 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.search;

import org.apache.lucene.index.ParallelLeafReader;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSearchZeroDocLeaf extends LuceneTestCase {

  public void testCountOnEmptyParallelLeafReader() throws Exception {
    try (ParallelLeafReader empty = new ParallelLeafReader(true)) {
      int count;
      try {
        count = new IndexSearcher(empty).count(new MatchAllDocsQuery());
      } catch (IllegalArgumentException e) {
        // expected: count 0 on a reader whose only leaf has maxDoc() == 0; actual: it throws
        throw new AssertionError("expected count 0 for a maxDoc() == 0 leaf, but threw " + e, e);
      }
      assertEquals(0, count);
    }
  }
}
Command
cp $REPRO/TestSearchZeroDocLeaf.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSearchZeroDocLeaf -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
count returns 0, as on 9.12.3.
Observed
IllegalArgumentException: minDocId is greater than than maxDoc: [0] > [0] from the IndexSearcher constructor, assertions on and off.

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

More files, environment, full output193 words

Copy the class into lucene/core/src/test/org/apache/lucene/search/ of a checkout and run it with gradle; it needs only core and the test framework. It fails the same way with assertions off (-Ptests.asserts=false). The same class is the suggested regression test: it fails on stock main through gradle and passes with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.6.0 and 10.0.0: fail; 9.12.3: passes; with fix A or C: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is plain Java)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF (trimmed)
TestSearchZeroDocLeaf > testCountOnEmptyParallelLeafReader FAILED
    java.lang.AssertionError: expected count 0 for a maxDoc() == 0 leaf, but threw java.lang.IllegalArgumentException: minDocId is greater than than maxDoc: [0] > [0]
        at org.apache.lucene.search.TestSearchZeroDocLeaf.testCountOnEmptyParallelLeafReader(TestSearchZeroDocLeaf.java:31)
        Caused by:
        java.lang.IllegalArgumentException: minDocId is greater than than maxDoc: [0] > [0]
            at org.apache.lucene.search.IndexSearcher$LeafReaderContextPartition.<init>(IndexSearcher.java:1086)
            at org.apache.lucene.search.IndexSearcher$LeafReaderContextPartition.createForEntireSegment(IndexSearcher.java:1099)
            at org.apache.lucene.search.IndexSearcher$LeafSlice.entireSegments(IndexSearcher.java:1025)
            at org.apache.lucene.search.IndexSearcher.<init>(IndexSearcher.java:230)
            at org.apache.lucene.search.IndexSearcher.<init>(IndexSearcher.java:202)
            at org.apache.lucene.search.IndexSearcher.<init>(IndexSearcher.java:190)
            at org.apache.lucene.search.TestSearchZeroDocLeaf.testCountOnEmptyParallelLeafReader(TestSearchZeroDocLeaf.java:28)
:lucene:core:test (FAILURE): 1 test, 1  failure

# standalone runner, seed DEADBEEF (summary of the runs)
main 645d4fe14a20  -ea: FAIL 1/1   -da: FAIL 1/1
10.6.0             -ea: FAIL 1/1
10.0.0             -ea: FAIL 1/1   (the constructor succeeds; count() throws from getSlices())
9.12.3             -ea: pass 1/1

Why it looks unintended

  • IndexSearcher(IndexReader) states no precondition, IndexReader#maxDoc has no lower bound, and LeafReader says any subclass "is searchable".
  • ParallelLeafReader handles zero sub-readers on purpose, and IndexSearcher itself special-cases a reader with no leaves.
  • 9.12.3 searches these readers; no CHANGES or MIGRATE entry announces a restriction.
The full argument260 words

The contract is implied, not stated outright.

  • IndexSearcher(IndexReader): "Creates a searcher searching the provided index." (IndexSearcher.java:188-191): no precondition, not @lucene.experimental.
  • IndexReader#maxDoc: "Returns one greater than the largest possible document number." (IndexReader.java:300-305): no lower bound.
  • LeafReader: "Search of an index is done entirely through this abstract interface, so that any subclass which implements it is searchable." (LeafReader.java:31-34)
  • ParallelLeafReader handles zero sub-readers on purpose (ParallelLeafReader.java:96-98); the upstream test TestParallelReaderEmptyIndex builds one (:49-50, LUCENE-1918), though only for addIndexes.
  • IndexSearcher special-cases a reader with no leaves (IndexSearcher.java:227-229).
  • 9.12.3 searches these readers correctly (executed). The 10.0.0 CHANGES entry for #13542 (lucene/CHANGES.txt:2332) and MIGRATE's six #13542 items say nothing about empty leaves, and no upstream test asserts the rejection.

History of the check. In the #13542 review jpountz asked (2024-07-31, on the private constructor that both factories share): "Can you add validation, e.g. minDocId >= 0, maxDocId > minDocId, minDocId < context.reader().maxDoc(), etc?", and javanna answered (2024-08-26) "good with the proposed validation", keeping NO_MORE_DOCS as the upper bound when targeting the entire segment. None of the PR's 30 comments, 99 review comments and 77 reviews mentions empty or 0-doc leaves.

Precedent. LUCENE-5045 (#6109, 2013): DrillSideways threw IllegalArgumentException on an empty reader; the report says "there's nothing technically wrong from running a search against an empty index", and it was fixed as a bug. In #15803 (2026-03-10) mikemccand wrote "Lucene tries hard to never produce a 0-doc segment" and still asked for an explicit test with 0-doc segments.

The devil's-advocate check163 words

The strongest case for "intended" (rated moderate by the cross-model reviewers, weak to moderate by the devil's-advocate verifier): the check is deliberate and is literally what the reviewer asked for ("minDocId < context.reader().maxDoc()"); LeafReaderContextPartition is @lucene.experimental; and Lucene never writes 0-doc segments (LUCENE-5173; CheckIndex rejects maxDoc <= 0), so an empty leaf is an unusual, synthetic reader.

Why it fails: the failure surfaces through the stable IndexSearcher(IndexReader) constructor, not the experimental partition API; the review was about partition bounds, and nobody discussed what the check does to the whole-leaf partition of an empty leaf; LUCENE-5173 and CheckIndex govern segments written to disk, while ParallelLeafReader is a public reader that handles zero inputs on purpose; and a fix that keeps the validation for real partitions leaves search working, with 0 hits in the empty leaf and correct counts beside it. That the effect on the empty whole-leaf partition was overlooked is an inference, not something the PR says. Verdict: not intended; rare.

Suggested fix

Recommended Range-check only true partitions (C, recommended, validated)

Range-check only true partitions: move the constructor's three checks into createFromAndTo, since createForEntireSegment's range is the whole leaf by construction. It passes the repro and the slicing suites, and keeps working if open PR #16952 changes the whole-leaf range to (0, maxDoc), where the one-condition alternative stops working.

public static LeafReaderContextPartition createFromAndTo(
    LeafReaderContext ctx, int minDocId, int maxDocId) {
  assert maxDocId != DocIdSetIterator.NO_MORE_DOCS;
  // only a true partition needs a range check: the entire-segment partition is valid for any
  // leaf, including one with no documents
  if (minDocId >= maxDocId) { throw ... }            // moved from the constructor, unchanged
  if (minDocId < 0) { throw ... }                    // moved, unchanged
  if (minDocId >= ctx.reader().maxDoc()) { throw ... } // moved, unchanged
  return new LeafReaderContextPartition(ctx, minDocId, maxDocId, maxDocId - minDocId);
}
All options and trade-offs3 options ยท 749 words

Two fixes were built and validated; for every partition Lucene itself creates on today's main they behave the same. C is recommended because it does not depend on how the whole-leaf range is represented, which open PR #16952 changes.

Range-check only true partitions (C, recommended, validated)

The change is +21/-20 lines in IndexSearcher.java: the three checks move, unchanged, from the private LeafReaderContextPartition constructor into createFromAndTo, the only factory that takes caller-supplied bounds. createForEntireSegment passes no check, because its range is the whole leaf, whatever the leaf's size.

public static LeafReaderContextPartition createFromAndTo(
    LeafReaderContext ctx, int minDocId, int maxDocId) {
  assert maxDocId != DocIdSetIterator.NO_MORE_DOCS;
  // only a true partition needs a range check: the entire-segment partition is valid for any
  // leaf, including one with no documents
  if (minDocId >= maxDocId) { throw ... }            // moved from the constructor, unchanged
  if (minDocId < 0) { throw ... }                    // moved, unchanged
  if (minDocId >= ctx.reader().maxDoc()) { throw ... } // moved, unchanged
  return new LeafReaderContextPartition(ctx, minDocId, maxDocId, maxDocId - minDocId);
}

Trade-offs

  • True partitions keep exactly the same checks and messages; only the whole-leaf partition, whose bounds the code chooses itself, is no longer checked.
  • It survives #16952 (open, opened 2026-10-30), which makes createForEntireSegment pass (0, maxDoc): there an empty leaf would otherwise fail the first check (0 >= 0).
  • It moves about 20 lines, so it conflicts textually with draft #16847, which edits the third check.
  • No behaviour change for any leaf with at least one document.

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the minimal test fails with assertions on and off. With C:

  • the minimal test plus TestIndexSearcher, TestTaskExecutor, TestTotalHitCountCollector, TestTopFieldCollectorEarlyTermination, TestSegmentToThreadMapping, TestForTooMuchCloning, TestParallelLeafReader, TestParallelReaderEmptyIndex and TestParallelCompositeReader: 10 classes, 65 tests, 0 failures, 1 skipped, with assertions on and off;
  • core lint (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest) passes with the test in place;
  • with open PR #16952 applied as well: the same run plus a verifier's sequential and executor search(q, 10) tests passes (11 classes, 70 tests, 0 failures, 1 skipped), while the minimal test alone fails on #16952 without a fix.

With the patched IndexSearcher.java shadowed on main's jars: the finder's 3 tests (the empty leaf alone and next to a 5-doc index), a verifier's 7 tests (sequential, executor and static slices(); 5 of 7 fail on stock) with assertions on and off, and another verifier's executor/static test (4/4) all pass. The whole org.apache.lucene.search package was run with A only.

Exempt the whole-segment partition from the maxDoc check (A, smallest, validated)

The change is +3/-1 lines in IndexSearcher.java: the hunt's one-condition fix.

// the whole-segment partition (0, NO_MORE_DOCS) is also valid for a leaf with no documents
if (minDocId >= leafReaderContext.reader().maxDoc()
    && (minDocId != 0 || maxDocId != DocIdSetIterator.NO_MORE_DOCS)) {

Trade-offs

  • One condition, the smallest change; for every partition Lucene itself creates on today's main it behaves exactly like C (they differ only for a direct createFromAndTo(ctx, 0, NO_MORE_DOCS) call on a 0-doc leaf with assertions off, which A accepts and C rejects).
  • It keys on the NO_MORE_DOCS sentinel, so it stops working if open PR #16952 is merged: with #16952 applied, the minimal test fails with A on top (minDocId is greater than or equal to maxDocId: [0] > [0]).

What the checks validated

Gradle in the same scratch worktree of 645d4fe14a20: the minimal test passes with assertions on and off; the minimal test plus the suites listed under C: 10 classes, 65 tests, 0 failures, 1 skipped; the whole org.apache.lucene.search package: 215 classes, 2,340 tests, 0 failures, 49 skipped; core lint passes with the test in place.

Earlier, in the hunt: every repro (the finder's 3, the verifiers' 7 and 7, the adjudication's re-run 5), 10.0.0 and the introducing commit with it applied, the bounded-exhaustive harness (0 failures) and TestIndexSearcher, TestCachingCollector(Manager), TestMultiCollector(Manager) and TestDocValuesStatsCollector.

Skip empty leaves when slicing (not run)

Leave leaves with maxDoc() == 0 out of LeafSlice.entireSegments and slices(...).

Trade-offs

Collectors would never see the leaf, the slices would no longer cover every leaf of the reader, and the public static slices() would return different slices. A reader whose only leaf is empty would get no slice at all, and search() asserts leafContexts.isEmpty() in that case (IndexSearcher.java:764), so that would need changing too. More invasive than C for no gain.

Downstream note

No downstream engine is known to build such a reader: a read-only code search found no readerless ParallelLeafReader in Elasticsearch, OpenSearch or Solr.

How it was found

A hunt on concurrent collector managers and slicing checked the public static IndexSearcher.slices(...) on the real jar against its own properties over every small leaf layout. "Does not throw for a legal reader" failed in 31,176 of 178,996 layouts: exactly those with a 0-doc leaf. It was then reduced by hand to a public-API test.

The method in full173 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier) of the public static IndexSearcher.slices(leaves, maxDocsPerSlice, maxSegmentsPerSlice, allowSegmentPartitions) in a hunt on concurrent collector managers and slicing, run on the real main jar (ea57360912ed).

  • A harness enumerated 1-3 leaves of 0-10 documents, every maxDocsPerSlice and maxSegmentsPerSlice up to the bound, partitions off and on, plus a few shapes near IndexWriter.MAX_DOCS, and checked properties P0-P6 on the result.
  • P0, "slices() does not throw for a legal reader", failed in 31,176 of 178,996 configurations, which is exactly the number of enumerated configurations that contain a 0-doc leaf (recomputed from the harness loops); 116,100 of 1,013,068 in a 3 x 0..16 run.
  • The partition properties P1-P6 held in all 5,894,928 configurations of 1-4 leaves with 1-12 documents (23,834,568 in a 4 x 1..16 run); z3 discharged the partition arithmetic obligations; the variant with the fix had 0 failures.
  • The finder then reduced it by hand to a 3-test public-API JUnit on the stock jars; the hunt later cut that to the one-method test shown here.

Prior reports, history and checks

Prior reports

No prior report found (191 logged queries on 2026-10-26 over GitHub, downstream trackers, the lists, JIRA and the web; rechecked 2026-10-29 and 2026-10-31). Open PR #16952 and draft #16847 edit the same code without fixing it.

Searches4 searches

Verdict new: no issue, PR, commit, list thread, JIRA entry, downstream report or web page reports it; the exact error text appears upstream only in the source and in the commits@ mail of #13542.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), and Elasticsearch, OpenSearch and Solr issues and code2026-10-26a scripted prior-report search (two runs) plus GraphQL searches (83 GitHub queries in all), commit searches and git log -S on the full history, the changed-file lists of all 413 open PRs, 15 downstream issue/PR queries and 4 code searchesonly related items: #13542 (introduced it), #13893 and #14343 (moved where it throws), #14428, #13860, #16847 (same check rewritten), #15324 and #16855 / #16856 (different NO_MORE_DOCS bugs)
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@ and the Solr lists (53 queries); the JIRA archive and Solr JIRA (17)only the commits@ mail of #13542 contains the error text; LUCENE-1918 and LUCENE-5045 are older empty-reader bugs in other code
Web2026-10-2610 queries, including the exact error textnothing relevant
GitHub (recheck)2026-10-318 issue/PR searches (exact error text, class names, empty-leaf phrasings; the control "createForEntireSegment" returns #13542, #15324 and #16855) and the 19 issues and PRs created since 2026-10-29 by titlenothing reports it; #16952 (open, 2026-10-30) changes the whole-leaf range and keeps the failure

History

Introduced in 10.0.0 by #13542 (fafd6af004e, merged 2024-09-10), which added the partitions and their checks; its parent passes and it fails. Every release from 10.0.0 to 10.6.0 fails, every release from 9.1.0 to 9.12.3 passes.

Versions tested6 rows

Introduced by #13542 "Add support for intra-segment search concurrency" (javanna; merged into main 2024-09-10 as fafd6af004e; first release 10.0.0). git blame gives every line of the constructor and createForEntireSegment to it, a pickaxe on the error text finds only it, and git log -L shows no later change. branch_9x never received it, so 9.12.x, released after the merge, is not affected. Where the exception surfaces moved with #13893 (10.1.0) and #14343 (10.2.0); the check did not change.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal test fails (main with assertions on and off; gradle and standalone runner)
10.1.0 to 10.5.2, main ea57360912ed affectedthe finder's repro fails 2/3: the constructor throws
10.0.0 affectedfails; the constructor succeeds and the first count()/search() throws (lazy slices)
9.1.0 to 9.12.3 not affectedall 20 releases pass; slices held plain leaves with no per-leaf check
fafd6af004e (#13542) vs its parent 942065cc3e7 bisectthe commit fails 2/3 and 4/4 (executor and static slices); its parent passes 3/3 and 4/4
open PR #16952 applied to 645d4fe14a20 not mergedstill fails, now in the first check (minDocId is greater than or equal to maxDocId: [0] > [0]); fix A on top still fails, fix C passes

Independent checks

A cross-model review with a shell (OpenAI GPT-5.6), an independent reproducer, a reading-only GPT-5.6 review, a devil's-advocate verifier and an adjudication all found it real; the adjudication confirmed it (high confidence, crash). This doc's runs reproduced it on main, 10.6.0 and 10.0.0 and validated both fixes through gradle.

Checks and verdicts355 words

Every executed check ran on stock builds; each verifier wrote its own tests from the claim.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (bounded-exhaustive check of slices(), then a JUnit) candidate31,176 of 178,996 layouts throw, exactly those with a 0-doc leaf; the 3-test JUnit fails 2/3 on main (assertions on and off), 10.5.2 and 10.0.0, passes on 9.12.3 and with the fix.
refute review (OpenAI GPT-5.6, with a shell) real (high)Own tests fail 2/3 on main, a one-document control passes; its release-compatible test fails on main and 10.0.0, passes on 9.12.3; with the fix its tests and TestIndexSearcher (10/10) pass.
Independent reproducer realOwn public-API tests outside org.apache.lucene: 3/5 fail with assertions on and off (both controls pass), 3/5 on 10.5.2 and 10.0.0, 0/5 on 9.12.3; the static slices() test 2/2; 7/7 pass with the fix.
Works-as-designed lens (OpenAI GPT-5.6, reading only) real (high)It could not run code; intended argument moderate, root cause agreed. A separate executor and static-slices test fails 4/4 on main and passes with the fix.
Intended-behaviour devil's advocate real (not intended; impact low)Re-ran the finder's repro (fails on main, passes on 9.12.3); read the #13542 review threads, CHANGES, MIGRATE, LUCENE-5173 and CheckIndex.
Adjudication confirmed (high), crashRe-ran the independent reproduction's tests with a fresh seed: 3/5 fail with assertions on and off, 0/5 on 9.12.3, 0/5 with the fix.
This doc's runs realGradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off, fixes A and C pass; releases 10.6.0 and 10.0.0 fail, 9.12.3 passes; open PR #16952 keeps the failure.
Independent fact checks of this doc (one executed, one cross-model with OpenAI GPT-5.6) holds after one wording fixRe-ran the gradle command (assertions on and off, output identical), fixes A and C, the suites with a new seed and open PR #16952 with each fix. A hand-written 0-doc segment opens from a directory and throws the same exception (CheckIndex calls it illegal), so the reach wording now says indexes that IndexWriter writes, not every directory reader.

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

Finding record

LFV-202, snapshot of 2026-11-06

Registry title
IndexSearcher throws IllegalArgumentException for any reader that contains a leaf with maxDoc() == 0 (for example a ParallelLeafReader with no sub-readers): the whole-segment LeafReaderContextPartition of that leaf fails a bounds check added for partition ranges (regression in 10.0.0)
Area
search / IndexSearcher slicing (LeafSlice, LeafReaderContextPartition)
Priority
Low, rank 785: Since 10.0.0 IndexSearcher rejects any reader that contains a 0-doc leaf (for example an empty ParallelLeafReader), loud and in practice only for readers assembled in memory.
Impact class
crash
Impact note
Hits only applications that assemble a reader in memory with a leaf whose maxDoc() is 0 (a ParallelLeafReader with no sub-readers, alone, wrapped or inside a MultiReader, or a custom LeafReader reporting maxDoc() == 0); for them every searcher over that reader fails with a clean IllegalArgumentException, in the no-executor constructor (10.1.0 on) or on the first count()/search() (executor searchers, and 10.0.0), with -ea and -da; readers over indexes that IndexWriter wrote never contain such a leaf (a hand-written 0-doc segment, which CheckIndex rejects, does open and fails the same way), no scale is needed, nothing is persisted and no wrong result is ever returned; affected since 10.0.0 (9.x not affected).
Summary
You build an IndexSearcher over a reader that contains an empty leaf, for example new ParallelLeafReader() with no sub-readers, possibly next to a real index inside a MultiReader, and you expect searches to work and simply find no hits in the empty part, as they do on 9.12.3. Instead the searcher throws IllegalArgumentException 'minDocId is greater than than maxDoc: [0] > [0]', because slicing wraps every leaf in a whole-segment partition that starts at doc 0 and the partition constructor rejects a start that is not below the leaf's maxDoc(). In the repro, a MultiReader of a 5-doc index plus an empty ParallelLeafReader cannot be searched at all instead of returning 5 hits.
Introduced
fafd6af004e0 on main: #13542 'Add support for intra-segment search concurrency' by javanna (merged 2024-09-10, closes #9721); it added LeafReaderContextPartition with three constructor checks, among them 'minDocId >= leafReaderContext.reader().maxDoc() -> IllegalArgumentException' (added after a review request by jpountz), and createForEntireSegment, which always passes minDocId 0 and maxDocId NO_MORE_DOCS [exec: jars built from the history clone at the parent 942065cc3e7 pass the repro 3 of 3 and the executor/static-slices test 4 of 4; at fafd6af004e they fail 2 of 3 (also with -da) and 4 of 4; read: git blame gives every line of the constructor and createForEntireSegment to fafd6af004e, git log --all -S on the error text finds only that commit, git log -L shows no later change]; #13893 (main a888af76b2f, 10.1.0) and #14343 (main bdf4deff313, 10.2.0) later moved where the no-executor searcher throws (into the constructor), not the defect [exec, read] (2024-09-10 (#13542, authored and committed on main); first release 10.0.0 (tag commit eadc07cc6a1, 2024-10-09))
First affected version
10.0.0 [exec]; 9.12.3 and every 9.x release from 9.1.0 are not affected [exec] (branch_9x never received #13542; 9.12.x shipped after the merge from branch_9x)
Affected versions
all 12 releases from 10.0.0 to 10.5.2 (10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2) and main ea57360912ed through 645d4fe14a2 (2026-10-29) [exec]: the finder's JUnit fails 2 of 3 on each with -ea, and with -da on 10.0.0, 10.5.2 and main; the executor/static-slices test fails 4 of 4 on 10.0.0, 10.5.2 and main. Not affected [exec]: all 20 releases from 9.1.0 to 9.12.3 (3 of 3 pass; -da too on 9.12.3; the executor/static test's pre-#13542 variant passes 4 of 4 on 9.12.3) and the parent commit 942065cc3e7. Releases before 9.1.0 were not run (the test framework package differs); the failing check does not exist there [read]. [read] the check is unchanged on main 645d4fe14a2, branch_10x 657014b3c57 (IndexSearcher.java:1093) and branch_10_6 4ae946e5a9b (10.6.0 RC1), and 10.6.0 (released 2026-10-29) shipped with it: the minimal test fails on its release jars (4ae946e5a9b9) with -ea [exec 2026-10-31]. Where it throws: on 10.0.0 and fafd6af004e the no-executor constructor succeeds and the first count()/search() throws; from 10.1.0 the constructor itself throws; with an executor it throws lazily from getSlices() in every affected version; the public static slices(leaves, ..., allowSegmentPartitions) throws for false and true. Not executed: x86; the history runs used JDK 25.
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
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 191 query lines, among them scripted prior-report searches over GitHub issues/PRs (83 GitHub queries in all, REST and GraphQL), commits (8), downstream repositories (15 issue/PR queries and 4 code searches over Elasticsearch, OpenSearch and Solr), the dev@, java-user@, issues@ and commits@ lists plus the Solr lists (53), the JIRA archive and Solr JIRA (17) and the web (10), plus the changed-file lists of all 413 open apache/lucene PRs and the full git history; no issue, PR, commit, list thread, JIRA entry, downstream report or web page reports it; the error text appears upstream only in IndexSearcher.java and in the commits@ mail of #13542); not fixed upstream: the check is unchanged on main ea57360912ed and 645d4fe14a2, branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b (recheck 2026-10-29, no new report; #16911 (merged 2026-10-29 as 48cf3f515d6: branch_10x decommissioned) and #16929 (merged on main 2026-10-29 as 3386cdab564: branch_10x replaced by branch_11x)); open PRs near the fix site: #16847 (draft by jimczi, long global doc ids) rewrites the check as 'minDocId >= leafReaderContext.maxLeafDoc()', same meaning, so a fix may need rebasing onto it; #13860 (open slicing refactor) still wraps every leaf with createForEntireSegment; related, not reports of this bug: #13542 (introduced it), #13893 (10.1.0, moved the throw into the no-executor constructor), #14428 (closed unmerged, would have made slicing lazy again), #15803 (mikemccand: Lucene 'tries hard to never produce a 0-doc segment' but still asked for 0-doc-segment tests), #2993 / LUCENE-1918 (2009, empty ParallelReader on the addIndexes path; added TestParallelReaderEmptyIndex) and #6109 / LUCENE-5045 (2013, DrillSideways IllegalArgumentException on an empty reader, fixed as a bug), #15324, #16855 / #16856 (different bugs about the NO_MORE_DOCS upper bound in bulk scorers); open PR #16952 (kkewwei, opened 2026-10-30, head fb1a3d8b44d, no reviews or comments as of 2026-11-02; on no branch) changes createForEntireSegment from (0, NO_MORE_DOCS) to (0, maxDoc) and does not fix this bug: with it, the whole-leaf partition of an empty leaf fails the constructor's first check instead ('minDocId is greater than or equal to maxDocId: [0] > [0]'), and the one-condition fix (option A, keyed on NO_MORE_DOCS) stops working, while option C (range checks only for true partitions) passes with and without it [exec on 645d4fe and on main e1faeba08bd, -ea and -da]; the PR author wrote that their #16897 waits on it
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through a standalone runner and through gradle in a scratch worktree, and passes there with either fix (A or C), together with the slicing and parallel-reader suites and core lint; fix A also passes the whole org.apache.lucene.search package. Releases 10.6.0 and 10.0.0 fail, 9.12.3 passes (standalone runner). Open PR #16952 (head fb1a3d8b44d9) applied to the same worktree: the test still fails, fix C passes, fix A does not. Earlier, the hunt ran every release from 9.1.0 to 10.5.2, main ea57360912ed and the introducing commit and its parent (48 targets), and re-verified on 645d4fe14a20 (2026-10-29). Upstream main 2f424fd428da (2026-10-30) and branch_11x 603af870375 carry the same IndexSearcher.java [read].

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:188-191 (IndexSearcher(IndexReader), no @lucene.experimental), :226-231 (no-executor constructor: leafContexts.isEmpty() special case, else LeafSlice.entireSegments), :1021-1028 (entireSegments wraps every leaf with createForEntireSegment), :1068-1088 (LeafReaderContextPartition constructor checks; the failing one is :1081-1088), :1097-1100 (createForEntireSegment: minDocId 0, maxDocId NO_MORE_DOCS)
  • lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:82-99 (zero sub-readers allowed; maxDoc = numDocs = 0)
  • lucene/core/src/java/org/apache/lucene/index/LeafReader.java:30-36 ('any subclass which implements it is searchable'), lucene/core/src/java/org/apache/lucene/index/IndexReader.java:300-305 (maxDoc javadoc, no lower bound), lucene/core/src/test/org/apache/lucene/index/TestParallelReaderEmptyIndex.java:49-50 (upstream test builds a readerless ParallelLeafReader)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:189-191, 218-232 (226-231), 341-390 (385, 422), 537-543, 545-565, 1021-1028, 1068-1094 (1081-1088), 1097-1100
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:82-99 (96-98)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:13437; lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1011-1013
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99SegmentInfoFormat.java:137-140 (rejects only docCount < 0); SECURITY.md:30-34 (index files are trusted)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:188-191, 227-229; lucene/core/src/java/org/apache/lucene/index/IndexReader.java:300-305; lucene/core/src/java/org/apache/lucene/index/LeafReader.java:31-34; lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:96-98; lucene/core/src/test/org/apache/lucene/index/TestParallelReaderEmptyIndex.java:49-50; lucene/CHANGES.txt:2332
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:764 (assert leafContexts.isEmpty()), 1068-1111
History
  • a Lucene clone refs as last fetched: origin/main 2f424fd428da, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e (IndexSearcher.java check present) [read]

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 an executed probe of the minimal case (stock main with assertions on and off, the fix, 9.12.3 and 10.0.0).

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.