All findings · Lucene finding · LFV-285 · addindexes-merged-view-total-maxdoc-limit

addIndexes(CodecReader...) counts deleted documents against the doc limit since 9.9.0, rejecting inputs whose live documents fit

Since 9.9.0, by default addIndexes(CodecReader...) builds a temporary combined reader of its inputs for an optional reorder step, and that reader counts deleted documents against the 2,147,483,519 limit. Two indexes of 1,073,741,824 doc IDs with 1,024 live documents each fail in one call, though only 2,048 documents would be added.

Summary

Priority
Low, rank 978IndexWriter.addIndexes() rejects inputs whose doc IDs, deleted ones included, add up to more than 2^31; the failure is clean, the writer stays usable and splitting the call works around it.
Impact
wrong error
Since
9.9.0 (2023)introduced 2023-11-23 · about 2 years
Affected
9.9.0 to 10.6.0 and main
Status
documented
Found by
semi-formal: a Z3 model of the merged view's doc-ID arithmetic

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

addIndexes admits its inputs by live documents, but since 9.9.0 it first builds a combined view of them for an optional reorder step, and that view counts deleted documents against the limit.

Background: live documents, doc IDs and the reorder step

  1. IndexWriter.addIndexes(CodecReader...) copies documents from readers you pass, such as the segments of other indexes, possibly filtered, into this index. Deleted documents are dropped; only live documents are copied.
  2. An index holds at most IndexWriter.MAX_DOCS = 2,147,483,519 documents. A reader's maxDoc() counts every doc ID, deleted ones included; numDocs() counts only the live ones.
  3. Since 9.9.0 a merge may renumber documents first through OneMerge#reorder, which BPReorderingMergePolicy uses. To offer that, Lucene glues the merge's inputs into one temporary combined reader. By default reorder() does nothing and returns null.
Summary card of the bug at Apache Lucene main 645d4fe14a20, using the small example from the repro: segments _0 (one deleted, one live document) and _1 (one live document), doc limit 2. Left, five numbered steps inside dst.addIndexes(leaf0, leaf1), each with code references: 1, testReserveDocs(2) passes because 1 + 1 live documents fit; 2, with no index sort a merged view of the inputs is built (IndexWriter.java:3561, SlowCompositeCodecReaderWrapper.java:95); 3, its MultiReader sums maxDoc(): 2 + 1 = 3 doc IDs; 4, 3 > 2 throws IllegalArgumentException and nothing is added; 5, never reached: the default reorder() returns null and the view would be dropped, and SegmentMerger would write the 2 live documents. Below: every count on the way in is of live documents; only the view counts all doc IDs. Right, top: the incoming readers _0 (deleted, live) and _1 (live) become the merged view doc 0, 1, 2, and doc 2 lies outside the doc-limit frame of 2. Right, middle: expected, adds 2 documents, numDocs 2 afterwards; actual, IllegalArgumentException 'Too many documents: composite IndexReaders cannot exceed 2 but readers have total maxDoc=3', numDocs 0. Then one line on full scale with no test hook: 2 x 1,073,741,824 doc IDs, 2,048 live; one call fails on main and 10.5.2 (MAX_DOCS + 129 doc IDs); two calls, or Lucene 9.8.0, add all 2,048 documents. Right, bottom: a clean failure, nothing written and the writer usable; the workaround of one addIndexes call per input index or a findMerges(CodecReader...) override that splits the inputs; the fix sketch of building the view only if the inputs' maxDoc sum fits, or only when reorder() is overridden; and the affected range, every release from 9.9.0 to 10.6.0 and main, with 9.8.0 passing.
The small example from the repro: segments _0 (one deleted, one live document) and _1 (one live), doc limit 2. addIndexes admits the 2 live documents, but the merged view built for reorder() numbers all 3 doc IDs (2 + 1 > 2) and is refused. Full scale fails the same way: 2 x 1,073,741,824 doc IDs, 2,048 live. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Two indexes, A and B, have 1,073,741,824 doc IDs each but only 1,024 live documents each (the rest deleted). You pass all their segments to one addIndexes(CodecReader...) call on a writer with the default IndexWriterConfig.
  2. You expect 2,048 documents added: far below the limit of 2,147,483,519 (MAX_DOCS), and every document-limit check addIndexes makes counts live documents. 9.8.0 adds them.
  3. Instead it first glues the inputs into one temporary reader for OneMerge#reorder. That reader counts all 2,147,483,648 doc IDs, deleted ones included, and refuses: IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed 2147483519 but readers have total maxDoc=2147483648. Nothing is added.
  4. The temporary reader was not needed: the default reorder() returns null. Two calls (A, then B) add the 2,048 documents.

Who is affected

Applications that add several very large inputs in one addIndexes(CodecReader...) call with the default config, when their doc IDs, deleted or filtered-out ones included, pass 2,147,483,519 while their live documents fit: for example consolidating heavily deleted views of two billion-document indexes. The call fails cleanly and writes nothing; splitting the inputs over several calls works around it. Rare.

Mechanism, terms and numbers798 words

IndexWriter.addIndexes(CodecReader...) copies only the live documents of its inputs and checks the doc limit (MAX_DOCS, 2,147,483,519) against live documents only. Since 9.9.0, when no index sort is configured, addIndexesReaderMerge first wraps all inputs of the merge in a SlowCompositeCodecReaderWrapper so that OneMerge#reorder can renumber documents. The wrapper builds a MultiReader, which counts every doc ID, deleted ones included, and throws IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed ... when they add up to more than the limit. The default reorder() returns null, so the view is built and thrown away.

So a call whose inputs fit by live documents but not by maxDoc() fails cleanly and adds nothing: at full scale with no test hook, two indexes of 1,073,741,824 doc IDs with 1,024 live documents each fail in one call on main and 10.5.2, succeed in two calls, and succeed in one call on 9.8.0. It needs more than 2^31 doc IDs across the inputs of one call, so it is rare. Introduced by #12622 (9.9.0); every release from 9.9.0 to 10.6.0 and main is affected. Building the view only when the inputs' maxDoc() sum fits fixes it; that fix passes the reduced-scale repro, the closest core suites, TestBPReorderingMergePolicy and core lint (not run at full scale), but it silently skips a custom reorder() for such an oversized merge.

The bug, step by step (main 645d4fe14a20, under lucene/core/src/java/org/apache/lucene/index/)

  1. Admission counts live documents. addIndexes(CodecReader...) (IndexWriter.java:3275) sums leaf.numDocs() (:3294) and calls testReserveDocs(numDocs) (:3296). The default MergePolicy.findMerges(CodecReader...) puts all readers into one OneMerge (MergePolicy.java:627-631), whose totalMaxDoc is the sum of numDocs() (MergePolicy.java:245-254); after success addIndexes reserves that sum (IndexWriter.java:3344, :3356). addIndexesReaderMerge checks live documents once more (:3504, :3518).
  2. The eager view. With no index sort and no input that has blocks without a parent field (:3548-3556), addIndexesReaderMerge runs CodecReader mergedReader = SlowCompositeCodecReaderWrapper.wrap(readers); (:3561) and only then merge.reorder(mergedReader, directory, intraMergeExecutor) (:3562). If reorder returns null, the input readers are merged as they are (after OneMerge#wrapForMerge, :3542-3546, which by default returns each reader unchanged).
  3. The view counts deleted documents. wrap returns a single reader as is (SlowCompositeCodecReaderWrapper.java:54); for two or more it builds the wrapper, which adds up reader.maxDoc() into int doc starts (:73) and creates new MultiReader(...) over the inputs (:95). BaseCompositeReader sums maxDoc() as a long (BaseCompositeReader.java:86) and above IndexWriter.getActualMaxDocs() (:89) throws IllegalArgumentException("Too many documents: composite IndexReaders cannot exceed ...") for a reader that is not a DirectoryReader (:99-107).
  4. The view was not needed. The default OneMerge#reorder returns null (MergePolicy.java:325-328; javadoc "will be called when index sorting is disabled on a merged view of the OneMerge", :311-324). SegmentMerger (IndexWriter.java:3569) gets those input readers and drops deleted documents: without the view, 9.8.0 writes the merged segment at full scale, and the proposed fix passes the reduced-scale tests (it was not run at full scale).
  5. How it fails. The exception is thrown before SegmentMerger exists, so nothing is written; with the default ConcurrentMergeScheduler the merge fails on the merge thread, which also reports it as an uncaught MergeException, and addIndexes rethrows the merge's exception (:3382); with SerialMergeScheduler (the minimal test) it comes straight out of handleMergeException (:5056). Either way only an Error is treated as tragic (:3390). Each failed call leaves its merge in the writer's merge-exception list (:5040-5041, :5819-5824) until the next forceMerge clears it (:2250) or the writer is closed: a probe counted 20 retained entries after 20 failing calls.
  6. Who reaches it. One index's own maxDoc is capped at MAX_DOCS (a DirectoryReader above it is rejected as corrupt, BaseCompositeReader.java:90-98), and a single input never builds a MultiReader, so at real scale one merge needs two or more input readers whose maxDoc() sum exceeds the limit: for example the leaves of two indexes (the executed runs), or the leaves of one index passed twice, as TestIndexWriterMaxDocs.testAddTooManyIndexesCodecReader repeats one reader (read, not run). The ordinary merge path builds the same view (IndexWriter.java:5557), but there the writer has reserved every input document, so its maxDoc sum stays within the limit.

Executed (assertions off; main ea57360912ed, the same four files as 645d4fe14a20)

targetinputs (no test hook)one calltwo calls
main2 indexes x 1,073,741,824 doc IDs, 1,024 live each; maxDoc sum 2,147,483,648 = MAX_DOCS + 129IllegalArgumentException, nothing added2,048 added
10.5.2the sameIllegalArgumentException2,048 added
9.8.0the same2,048 added2,048 added
main (separate run)2 indexes x 1,073,741,760 doc IDs filtered to 32 live each; maxDoc sum MAX_DOCS + 1IllegalArgumentException64 added
Exposure and likelihood242 words
  • Who is hit. Callers of addIndexes(CodecReader...) with the default configuration (no index sort) that pass two or more inputs (for example the segments of two indexes) holding more than 2,147,483,519 doc IDs in total, deleted or filtered-out ones included, while their live documents fit: for example heavily deleted, or FilterCodecReader-filtered, views of two indexes of about a billion documents each.
  • What goes wrong. The call is refused with a misleading message (the result would hold a fraction of the limit) and adds nothing. It fails cleanly: in a probe of 20 failing calls in a row (assertions on and off) no file was written, no document reservation leaked, the inputs were untouched, the writer stayed open and not tragic, and CheckIndex was clean afterwards. Each failed call does leave one entry in the writer's merge-exception list until the next forceMerge or close.
  • Workarounds. Split the inputs so that each merge's maxDoc() sum fits: one addIndexes call per input index (executed at full scale), or a findMerges(CodecReader...) override that splits them into groups (executed at small scale). An index sort on the destination is not a general workaround: it skips the view, but a sorted destination accepts only inputs that already have a congruent index sort and rejects unsorted ones with cannot change index sort (IndexWriter.java:3245-3251).
  • Likelihood: rare. Few deployments combine more than 2^31 doc IDs in one call, and the recorded searches found no public report since 9.9.0 (2023).

Minimal repro

One JUnit test in lucene/core: a source index with two segments, maxDoc 2 with one deleted document and maxDoc 1, so 2 live documents and a maxDoc sum of 3. The test framework's setIndexWriterMaxDocs(2) lowers the limit for the destination, as TestIndexWriterMaxDocs does. One addIndexes call with both segments should add 2 documents; it throws.

TestAddIndexesDeletedDocsLimit.java (one test)56 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.index;

import java.util.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAddIndexesDeletedDocsLimit extends LuceneTestCase {

  public void testOnlyLiveDocsCountTowardsLimit() throws Exception {
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(src, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
        w.addDocument(List.of(new StringField("id", "deleted", Field.Store.NO)));
        w.addDocument(List.of(new StringField("id", "live", Field.Store.NO)));
        w.commit();
        w.addDocument(List.of(new StringField("id", "live", Field.Store.NO)));
        w.deleteDocuments(new Term("id", "deleted"));
      }
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w =
              new IndexWriter(
                  dst, new IndexWriterConfig().setMergeScheduler(new SerialMergeScheduler()))) {
        setIndexWriterMaxDocs(2);
        try {
          w.addIndexes(
              (CodecReader) r.leaves().get(0).reader(), (CodecReader) r.leaves().get(1).reader());
        } catch (IllegalArgumentException e) {
          fail("expected: 2 live docs added under a limit of 2; actual: " + e.getMessage());
        } finally {
          restoreIndexWriterMaxDocs();
        }
        assertEquals(2, w.getDocStats().numDocs);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or upstream main, branch_10_6, 10.6.0)
cp $REPRO/TestAddIndexesDeletedDocsLimit.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestAddIndexesDeletedDocsLimit -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
2 documents added: the limit is 2 and the inputs hold 2 live documents.
Observed
IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed 2 but readers have total maxDoc=3; nothing added, with assertions on and off.

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

More files, environment, full output304 words

Public API plus the test framework's setIndexWriterMaxDocs, ByteBuffersDirectory, SerialMergeScheduler so the merge runs on the test thread; deterministic, single thread. The limit is lowered only after the source index is built. At real scale one index cannot exceed the limit, so one merge needs two or more readers whose maxDoc() sum does, such as the leaves of two indexes; the full-scale runs with no test hook (in the observed output) show the same failure.

With the default ConcurrentMergeScheduler the test fails the same way, and the merge thread's uncaught MergeException is reported as well. As the regression test it can be used as is: it has the license header and passes core lint.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; the same test also fails on the 10.6.0 and 9.9.0 jars and passes on 9.8.0
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestAddIndexesDeletedDocsLimit > testOnlyLiveDocsCountTowardsLimit FAILED
    java.lang.AssertionError: expected: 2 live docs added under a limit of 2; actual: Too many documents: composite IndexReaders cannot exceed 2 but readers have total maxDoc=3
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:827B29374A95FE3C]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.apache.lucene.index.TestAddIndexesDeletedDocsLimit.testOnlyLiveDocsCountTowardsLimit(TestAddIndexesDeletedDocsLimit.java:48)

:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false. Where it is thrown (default ConcurrentMergeScheduler, main 645d4fe14a20):
org.apache.lucene.index.MergePolicy$MergeException: java.lang.IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed 2 but readers have total maxDoc=3
    at __randomizedtesting.SeedInfo.seed([DEADBEEF]:0)
    at org.apache.lucene.index.ConcurrentMergeScheduler.handleMergeException(ConcurrentMergeScheduler.java:761)
    at org.apache.lucene.index.ConcurrentMergeScheduler$MergeThread.run(ConcurrentMergeScheduler.java:753)
Caused by: java.lang.IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed 2 but readers have total maxDoc=3
    at org.apache.lucene.index.BaseCompositeReader.<init>(BaseCompositeReader.java:104)
    at org.apache.lucene.index.MultiReader.<init>(MultiReader.java:76)
    at org.apache.lucene.index.MultiReader.<init>(MultiReader.java:50)
    at org.apache.lucene.index.SlowCompositeCodecReaderWrapper.<init>(SlowCompositeCodecReaderWrapper.java:95)
    at org.apache.lucene.index.SlowCompositeCodecReaderWrapper.wrap(SlowCompositeCodecReaderWrapper.java:56)
    at org.apache.lucene.index.IndexWriter.addIndexesReaderMerge(IndexWriter.java:3561)
    at org.apache.lucene.index.IndexWriter$AddIndexesMergeSource.merge(IndexWriter.java:3463)
    at org.apache.lucene.index.ConcurrentMergeScheduler.doMerge(ConcurrentMergeScheduler.java:661)
    at org.apache.lucene.index.ConcurrentMergeScheduler$MergeThread.run(ConcurrentMergeScheduler.java:726)

# full scale, no test hook, main ea57360912ed (same four files), assertions off:
inputs: leaves=2048 sum maxDoc=2147483648 (MAX_DOCS+129) sum numDocs=2048
CONTROL two calls: OK numDocs=2048
ONE CALL: FAILED java.lang.IllegalArgumentException: Too many documents: composite IndexReaders cannot exceed 2147483519 but readers have total maxDoc=2147483648
# 10.5.2: the same; 9.8.0: ONE CALL: OK numDocs=2048

Why it looks unintended

  • Every document-limit check and reservation on this path counts live documents; the javadoc throws only "if addIndexes would cause the index to exceed MAX_DOCS".
  • The combined reader only feeds OneMerge#reorder, whose default returns null; the same inputs split over two calls succeed.
  • 9.8.0 accepted the call; the 9.9.0 change never discussed doc limits, and nothing announces the restriction.
The full argument260 words
  • Live documents everywhere. The up-front check (IndexWriter.java:3294-3296), OneMerge(CodecReader...)'s totalMaxDoc (MergePolicy.java:245-254), the merge's own check (:3504, :3518) and the final reservation (:3344, :3356) all count live documents. The javadoc's only doc-limit failure is "if addIndexes would cause the index to exceed MAX_DOCS" (:3272-3273), and MAX_DOCS is the limit on documents "added to the index" (:201-203). The view is the one place that counts deleted documents.
  • Unused by default. The default reorder() returns null and the view is discarded; the same inputs succeed in two calls or with one merge per reader. For inputs that are already index-sorted, the destination's index sort alone decides: two sorted inputs fail into an unsorted destination and succeed into one with the same sort, which skips the view (executed at small scale). Unsorted inputs cannot take that route, since a sorted destination rejects them.
  • A regression. 9.8.0 accepts the identical full-scale call. #12622, which added the view, never discusses doc limits, addIndexes or MAX_DOCS in its 45 comments and reviews; its author describes the view as "the reordering logic that works on a merged view of the input segments and doesn't care about deletes". No CHANGES.txt, MIGRATE.md, javadoc or test entry announces a limit on the inputs' maxDoc() sum.
  • Deleted and filtered inputs are normal here. TestAddIndexes.testFakeAllDeleted adds an all-deleted filtered reader; MultiPassIndexSplitter ("the input data comes from the input index with artificially applied deletes") and PKIndexSplitter pass filtered leaves. TestIndexWriterMaxDocs tests the addIndexes limit only with all-live inputs.
The devil's-advocate check212 words

The strongest case for "works as designed", rated moderate by the independent reproducer and cross-model reviews (root-cause and intended lenses); the refute lens rated it weak and the intended-behaviour devil's advocate found it does not survive: a composite reader has one int doc-ID space, OneMerge#reorder is documented to receive a merged view whenever index sorting is off, so a merge whose inputs cannot be addressed as one reader is arguably unrepresentable; findMerges(CodecReader...) (whose javadoc mentions one merge per reader) or separate calls are the documented knobs. It also needs more than about 2.1 billion input doc IDs and fails loudly before anything is written, with an easy workaround, so maintainers may see a surprising limitation rather than a contract violation.

Why it fails: with the default configuration nothing uses the view, every document-limit check counts live documents, for already index-sorted inputs the destination's index sort alone decides whether the limit applies, and the call worked in 9.8.0 with no announcement of the change. Where it does hold: a merge policy that really overrides reorder(), such as BPReorderingMergePolicy, needs the view, so a fix must decide what that policy gets for an oversized group. Maintainers may also prefer a javadoc change, since the @throws clause does not literally say "only if".

Suggested fix

Recommended Build the merged view only when the inputs' maxDoc sum fits (+7/-1 in IndexWriter.java)

Build the merged view only when the inputs' maxDoc() sum fits the limit (+7/-1 in IndexWriter.java); otherwise the merge runs on the input readers, which drop deleted documents. Caveat: a custom reorder() is then silently skipped for such a merge. Validated at reduced scale only: the minimal test, the closest core suites and TestBPReorderingMergePolicy pass.

// addIndexes only reserves live docs, so the inputs' maxDoc (deleted docs included) may add up
// to more than a single (composite) reader can address: then do not offer a merged view
final long totalMaxDoc = readers.stream().mapToLong(CodecReader::maxDoc).sum();
if (hasIndexSort == false
    && hasBlocksButNoParentField == false
    && readers.isEmpty() == false
    && totalMaxDoc <= getActualMaxDocs()) {
  CodecReader mergedReader = SlowCompositeCodecReaderWrapper.wrap(readers);
  ...
All options and trade-offs4 options · 495 words

One fix was built and validated on 645d4fe14a20 with gradle; the alternatives come from the adjudication and the checks and were not run.

Build the merged view only when the inputs' maxDoc sum fits (+7/-1 in IndexWriter.java) (validated by the checks) (recommended)

In addIndexesReaderMerge, add the inputs' maxDoc() sum to the condition that builds the view. When it does not fit, the merge runs on the input readers, exactly as without reordering, and drops deleted documents. A custom reorder() is then silently not called for that group (see the trade-offs).

// addIndexes only reserves live docs, so the inputs' maxDoc (deleted docs included) may add up
// to more than a single (composite) reader can address: then do not offer a merged view
final long totalMaxDoc = readers.stream().mapToLong(CodecReader::maxDoc).sum();
if (hasIndexSort == false
    && hasBlocksButNoParentField == false
    && readers.isEmpty() == false
    && totalMaxDoc <= getActualMaxDocs()) {
  CodecReader mergedReader = SlowCompositeCodecReaderWrapper.wrap(readers);
  ...

Trade-offs

  • For an oversized group a merge policy that overrides reorder() is not called, so that group is merged without reordering, silently (a probe counted reorderCalls=0). BPReorderingMergePolicy already treats reordering as best-effort: it skips it on NotEnoughRAMException and records reordered=false in the segment diagnostics when reorder() was not called. An infoStream message for the skipped case would make it visible (not run).
  • Cost: one pass over the inputs' maxDoc() per merge.

What the checks validated

  • Scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF: stock, the minimal test fails with assertions on and off; with the fix it passes, on and off.
  • With the fix: TestAddIndexes 39 of 39, TestIndexWriterMaxDocs (19 run, 2 skipped), TestIndexWriterMergePolicy (18 run, 3 skipped), TestOneMergeWrappingMergePolicy and TestGetVectorCountCodecWrappers pass: 83 tests, 5 skipped, 0 failures.
  • With the fix: TestBPReorderingMergePolicy passes (1 run, 2 nightly tests skipped; an earlier check ran the nightly tests with the fix: 3 of 3 pass).
  • Core lint (google-java-format, ecj, forbidden APIs) passes with the fix and the test in place.
  • Not run at full scale: every fix run lowered the limit with the test hook.

Build the view only when reorder() is meant to run

For example a OneMerge method that says whether it reorders, or a view built lazily; give a clear error only when a reordering policy meets an oversized group (the adjudication's preference).

Trade-offs

Keeps the default configuration free of the view and makes the oversized case explicit, but needs a small API change or an override check; not run.

Split the inputs in the default findMerges(CodecReader...)

Group the readers so that each OneMerge's maxDoc() sum fits.

Trade-offs

Reordering still runs per group, but one call then produces several segments, unlike the documented default of one, and custom findMerges overrides still hit the limit; not run.

Document the limit

State that, without an index sort, the inputs of one merge must fit MAX_DOCS by maxDoc(), deleted documents included.

Trade-offs

No code change, but keeps the 9.9.0 regression and makes the limit depend on index sorting; not run.

Downstream note

Solr's MERGEINDEXES core-admin action passes the segments of several source indexes to one addIndexes(CodecReader...) call, so merging very large, heavily deleted cores there can hit it (read, not tested). Solr's shard split adds one filtered segment per call, so it never builds the view and is not affected.

How it was found

Semi-formal. While modelling the doc-ID arithmetic of the merged view in Z3 with exact Java int semantics, the AI-assisted hunt asked whether inputs can fit the limit by live documents but not by maxDoc(): satisfiable for addIndexes, impossible on the ordinary merge path. A JUnit on the stock jar then hit the exception.

The method in full166 words

Method: semi-formal (an SMT query encoding a hypothesis formed while modelling, then executed tests).

  • In a hunt unit (CodecReader wrappers) the finder modelled SlowCompositeCodecReaderWrapper's doc-ID arithmetic in Z3 (quantifier-free bit-vectors, exact Java int semantics).
  • Query Q5a, with the ordinary merge path's precondition (the inputs' maxDoc() sum within MAX_DOCS, which the writer guarantees for its own segments), showed that the view's doc starts never wrap (unsat).
  • Query Q5b encoded addIndexes(CodecReader...)'s actual precondition (live documents within MAX_DOCS) and asked for a maxDoc() sum above it: sat, with a concrete witness. So the view's MultiReader check is reachable from a legal call.
  • The finder bridged that to the stock jar with a JUnit at reduced scale and a full-scale run with no test hook; independent checks reproduced it (see the checks below).
  • Recorded as semi-formal rather than formal: the query encodes a hypothesis and its sat answer is plain arithmetic; the exception path itself was found by reading the code and confirmed by execution.

Prior reports, history and checks

Prior reports

No public report of this bug found in the recorded searches (2026-10-26, refreshed 2026-10-31): three search-tool runs over GitHub, the JIRA archive, the mailing lists and the web, 33 manual GitHub searches and a list phrase search; 25 items read. Closest: the open draft #16847 (long doc IDs), which changes the limit behind this exception.

Searches4 searches

Verdict new (no public report found in the recorded searches; absence cannot be proven); not fixed upstream. Closest items, none this bug: #12622 (introduced the view); #6905 / LUCENE-5843 and #7360 / LUCENE-6299 (2014-2015, the limit checks and this message, for indexes that really exceed the limit); #11253 / #633 (LUCENE-10216, the live-document accounting of OneMerge(CodecReader...)); #14994, #15025, #15120 (other defects in addIndexesReaderMerge); #16879 (open, edits the wrapper for HNSW reuse, not this code).

Open draft #16847 (jimczi, "Support more than 2^31 documents per index (long global doc ids)"): it makes IndexReader.maxDoc() a long, checks composite totals against a new 2^37 limit, and its MergePolicy javadoc says merge inputs "may, together, hold more than Integer.MAX_VALUE documents (only the merged result is bounded by MAX_DOCS once deletions are dropped)", which is this bug's premise. It keeps the eager wrap(readers) and the view's int docStart += reader.maxDoc(); if it lands unchanged, this clean exception may turn into a view with wrapped doc starts (read from the diff, not run).

Gaps: only public, indexed sources; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
a scripted prior-report search: GitHub (apache/lucene issues, PRs, commits), downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web2026-10-26two runs (class and method names, maxDoc/numDocs/deleted/live docs, both symptom messages; the reorder angle) plus the finder's earlier run1,274, 399 and 1,262 distinct hits; none is this bug
GitHub (manual): apache/lucene, apache/solr and other downstream repositories; all 412 open apache/lucene PRs2026-10-2633 searches; the changed files of every open PRonly the related items listed above
JIRA archive (LUCENE, SOLR), lists.apache.org, web2026-10-26"composite IndexReaders cannot exceed", "readers have total maxDoc", "Too many documents" with addIndexes or mergeindexes; 9 web queries35 mails and 6 JIRA issues, all about indexes that really exceed the limit
GitHub refresh2026-10-31the symptom message and method names, updated since 2026-10-20; commits on main since 2026-10-26 to the four files; #16847 statenothing new; #16847 still an open draft (two of its queries were re-run the same day, nothing new)

History

Introduced by #12622 (f7cab1645017, 2023-11-23), which added OneMerge#reorder and the merged view; first released in 9.9.0. Executed: all 23 releases from 9.9.0 to 10.6.0 and main fail; 8.11.4, 9.0.0, 9.7.0 and 9.8.0 pass. Not fixed.

Versions tested8 rows

Introduced by #12622 "Add a merge policy wrapper that performs recursive graph bisection on merge." (jpountz), merged 2023-11-23 as f7cab1645017 on main, with the branch_9x backport 6b1538b3313 the same day. It added SlowCompositeCodecReaderWrapper, OneMerge#reorder (default null) and the view in addIndexesReaderMerge and on the ordinary merge path. Found with git blame, git log -L and a pickaxe for SlowCompositeCodecReaderWrapper.wrap(readers), which finds only these two commits; isolated by execution: 9.8.0 plus only the backport's core diff fails, 9.9.0 with only the view block removed passes.

Later changes, not fixes: #13128 (9.11.0) also skips the view when inputs have blocks but no parent field; #13289 (9.12.0) passes an executor to reorder(). The doc-limit check itself (LUCENE-6299, 2015) is older and unchanged. No later commit touches the defect lines on main (upstream main 2f424fd428da, 2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0 affectedthe minimal test fails at reduced scale, assertions on and off (no full-scale run)
main ea57360912ed, 10.5.2 full scaleno test hook: maxDoc sum 2,147,483,648 with 2,048 live documents fails in one call, works in two
9.9.0 to 10.5.2 (22 releases) 9.9.0: first affectedthe hunt's two test classes fail 3 of 7 at reduced scale; assertions off too on 9.9.0, 9.12.3, 10.5.2; the minimal test fails on 9.9.0
9.8.0 last unaffectedpasses at reduced and full scale (one call adds 2,048 documents); the minimal test passes
8.11.4, 9.0.0, 9.7.0 not affectedpass (no merged view before #12622); 8.11.4 and 9.0.0 with a copy of the test classes whose only change is the test-framework import
9.8.0 + only the backport's core diff; 9.9.0 minus only the view block bisectthe first fails, the second passes
fix on 645d4fe14a20 not affectedthe minimal test passes, assertions on and off; 83 core tests (5 skipped) and TestBPReorderingMergePolicy pass (reduced scale only)

Independent checks

An independent reproducer, a root-cause check, an intended-behaviour devil's advocate and an adjudication confirmed it with their own tests, at small and full scale. Reading-only reviews by another model family (OpenAI GPT-5.6) agreed on the root cause; one gave a procedural "not real" because it could not run code. Still reproduces on main.

Checks and verdicts310 words

Neither GPT review executed code; the finder, the independent reproducer, the root-cause check's separate runs, the devil's advocate, the adjudication, the follow-up and this doc did.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (Z3 model, then JUnit) candidate, executedFails on main, 10.5.2, 10.0.0, 9.12.3, 9.9.0; passes on 9.8.0 and with the fix; full scale on a separate host: maxDoc sum MAX_DOCS + 1, 64 live documents, one call fails.
Cross-model reviews, refute and intended lenses (OpenAI GPT-5.6, reading only) not real (low, procedural); real (medium)Could not run code; both agreed on the root cause and that the inputs are in contract; the refute lens rated the intended argument weak, the intended lens moderate.
Independent reproducer realOwn 5-test class fails 2 of 5 on main, 10.5.2, 9.12.3, 9.9.0; own full-scale run with no hook fails on main and 10.5.2, passes on 9.8.0; the writer stays usable.
Root-cause adversary (OpenAI GPT-5.6, reading only) and separate runs real, mediumRoot cause is the eager view, not the composite check; 7-test class: 3 fail, including maxDoc sum = limit + 1 while = limit passes; with the fix 7 of 7 pass and a custom reorder() is skipped for an oversized group.
Intended-behaviour devil's advocate real, medium-highOnly the unsorted destination fails (index-sorted inputs); a destination with the same sort (same inputs) and one merge per reader (unsorted inputs) succeed; 9.8.0 passes all three. It concludes the works-as-designed argument does not survive.
Adjudication confirmed, highRe-ran the independent test with a new seed: 2 of 5 fail, assertions on and off.
Current-main re-runs still reproducesMain a9779ef3c193 (three replays) and 645d4fe14a20 (the minimal test, assertions on and off).
This doc still reproduces; fix validatedGradle on a scratch worktree of 645d4fe14a20; the 10.6.0, 9.9.0 and 9.8.0 jars.

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

Finding record

LFV-285, snapshot of 2026-11-06

Registry title
addIndexes(CodecReader...) with the default config rejects inputs whose maxDoc() sum, deleted documents included, exceeds MAX_DOCS although only their live documents are added, because it always builds a merged view of the inputs for OneMerge#reorder (regression since 9.9.0)
Area
index / IndexWriter addIndexes(CodecReader...) (addIndexesReaderMerge merged view: SlowCompositeCodecReaderWrapper over a MultiReader, BaseCompositeReader doc limit)
Priority
Low, rank 978: IndexWriter.addIndexes() rejects inputs whose doc IDs, deleted ones included, add up to more than 2^31; the failure is clean, the writer stays usable and splitting the call works around it.
Impact class
wrong error
Impact note
Hits an application that adds several very large inputs in one addIndexes(CodecReader...) call with the default IndexWriterConfig (no index sort) when the inputs' maxDoc() sum, deleted or filtered-out documents included, exceeds 2^31-129 while their live documents fit (for example consolidating heavily deleted or filtered views of two indexes of about a billion documents each): the call fails cleanly with IllegalArgumentException and adds nothing, the writer stays open and usable and nothing is written, the default ConcurrentMergeScheduler also reports the exception as an uncaught MergeException, and one call per input (or a findMerges(CodecReader...) override that splits the inputs) works around it.
Summary
You call IndexWriter.addIndexes(CodecReader...) with readers that have many deleted or filtered-out documents, so their live documents fit under IndexWriter.MAX_DOCS but their maxDoc() values (deleted documents included) add up to more. You expect the call to succeed, since addIndexes counts and reserves only live documents, its javadoc throws IllegalArgumentException only if the index would exceed MAX_DOCS, and 9.8.0 accepted the same call. Instead, with the default configuration it throws IllegalArgumentException 'Too many documents: composite IndexReaders cannot exceed ...' and adds nothing, because since 9.9.0 addIndexesReaderMerge always wraps the inputs in a SlowCompositeCodecReaderWrapper (a MultiReader over the uncompacted inputs) for OneMerge#reorder, whose default returns null and discards the view.
Introduced
f7cab1645017d863331b42900581b67d3591e2da on main and its branch_9x backport 6b1538b33136a4737ad0d22b3633e62f3d7f362b, PR #12622 'Add a merge policy wrapper that performs recursive graph bisection on merge.' (jpountz): it added SlowCompositeCodecReaderWrapper and OneMerge#reorder and made addIndexesReaderMerge build the merged view whenever no index sort is configured [read: git blame, git log -L and a pickaxe for 'SlowCompositeCodecReaderWrapper.wrap(readers)', which finds only these two commits; exec: 9.8.0 plus only the backport's core diff fails, 9.9.0 with only the merged-view block removed passes] (2023-11-23 (f7cab1645017 on main and the branch_9x backport 6b1538b3313, same day))
First affected version
9.9.0 [exec] (tagged 2023-12-04, through the branch_9x backport 6b1538b3313; fails with -ea and -da); on the 10.x line 10.0.0 [exec]; 9.8.0 is the last unaffected release [exec: -ea and -da at reduced scale, and -da at full scale with no test hook]
Affected versions
Every release from 9.9.0 through 10.6.0 (23 releases: 9.9.0-9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, 10.6.0) and main ea57360912ed [exec: the finder's and the independent reproduction's tests at reduced scale (limit 10 via the test framework's setIndexWriterMaxDocs) fail 3 of 7 on each of the 22 releases from 9.9.0 to 10.5.2 and on main; -da also at 9.9.0, 9.12.3, 10.5.2 and main]; at full scale with no test hook (inputs' maxDoc sum 2^31, 2048 live documents) main and 10.5.2 fail and 9.8.0 passes [exec, -da]. Not affected: 8.11.4, 9.0.0, 9.7.0 and 9.8.0 [exec]; every release from 5.0.0 to 9.8.0 by reading (no merged view before #12622). Not fixed upstream: no later commit touches the defect code on main (head e1c4184cb98, 2026-10-26) or branch_10x [read]; 10.6.0 (released 2026-10-29): the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da [exec 2026-10-31]. All runs on aarch64 Linux, JDK 25 (plain Java logic, no vector code).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: Z3; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only; verdict new): two scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (1274 and 399 distinct hits), the finder's earlier run (1262 hits), 33 read-only GitHub searches on apache/lucene and downstream repositories, JIRA REST queries on LUCENE and SOLR, a lists.apache.org phrase search for 'composite IndexReaders cannot exceed' (35 mails, all about indexes that really exceed the limit), web searches, and the file lists of all 412 open apache/lucene PRs (finder); 25 items read, none the same bug. Related, not reports: #12622 (the introducing PR; its comments and reviews never discuss the doc limit), #6905 / LUCENE-5843 and #7360 / LUCENE-6299 (2014-2015, the doc-limit checks and this message, about indexes that really exceed the limit), #11253 / #633 (LUCENE-10216, the live-doc OneMerge accounting), the open draft #16847 (jimczi, long global doc IDs: it changes the limit behind this exception and keeps the view's int docStart, so if it lands unchanged this clean exception may become a silently wrapped view; read from the diff, not executed) and the open PR #16879 (touches SlowCompositeCodecReaderWrapper, not this code). Not fixed: no later commit to the defect code on main or branch_10x, and no CHANGES or MIGRATE entry.
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree and the standalone runner, assertions on and off); the minimal test on the 10.6.0 jars (fails, assertions on and off), 9.9.0 (fails) and 9.8.0 (passes); the hunt's two test classes on the 22 releases from 9.9.0 to 10.5.2, on 8.11.4, 9.0.0, 9.7.0 and 9.8.0 (8.11.4 and 9.0.0 with a copy whose only change is the test-framework import) and on main ea57360912ed; full scale with no test hook only on main ea57360912ed, 10.5.2 and 9.8.0 (assertions off); the recommended fix in a scratch worktree of 645d4fe14a20, at reduced scale only. The four defect files are identical at ea57360912ed, 645d4fe14a20 and upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3272-3273 (at ea57360912ed) (addIndexes(CodecReader...) javadoc: '@throws IllegalArgumentException if addIndexes would cause the index to exceed MAX_DOCS'), :3289-3296 (sums leaf.numDocs(), testReserveDocs(numDocs)), :3500-3518 (addIndexesReaderMerge: numDocs sum, testReserveDocs), :3560-3566 (merged view built when there is no index sort, before merge.reorder(); discarded when reorder returns null), :5557 (the same view on the ordinary merge path), :201-204 (MAX_DOCS javadoc: documents 'added to the index')
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:54-56 (at ea57360912ed) (wrap: a single reader is returned as is), :66-75 (int docStarts += reader.maxDoc()), :95 (new MultiReader over the inputs)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:82-104 (at ea57360912ed) (long maxDoc sum; IllegalArgumentException 'Too many documents: composite IndexReaders cannot exceed' for a non-DirectoryReader above getActualMaxDocs())
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:245-254 (at ea57360912ed) (OneMerge(CodecReader...) totalMaxDoc = sum of numDocs), :311-327 (OneMerge#reorder javadoc 'called when index sorting is disabled on a merged view'; default returns null)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:201-208 (at 645d4fe14a20) (MAX_DOCS), 3272-3275, 3294-3296, 3344, 3356, 2250, 3039-3042, 3245-3251, 3382, 3390, 3504, 3518, 3542-3546, 3548-3566, 3569, 5040-5041, 5056, 5555-5558, 5819-5824; MergePolicy.java:301-309 (wrapForMerge); lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:433-481
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:49-58, (at 645d4fe14a20) 66-75, 95
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-107 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:245-254, (at 645d4fe14a20) 311-328, 615-631
  • a Lucene clone git diff ea57360912ed 645d4fe14a20 and 645d4fe14a20 origin/main (2f424fd428da) on the four files: empty
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:201-203, (at 645d4fe14a20) 3272-3273, 3294-3296, 3344, 3356, 3504, 3518; MergePolicy.java:245-254, 311-328, 615-626
  • lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1585-1604; (at 645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/MultiPassIndexSplitter.java:42-45; PKIndexSplitter.java:120-129
Suggested fix
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-158 (at 645d4fe14a20)
History
  • a Lucene clone git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3601-3602 (the same view)

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

Repro files

About the diagrams

Made separately from this text, from the code at 645d4fe14a20 and runs executed for the diagrams; the PNG is the summary still.

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.