All findings · Lucene finding · LFV-1215 · docidsetbuilder-zero-maxdoc-build-bits-required-minus-one

DocIdSetBuilder.build() throws for maxDoc 0 instead of returning the empty set

new DocIdSetBuilder(0).build() throws IllegalArgumentException: maxValue must be non-negative (got: -1) instead of an empty set, so FacetsCollector fails on a leaf with maxDoc 0, such as an empty ParallelLeafReader. A 6.1.0 regression; only in-memory readers have such a leaf, and on 10.x and main IndexSearcher rejects it first (LFV-202).

Summary

Priority
Low, rank 786Faceting over a reader with an empty in-memory leaf throws instead of counting the other leaves, because DocIdSetBuilder.build() mishandles maxDoc 0, in every release since 6.1.0: on 9.x through a plain faceted search, on 10.x only when FacetsCollector is driven directly. The failure is loud with no wrong counts and needs a reader that IndexWriter never produces, so it is rare.
Impact
minor
Since
6.1.0 (LUCENE-7261, 2016)introduced 2016-04-28 · over 10 years
Affected
6.1.0 to 10.6.0 and main (10 releases run, all fail); 5.3.0 to 6.0.1 clean; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of DocIdSetBuilder's contract, witnessed by an executable repro

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

DocIdSetBuilder gathers one segment's matching doc ids and build() sorts them. Before sorting, it computes the sort width as PackedInts.bitsRequired(maxDoc - 1). With maxDoc 0 that is bitsRequired(-1), which throws, although the buffer is empty and the sorter would skip it.

Background: how faceting collects matches

  1. A reader is a list of leaves (segments). FacetsCollector visits each leaf, records its matching doc ids in a DocIdSetBuilder sized by the leaf's maxDoc, and calls build() when the leaf is done.
  2. A leaf can have maxDoc 0 when the application composes a reader in memory, for example new ParallelLeafReader() with no sub-readers inside a MultiReader. IndexWriter never writes such a segment. A fully deleted segment is different: it has numDocs 0 but maxDoc > 0, and is not affected.
Diagram of the bug: FacetsCollector creates a DocIdSetBuilder for a leaf with maxDoc 0. Nothing is added, so the buffer is empty. build() computes the sort width as PackedInts.bitsRequired(maxDoc - 1), which is bitsRequired(-1), and that throws IllegalArgumentException: maxValue must be non-negative (got: -1). With maxDoc 1 or more, or with the clamp Math.max(maxDoc - 1, 0), the same builder returns the empty set.
A leaf with 0 documents: its builder holds no doc ids, yet build() asks bitsRequired(maxDoc - 1) = bitsRequired(-1) for the sort width and throws before the sorter can skip the empty buffer. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create new DocIdSetBuilder(0), add nothing and call build(). FacetsCollector does exactly this for a leaf with maxDoc 0, for example new ParallelLeafReader() with no sub-readers.
  2. You expect the empty set: its iterator's first nextDoc() is NO_MORE_DOCS, as for new DocIdSetBuilder(1) (or 2, 128, 1000), and as Lucene 6.0 returned for 0.
  3. Instead build() throws IllegalArgumentException: maxValue must be non-negative (got: -1), with assertions on and off.
  4. Why: before sorting its buffer, which is empty, build() computes the sort width as PackedInts.bitsRequired(maxDoc - 1). With maxDoc 0 that is bitsRequired(-1), which rejects negative values.
  5. Effect: on 9.12.3 a faceted search over MultiReader(2-document index, new ParallelLeafReader()) throws instead of returning the 2 hits of the other leaf. On 10.x and main IndexSearcher rejects that reader earlier (LFV-202), and fixing that brings this failure back.

Who is affected

Applications that facet over a reader they compose in memory with a leaf with maxDoc 0, such as an empty ParallelLeafReader, and direct callers of new DocIdSetBuilder(0). On 9.12.3 a plain faceted search fails; on 10.x and main IndexSearcher rejects the leaf first (LFV-202), and fixing that exposes this. Never a wrong result.

Mechanism, terms and numbers411 words

DocIdSetBuilder collects the matching doc ids of one segment, sized by the segment's maxDoc. With maxDoc 0 and nothing added, build() throws IllegalArgumentException: maxValue must be non-negative (got: -1) instead of returning the empty set, because its sparse branch always computes the radix-sort width as PackedInts.bitsRequired(maxDoc - 1). A public caller, and the demonstrated route, is FacetsCollector, which builds one per leaf, so it fails on a leaf with maxDoc 0 (for example an empty ParallelLeafReader inside a MultiReader) instead of counting the other leaves. A plain faceted search reaches it on 9.12.3; on 10.x and main IndexSearcher rejects such a reader first (LFV-202), so only code that drives FacetsCollector directly does. It is a regression from LUCENE-7261 (6.1.0); 5.3.0 to 6.0.1 returned the empty set. IndexWriter never writes a 0-document segment, so only readers composed in memory are affected, and the failure is loud, never a wrong result.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/util/DocIdSetBuilder.java)

  1. No lower bound. DocIdSetBuilder(int maxDoc): "Create a builder that can contain doc IDs between {@code 0} and {@code maxDoc}." (:148-151), with no check. The class is @lucene.internal (:35).
  2. With maxDoc 0 the sparse path is always empty. threshold = maxDoc >>> 7 is 0 (:187). grow(n) for any n > 0 exceeds it and switches to a FixedBitSet(maxDoc) (:220-232, :296), which builds an empty set fine. With nothing added, or grow(0), the builder stays on the sparse path with an empty buffer.
  3. The fix site. build() (:313), sparse branch: sorter.sort(PackedInts.bitsRequired(maxDoc - 1), concatenated.array, concatenated.length) (:322).
  4. Why an empty buffer still throws. LSBRadixSorter.sort returns early for fewer than 30 values (LSBRadixSorter.java:89-93), but Java evaluates the argument first, and PackedInts.bitsRequired(int) throws "maxValue must be non-negative (got: -1)" for any negative value (PackedInts.java:803-806).
  5. A public caller, and the demonstrated route. FacetsCollector.doSetNextReader creates new DocIdSetBuilder(context.reader().maxDoc()) (FacetsCollector.java:108) and finish() calls build() (:120). An empty ParallelLeafReader sets maxDoc = numDocs = 0 (ParallelLeafReader.java:96-98). FacetsCollector is one of 21 new DocIdSetBuilder( sites in non-test code; the others were not probed.

What fails and what does not (executed on main)

callresult
new DocIdSetBuilder(0).build() (nothing added, or grow(0), or add(DocIdSetIterator.empty()))throws
new DocIdSetBuilder(0), grow(1), build() (dense path)empty BitDocIdSet (cost estimate 1, iterator empty)
new DocIdSetBuilder(1 / 2 / 128 / 1000).build()empty IntArrayDocIdSet
FacetsCollector.getLeafCollector(new ParallelLeafReader().getContext()), then finish()throws
Exposure and likelihood217 words
  • Who. Applications that facet (FacetsCollector or FacetsCollectorManager) over a reader they compose in memory that contains a leaf with maxDoc 0, such as an empty ParallelLeafReader inside a MultiReader or a custom LeafReader, and direct callers of the @lucene.internal new DocIdSetBuilder(0). A fully deleted segment (numDocs 0, maxDoc > 0) does not trigger it.
  • Through search. On 9.12.3, new IndexSearcher(new MultiReader(twoDocIndex, new ParallelLeafReader())).search(new MatchAllDocsQuery(), new FacetsCollectorManager()) throws this exception from FacetsCollector.finish. On 10.x and main IndexSearcher rejects that reader earlier with LFV-202's "minDocId is greater than than maxDoc: [0] > [0]", so today only code that drives FacetsCollector directly reaches it.
  • Coupled with LFV-202. With LFV-202's recommended fix (range-check only true partitions) applied on main, count(MatchAllDocsQuery) over the same reader returns 2 and the faceted search then throws this bug from FacetsCollector.finish <- IndexSearcher.searchLeaf; with both fixes it returns 2 hits. LFV-202's alternative "skip empty leaves when slicing" would keep this one hidden.
  • Default vs opt-in. No setting is involved, but IndexWriter never writes a 0-document segment, so readers over on-disk indexes are not affected.
  • Effect and scale. One clean IllegalArgumentException; no wrong or missing hits, no index change. No scale needed.
  • Likelihood: rare. It needs an in-memory empty leaf, and no public report was found since 6.1.0 (2016).

Minimal repro

One JUnit test method, no index: new DocIdSetBuilder(0).build().iterator().nextDoc() must be NO_MORE_DOCS. Deterministic and single-threaded; it fails with assertions on and off.

TestDocIdSetBuilderZeroMaxDocBuild.java (one JUnit test, lucene/core tests)30 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.util;

import java.io.IOException;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDocIdSetBuilderZeroMaxDocBuild extends LuceneTestCase {

  // Expected: an empty set; actual: IllegalArgumentException "maxValue must be non-negative (got: -1)".
  public void testEmptyWithZeroMaxDoc() throws IOException {
    DocIdSetBuilder builder = new DocIdSetBuilder(0);
    assertEquals(DocIdSetIterator.NO_MORE_DOCS, builder.build().iterator().nextDoc());
  }
}
Command
cp $REPRO/TestDocIdSetBuilderZeroMaxDocBuild.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestDocIdSetBuilderZeroMaxDocBuild -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
build() returns the empty set: the first nextDoc() is NO_MORE_DOCS.
Observed
IllegalArgumentException: maxValue must be non-negative (got: -1) at PackedInts.bitsRequired(PackedInts.java:805) <- DocIdSetBuilder.build(DocIdSetBuilder.java:322).

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

More files, environment, full output118 words

TestDocIdSetBuilderZeroMaxDocBuild (one method, extends LuceneTestCase, package org.apache.lucene.util of the lucene/core tests). It draws no random values and builds no index. Under a second.

As a regression test the same method fits in TestDocIdSetBuilder; its 104-character comment line is the only thing checkGoogleJavaFormat flags.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3, 10.0.0, 10.5.2, 10.6.0: the same or an equivalent test fails; 6.1.0 to 9.0.0 (6 releases): a main() form fails; 5.3.0, 5.5.5, 6.0.0, 6.0.1: clean
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)

Observed output

TestDocIdSetBuilderZeroMaxDocBuild > testEmptyWithZeroMaxDoc FAILED
    java.lang.IllegalArgumentException: maxValue must be non-negative (got: -1)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3FE013F3FB3FB23E]:0)
        at org.apache.lucene.util.packed.PackedInts.bitsRequired(PackedInts.java:805)
        at org.apache.lucene.util.DocIdSetBuilder.build(DocIdSetBuilder.java:322)
        at org.apache.lucene.util.TestDocIdSetBuilderZeroMaxDocBuild.testEmptyWithZeroMaxDoc(TestDocIdSetBuilderZeroMaxDocBuild.java:28)
1 test completed, 1 failed
# the same trace with -Ptests.asserts=false

Why it looks unintended

  • The constructor allows "doc IDs between 0 and maxDoc" with no lower bound; the class returns the empty set for maxDoc 0 on its dense path and for every maxDoc of 1 or more.
  • 5.3.0 to 6.0.1 returned the empty set; the throw came with a 2016 sort speed-up (LUCENE-7261).
  • The public FacetsCollector reaches it on an empty ParallelLeafReader.
The full argument225 words
  • Contract. DocIdSetBuilder(int maxDoc): "Create a builder that can contain doc IDs between {@code 0} and {@code maxDoc}." (DocIdSetBuilder.java:148); build(): "Build a {@link DocIdSet} from the accumulated doc IDs." (:312). No javadoc, assert, CHANGES or MIGRATE entry says maxDoc must be at least 1. That is strong evidence, not proof: the class is @lucene.internal, and "between 0 and maxDoc" is loose about the usual exclusive upper bound. With the points below it makes maxDoc 0 a plausible unsupported-boundary defect rather than a documented limit.
  • The class handles the case elsewhere. maxDoc 0 on the dense path (grow(1), then build()) returns an empty BitDocIdSet (cost estimate 1 from the grow(1) reservation, iterator empty).
  • Tests. Upstream TestDocIdSetBuilder.testEmpty asserts that an empty builder builds an empty set, for maxDoc = 1 + random().nextInt(1000) (TestDocIdSetBuilder.java:30-32). That form predates the regression (first in 8f96faf3d33, 2014); it never exercises 0, and no rationale for leaving 0 out is recorded. TestParallelReaderEmptyIndex builds new ParallelLeafReader() on purpose (:50).
  • History. LUCENE-7261 "Speed up LSBRadixSorter" (#8316, 2016) replaced sorter.sort(buffer, 0, bufferSize) with sorter.sort(PackedInts.bitsRequired(maxDoc - 1), buffer, bufferSize) to skip radix passes. Its discussion is about radix-sort speed only; 6.0.x returned the empty set for maxDoc 0.
  • Precedent. LUCENE-5045 (#6109): DrillSideways threw on an empty reader and was fixed as a bug.
The devil's-advocate check169 words

The strongest case for "intended": Lucene never writes 0-document segments, and maintainers have said such segments must not exist (LUCENE-5173 #6237, opened partly over a Solr split-shard error with this very message, and the related LUCENE-5116 #6180, both 2013; later #12872 and #15803). The builder is @lucene.internal, and on 10.x and main IndexSearcher blocks the search route anyway (LFV-202).

Why it mostly fails: those statements concern segments that IndexWriter writes and codecs read, while an empty ParallelLeafReader is public API with an explicit zero-reader branch and an upstream test. @lucene.internal is a compatibility tag, not a precondition, though it does leave maintainers free to define the builder's domain; the failure is reached through the public FacetsCollector. On 9.12.3 a plain faceted search hits it, and LFV-202's recommended fix brings it back on main. Verdict: not intended, a Low robustness defect. A maintainer may still call it too minor to fix, or prefer LFV-202's skip-empty-leaves option or fix C; that is a fair outcome for a Low bug.

Suggested fix

Recommended Clamp the bitsRequired argument to 0 (A, recommended, validated)

Clamp the sort width: PackedInts.bitsRequired(Math.max(maxDoc - 1, 0)) at DocIdSetBuilder.java:322. The repro passes with assertions on and off, and 37 tests of the closest core and facet suites pass.

        sorter.sort(
            PackedInts.bitsRequired(Math.max(maxDoc - 1, 0)),
            concatenated.array,
            concatenated.length);
All options and trade-offs3 options ยท 322 words

Option A was built and checked through gradle on 5adff78bb168; B and C were not run.

Clamp the bitsRequired argument to 0 (A, recommended, validated)

In DocIdSetBuilder.build(), sparse branch (:322), pass Math.max(maxDoc - 1, 0) to PackedInts.bitsRequired, wrapped as google-java-format wants it.

        sorter.sort(
            PackedInts.bitsRequired(Math.max(maxDoc - 1, 0)),
            concatenated.array,
            concatenated.length);

Trade-offs

One Math.max per sparse build(), nothing per document (expected negligible, not benchmarked); every maxDoc of 1 or more sorts exactly as before. Caveat: it also stops most negative maxDoc values (invalid, but the constructor does not reject them) from throwing here, and they would then sort with a 1-bit width; option B is narrower, or reject a negative maxDoc in the constructor. Open PR #12800 rewrites the same line (and keeps bitsRequired(maxDoc - 1)).

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (assertions off).
  • With the fix and assertions on: the repro (1), TestDocIdSetBuilder (9), TestLSBRadixSorter (6), TestIntArrayDocIdSet (7) and, in :lucene:facet, TestDrillSideways (14): 37 tests, 0 failures. checkGoogleJavaFormat flags only the repro's long comment line, not the fix.
  • Earlier the finder, the independent reproducer and the cross-model reviewer each checked the clamp by shadowing the class: their tests passed, and TestDocIdSetBuilder passed 9 of 9 in the finder's and the reviewer's runs.

Skip the sort when the buffer is empty (B, not run)

Guard the sorter.sort(...) call with if (concatenated.length > 0).

Trade-offs

Equivalent for this bug, since a maxDoc 0 builder's sparse buffer is always empty; it also skips work for every empty builder, at the cost of one more branch.

Special-case empty leaves in FacetsCollector (C, not run)

In FacetsCollector, record DocIdSet.EMPTY for a leaf with maxDoc 0 instead of building one.

Trade-offs

Fixes the faceting route only and leaves the builder's boundary in place for other callers.

Downstream note

Downstream products are affected only if they facet over readers they compose with an empty leaf; end-user queries and documents cannot create such a leaf.

How it was found

A proof-obligation audit of DocIdSetBuilder's contract: build() must return a set for every documented maxDoc, including the empty domain 0. The sparse branch fails that obligation at bitsRequired(maxDoc - 1); a one-method JUnit test then witnessed the exception.

The method in full121 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder checked build() against the constructor's domain ("doc IDs between 0 and maxDoc") on both paths, dense and sparse, and found that only the sparse path evaluates an expression that is undefined at maxDoc 0.
  • The finder then wrote a public-API JUnit test, probes for the FacetsCollector and IndexSearcher routes, and the one-line clamp that makes the test pass.
  • An independent reproducer and an executed cross-model review reproduced it with their own tests; a devil's-advocate verifier and an adjudication re-ran the finder's test (the adjudication with a new seed), and the verifier's own faceting test showed it once LFV-202 is fixed ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No matching prior report in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-03). Nearest: #8316 (the introducing change) and #12800 (open, keeps the same expression).

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #8316 (LUCENE-7261, the introducing change; its discussion never mentions maxDoc 0), #8324 (LUCENE-7269, another zero case in another constructor, fixed in 2016), #12800 (open, rewrites the line but keeps bitsRequired(maxDoc - 1)), #16847 (open; in FacetsCollector it only changes the expression passed to new DocIdSetBuilder(...), from context.reader().maxDoc() to context.maxLeafDoc(), with no DocIdSetBuilder.java change, so not a fix), #6237 (LUCENE-5173: the same message, from 0-document segments on disk, 2013), #6180 (LUCENE-5116, the related 2013 addIndexes fix for deleted segments) and #6109 (LUCENE-5045, a precedent). Limits: keyword search; GitHub search does not index every review thread; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked the same daytwo scripted prior-report searches (the builder and sorter terms; the faceting route) and about 25 manual searches: DocIdSetBuilder, LSBRadixSorter, the exception message, bitsRequired(-1), zero-doc leaf, empty ParallelLeafReader, FacetsCollectorno report; #8316 and #12800 are the nearest
Open and recent PRs2026-11-03the changed files of open PRs updated since 2026-10-01 and PRs closed since 2026-11-01; diffs and comments of every PR touching DocIdSetBuilder.java or LSBRadixSorter.java (#709, #12800, #15383, #16089)none fixes it; #12800 keeps the expression
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@ (the scripted searches' queries plus the DocIdSetBuilder subject lists), Elasticsearch, OpenSearch and Solr trackers, 7 web queriesonly the 2013 on-disk 0-document-segment threads, before this bug existed

History

Introduced by LUCENE-7261 (8ca6f6651ed on branch_6x, ef45d4b2e1f on master, 2016-04-28), first released in 6.1.0; 5.3.0 to 6.0.1 pass. Every executed release since fails, through 10.6.0 and main. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-7261 "Speed up LSBRadixSorter" (Adrien Grand, 2016-04-28; #8316, JIRA era, no PR): ef45d4b2e1f on master and 8ca6f6651ed on branch_6x, which shipped first, in 6.1.0. It replaced sorter.sort(buffer, 0, bufferSize) with sorter.sort(PackedInts.bitsRequired(maxDoc - 1), buffer, bufferSize). git blame on main gives the line to 052fb97f828 (LUCENE-7258 "Speed up DocIdSetBuilder allocation", 2016-05-23), a wider refactor of the sparse storage that changed only this line's arguments; the bitsRequired(maxDoc - 1) expression comes from ef45d4b2e1f.

A commit-level A/B isolates it: the 6.0.0 jars with DocIdSetBuilder and LSBRadixSorter from 8ca6f6651ed fail at the changed line, and with the two classes from its parent they pass. 9.0.0 and older ran a main() form of the same steps; 9.12.3 and later ran the JUnit test. Upstream main still has the line at DocIdSetBuilder.java:322 (checked 2026-11-03).

Versions and branches executed, with results
Version or commitVerdictResult
5.3.0, 5.5.5, 6.0.0, 6.0.1 main() formpass: empty IntArrayDocIdSet
6.0.0 with the parent's two classes A/B, parentpass
6.0.0 with the two classes from 8ca6f6651ed A/B, commitfails at the changed line
6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0 main() formfails, same exception
9.12.3, 10.0.0, 10.5.2, 10.6.0 JUnitfails, same exception
main 5adff78bb168 JUnit, gradlefails, assertions on and off

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6, real, high), a devil's-advocate verifier, an adjudication, a credibility audit and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts224 words

The cross-model review ran on the older main ea57360912ed; its tests were re-run on 5adff78bb168 and fail the same way. Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test fails on main (assertions on and off), 10.6.0 and 9.12.3; probes show the FacetsCollector and 9.12.3 search routes; the clamp makes the test pass.
independent reproducer realOwn test through add(DocIdSetIterator.empty()) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; 6.0.0 passes, 6.1.0 and 8.11.4 fail; the clamp makes it pass.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own core test (trigger plus two controls) and own FacetsCollector test fail on the trigger only; the clamp passes both. Strongest counter-point: reach (internal builder, masked by LFV-202 on main).
devil's-advocate verifier real (moderate-high)Rebutted every intent argument; with LFV-202's recommended fix applied, faceted search on main throws this bug; with both fixes it returns 2 hits.
adjudication confirmed, Low (high)Re-ran the test on main with seed 1CEB00DA, assertions on and off: fails as stated. A final repro check kept it as a Low.
this doc's gradle check reproduced; fix A validatedFails on main with assertions on and off; with the clamp, 37 tests of the repro and closest suites pass.

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

Finding record

LFV-1215, snapshot of 2026-11-06

Registry title
DocIdSetBuilder.build() on an empty builder with maxDoc 0 throws IllegalArgumentException 'maxValue must be non-negative (got: -1)' instead of returning the empty set, because the sparse path always evaluates PackedInts.bitsRequired(maxDoc - 1), so FacetsCollector fails on a zero-document leaf (regression in 6.1.0)
Area
core / util (DocIdSetBuilder sparse build; reached from facet / FacetsCollector)
Priority
Low, rank 786: Faceting over a reader with an empty in-memory leaf throws instead of counting the other leaves, because DocIdSetBuilder.build() mishandles maxDoc 0, in every release since 6.1.0: on 9.x through a plain faceted search, on 10.x only when FacetsCollector is driven directly. The failure is loud with no wrong counts and needs a reader that IndexWriter never produces, so it is rare.
Impact class
minor
Impact note
Hits applications that facet (FacetsCollector or FacetsCollectorManager) over a reader they assemble in memory with a zero-document leaf (for example new ParallelLeafReader() inside a MultiReader, or a custom LeafReader with maxDoc() 0), and direct callers of the @lucene.internal DocIdSetBuilder(0): build() throws a clean IllegalArgumentException with -ea and -da instead of returning the empty set; on 9.12.3 a plain IndexSearcher.search(MatchAllDocsQuery, FacetsCollectorManager) over such a reader throws it, while on 10.x and main IndexSearcher rejects the leaf earlier (LFV-202) and fixing LFV-202 the recommended way exposes this failure again; readers over indexes that IndexWriter wrote never contain such a leaf, no scale or setting is needed, and no wrong result is ever returned; affected since 6.1.0.
Summary
You create a DocIdSetBuilder for an empty doc-ID range (new DocIdSetBuilder(0), which FacetsCollector does for every leaf as new DocIdSetBuilder(context.reader().maxDoc())), add nothing and call build(), and expect the empty set, as the same class already returns for maxDoc 0 on its dense path and for every maxDoc >= 1. Instead build() throws IllegalArgumentException 'maxValue must be non-negative (got: -1)', because its sparse branch always computes the radix sort width as PackedInts.bitsRequired(maxDoc - 1) = bitsRequired(-1), even though there is nothing to sort. So faceting over a reader that contains a zero-document leaf (for example an empty ParallelLeafReader) fails instead of counting the other leaves; Lucene 6.0.x returned the empty set.
Introduced
ef45d4b2e1f9c967b62340acb027f50888a00ba2 (master, Adrien Grand, LUCENE-7261 'Speed up LSBRadixSorter', migrated as apache/lucene#8316; pre-GitHub-PR era, patch on JIRA) and its branch_6x copy 8ca6f6651ede19bfaee9051e9b87927685cb9be0 (same author date, the copy that shipped first, in 6.1.0): it replaced sorter.sort(buffer, 0, bufferSize) with sorter.sort(PackedInts.bitsRequired(maxDoc - 1), buffer, bufferSize) in DocIdSetBuilder.build() [read: git log -S 'bitsRequired(maxDoc - 1)' finds only ef45d4b2e1f; git blame on 5adff78bb168 gives 052fb97f828 (LUCENE-7258, 2016-05-23), which only renamed the arguments to concatenated.array / concatenated.length, and blame on 052fb97f828^ gives ef45d4b2e1f] [exec: 6.0.0 jars with DocIdSetBuilder and LSBRadixSorter shadowed from 8ca6f6651ed fail at the changed line, shadowed from its parent pass] (2016-04-28 (author date 2016-04-28T18:20:41+02:00 on both master and branch_6x))
First affected version
6.1.0 [exec] (tagged 2016-06-12; contains 8ca6f6651ed; CHANGES.txt lists LUCENE-7261 under 6.1.0 Optimizations); on the master line the first release is 7.0.0. Not a new API: DocIdSetBuilder(int) and build() exist since 5.3.0 (LUCENE-6645) and returned the empty set for maxDoc 0 in 5.3.0 through 6.0.1 [exec].
Affected versions
Every release from 6.1.0 through 10.6.0 (4ae946e5a9b, released 2026-10-29) and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed upstream (upstream main still has the line at DocIdSetBuilder.java:322, checked 2026-11-03). [exec] fail: 6.1.0 (-ea, -da), 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (-ea, -da) and main (-ea, -da) (the history sweep ran a main() form of the same steps on the older releases and the JUnit repro on 9.12.3, 10.0.0, 10.6.0 and main; 10.5.2 by the independent reproduction's own test); [read] all release tags from 6.1.0 on contain the commit. Not affected [exec]: 5.3.0, 5.5.5, 6.0.0, 6.0.1 (empty set); no release before 5.3.0 has DocIdSetBuilder. Public IndexSearcher route [exec]: 9.12.3 fails through IndexSearcher.search(MatchAllDocsQuery, FacetsCollectorManager) over MultiReader(2-doc index, new ParallelLeafReader()); 10.6.0 and main stop earlier with LFV-202's IllegalArgumentException. Not new in 10.6 (10.5.2 and older releases fail). All runs used JDK 25.0.3 on Linux aarch64.
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: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (the builder/sorter spec and the faceting-route spec: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@ and 4 JIRA-archive queries each; 280 and 3109 distinct hits, 1 high by term overlap (#8313, LUCENE-7258 allocation tuning, read: not this bug)), the finder's own run, about 25 manual GitHub searches on apache/lucene (DocIdSetBuilder issues and PRs, all titles read; LSBRadixSorter; 'maxValue must be non-negative'; 'bitsRequired(-1)'; zero-doc leaf; empty ParallelLeafReader; FacetsCollector IllegalArgumentException) and on Elasticsearch, OpenSearch and Solr, the changed files of the open apache/lucene PRs updated since 2026-10-01 plus those closed since 2026-11-01, the diffs and comments of every PR that touches DocIdSetBuilder.java or LSBRadixSorter.java (#12800, #709, #15383, #16089), lists.apache.org (192 dev@ and 1 java-user@ DocIdSetBuilder mails; the two java-user@ threads with the same message read), JIRA (SOLR-9235, SOLR-9125) and 7 web queries: no issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports that DocIdSetBuilder.build() throws for maxDoc 0 or that FacetsCollector fails on a zero-document leaf. Nearest items, not this bug: #8316 (LUCENE-7261, the introducing change; its discussion never mentions maxDoc 0), #8324 (LUCENE-7269, docCount 0 in the stats constructor, other code), #12800 (open, rewrites the same line but keeps bitsRequired(maxDoc - 1)), #16847 (open, renames FacetsCollector's constructor argument only), #6237 and #6180 (LUCENE-5173/5116, the same message from 0-document segments on disk in 2013, codec code), #6109 (LUCENE-5045, a faceting IllegalArgumentException on an empty reader fixed as a bug, other code). Same-day recheck: GitHub search on apache/lucene for DocIdSetBuilder items updated since 2026-11-01 (0), 'DocIdSetBuilder build maxDoc' (0), '"maxValue must be non-negative"' (#6237 only), 'FacetsCollector maxDoc 0' (0); the newest upstream main commit on DocIdSetBuilder.java is still 1169cd2007d (#16089, author and committer date 2026-10-14) and on FacetsCollector.java aa86c2b8a04 (#13774, 2024-09-12); upstream main still has the bitsRequired(maxDoc - 1) line at DocIdSetBuilder.java:322.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT) through gradle on 2026-11-03 in a scratch worktree, seed DEADBEEF: the repro without the fix (assertions on and off: fails) and with the clamp fix (assertions off: passes; assertions on, with 4 closest core suites and TestDrillSideways: 37 tests, 0 failures), plus checkGoogleJavaFormat. The hunt ran the repro or an equivalent test on main (assertions on and off), 10.6.0, 10.5.2, 10.0.0 and 9.12.3 (fail), a main() form on 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4 and 9.0.0 (fail) and on 5.3.0, 5.5.5, 6.0.0 and 6.0.1 (pass), and a commit-level A/B of LUCENE-7261 on the 6.0.0 jars. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/DocIdSetBuilder.java:35 (at 5adff78bb168) (@lucene.internal), :148-151 (DocIdSetBuilder(int maxDoc): 'Create a builder that can contain doc IDs between 0 and maxDoc', no range check), :187 (threshold = maxDoc >>> 7), :296 (dense path new FixedBitSet(maxDoc)), :312-322 (build(): the sparse branch calls sorter.sort(PackedInts.bitsRequired(maxDoc - 1), concatenated.array, concatenated.length) at :322, fix site)
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedInts.java:803-808 (at 5adff78bb168) (bitsRequired throws IllegalArgumentException 'maxValue must be non-negative (got: ' + maxValue + ')' for maxValue < 0)
  • lucene/core/src/java/org/apache/lucene/util/LSBRadixSorter.java:89-93 (at 5adff78bb168) (sort(numBits, array, len): the len < 30 insertion-sort shortcut runs only after the numBits argument has been evaluated)
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:105-120 (at 5adff78bb168) (doSetNextReader: new DocIdSetBuilder(context.reader().maxDoc()) at :108; finish(): docsBuilder.build() at :120)
  • lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:93-97 (at 5adff78bb168) (the zero-reader branch sets maxDoc = numDocs = 0)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/DocIdSetBuilder.java:35, (at 5adff78bb168) :148-151, :187, :220-232, :296, :312-322
  • lucene/core/src/java/org/apache/lucene/util/LSBRadixSorter.java:89-93 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedInts.java:803-806 (at 5adff78bb168)
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:106-120 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:91-98 (at 5adff78bb168)
  • Lucene main 5adff78bb168: grep of 'new DocIdSetBuilder(' in non-test code (21 sites)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:108, (at 5adff78bb168) :120
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/DocIdSetBuilder.java:148, (at 5adff78bb168) :312
  • lucene/core/src/test/org/apache/lucene/util/TestDocIdSetBuilder.java:30-32; (at 5adff78bb168) lucene/core/src/test/org/apache/lucene/index/TestParallelReaderEmptyIndex.java:50
Suggested fix
  • this finding's record, Upstream and prior reports (open PR #12800 keeps the expression)
History
  • a Lucene clone: git tag --contains 8ca6f6651ed
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/DocIdSetBuilder.java:313-322; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/util/packed/PackedInts.java:803-806; lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:108, :120

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

Repro files

About the diagrams

The diagram follows the repro and the code at 5adff78bb168 (DocIdSetBuilder.java:322, PackedInts.java:803-806, FacetsCollector.java:108, :120).

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.