All findings · Lucene finding · LFV-1218 · merged-view-termvectors-clone-null-sub

Cloning the merged view's TermVectorsReader throws NullPointerException when a merged segment has no term vectors

A custom OneMerge#reorder receives a merged view of the segments being merged; cloning that view's term-vectors reader throws NullPointerException as soon as one of those segments has no term vectors. The class handles that missing reader everywhere except in clone(); every release since 9.9.0 has it.

Summary

Priority
Low, rank 679Cloning the term-vectors reader of the merged view handed to OneMerge#reorder throws NullPointerException when a merged segment has no term vectors, and on the merge path that closes the IndexWriter, drops uncommitted changes and fails again after a reopen, in every release since 9.9.0. Only custom reorder or codec code makes that clone and nothing in Lucene does, so it is contrived despite the severe effect.
Impact
minor
Since
9.9.0 (#12622, 2023)introduced 2023-11-23 · about 2 years
Affected
9.9.0 to 10.6.0 and main (7 releases run, all fail); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of SlowCompositeCodecReaderWrapper's TermVectorsReader.clone 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

The merged view keeps one term-vectors sub-reader per segment and stores null for a segment without term vectors. Four of its methods skip that null; clone() calls clone() on every entry and throws.

Background: the merged view a reorder hook sees

  1. A merge policy can renumber the documents of a merge (as BPReorderingMergePolicy does) by overriding OneMerge#reorder. When index sorting is off and the merge has two or more segments, IndexWriter calls it with one merged CodecReader view over all of them (for a single segment it passes the plain reader).
  2. The view reads each segment through that segment's own readers. A segment in which no document stored term vectors has no term-vectors reader at all: null.
Diagram of the bug: a merged view over two one-document segments. Segment 0 (doc 0, field f with term vectors) has a term-vectors reader; segment 1 (doc 1, an empty document) has none, shown as null. The view's get, prefetch, checkIntegrity and close skip the null entry; clone() calls clone() on both entries and throws NullPointerException on the null one.
The merged view over the repro's 2 segments: segment 0 has a term-vectors reader, segment 1 has null. get checks for the null and returns no term vectors for doc 1; clone() calls clone() on every entry and throws on segment 1's. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 2 one-document segments: doc 0 has field f = "a" with term vectors stored; doc 1 is an empty document, so its segment has no term vectors.
  2. Merge them (addIndexes or forceMerge(1)) with a merge policy whose OneMerge#reorder(view, ...) calls view.getTermVectorsReader().clone(), for example to give each worker its own reader.
  3. You expect a clone that returns doc 0's term vectors and null for doc 1, as the uncloned reader does.
  4. Instead clone() throws NullPointerException at SlowCompositeCodecReaderWrapper.java:233, with assertions on and off. On forceMerge the IndexWriter then closes itself, drops uncommitted documents, and fails the same way after a reopen.
  5. Why: the view keeps one sub-reader per segment and stores null for segment 1. get, prefetch, checkIntegrity and close skip the null; clone() calls clone() on every entry.

Who is affected

Only applications with their own merge code: a custom OneMerge#reorder that clones the view's term-vectors reader, or a custom codec that clones its merge inputs during a reordering merge. Nothing in Lucene does either. The data is ordinary (any segment without term vectors); on the merge path the writer closes and uncommitted changes are lost.

Mechanism, terms and numbers357 words

When index sorting is off, IndexWriter passes every merge of two or more segments, including an addIndexes(CodecReader...) merge of two or more readers, to OneMerge#reorder as one merged CodecReader view, SlowCompositeCodecReaderWrapper (IndexWriter.java:3561-3562, :5556-5557); not when the segments have blocks but the index has no parent field (:3551-3556, :5547-5552). The view's getTermVectorsReader() keeps one sub-reader per segment, and the sub-reader is null for a segment without term vectors (SlowCompositeCodecReaderWrapper.java:206-213, SegmentReader.java:246-253). get, prefetch, checkIntegrity and close handle that null; clone() maps TermVectorsReader::clone over the array and throws NullPointerException at :233.

Nothing in Lucene clones this reader (merges use getMergeInstance(), MergeState.java:150-152), so only application code reaches it: a custom OneMerge#reorder, or a custom codec that clones MergeState.termVectorsReaders during a reordering merge. On the merge path IndexWriter treats the exception as a tragic event (IndexWriter.java:5111). The fix is a one-line null guard in clone().

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/index/)

  1. The view. SlowCompositeCodecReaderWrapper.wrap(readers) returns the single reader itself for one input and a new wrapper for two or more (SlowCompositeCodecReaderWrapper.java:48-57); the class is "primarily targeted at merging, not searching" (:43-45).
  2. A null sub-reader on purpose. getTermVectorsReader() maps CodecReader::getTermVectorsReader over the inputs (:206-213); SegmentReader.getTermVectorsReader() returns null when the segment has no term vectors, else a fresh clone (SegmentReader.java:246-253).
  3. Four methods handle it. close uses IOUtils.close, which skips null (:225-228); checkIntegrity (:237-244), prefetch (:246-253) and get (:255-264) test the entry first.
  4. clone() does not (the fix site). Arrays.stream(readers).map(TermVectorsReader::clone) (:230-235) dereferences the null entry at :233. SortingCodecReader's term-vectors clone() delegates (SortingCodecReader.java:831-834), so a sorting view over the merged view fails too.

Also executed: the reversed order (no term vectors first) and SortingCodecReader.wrap(view, ...) fail the same way; two segments that both have term vectors clone fine; a merge of segments of which none has term vectors also fails, because the view then returns a non-null reader whose entries are all null, so a caller's own null check does not help.

Exposure and likelihood272 words
  • Who. A OneMerge#reorder override (@lucene.experimental, MergePolicy.java:311-328) that clones view.getTermVectorsReader(), for example one reader per task of the executor the hook receives "to parallelize the reordering logic" (:321-322). Or a custom codec whose TermVectorsWriter.merge clones the public MergeState.termVectorsReaders entries during a merge that reorders (a reorder that returns a non-null DocMap, so the merge reads through SortingCodecReader.wrap(view, ...), IndexWriter.java:5574-5575); this trigger needs no @lucene.internal getter and was executed on main.
  • Default vs opt-in. Opt-in: custom code is required. The data shape is common: any merge of two or more segments where at least one has no term vectors, including an index with no term vectors at all.
  • Effect. A NullPointerException. In addIndexes(CodecReader...) it reaches the caller and the writer stays open: with the repro's SerialMergeScheduler it propagates straight out of mergeScheduler.merge (IndexWriter.java:3312); with a concurrent scheduler it is recorded and rethrown at :3380-3382; only an Error is tragic there (:3390-3392). In a merge, IndexWriter.merge sends any non-abort Throwable to tragicEvent(t, "merge") (:5064-5112, :5111). An impact probe on forceMerge(1) showed: the writer is closed, its tragic exception is the NPE, an uncommitted third document is gone after reopening (2 docs left), and a reopened writer fails and closes the same way. That close is IndexWriter's generic handling of an exception that escapes the application's own reorder or codec code; stock merges without such code do not fail. Committed data stays intact; no wrong results.
  • Scale. Two one-document segments.
  • Workaround. Call getTermVectorsReader() (or termVectors()) again instead of clone(): each call builds fresh per-segment readers.

Minimal repro

One JUnit test method: two one-document segments (one with term vectors, one without), addIndexes through a merge policy whose reorder clones the view's term-vectors reader. Single-threaded; it fails with assertions on and off and with every seed tried.

TestMergedViewTermVectorsClone.java (one JUnit test)73 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.io.IOException;
import java.util.concurrent.Executor;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMergedViewTermVectorsClone extends LuceneTestCase {

  public void testCloneWhenOneSegmentHasNoTermVectors() throws Exception {
    FieldType tvType = new FieldType(TextField.TYPE_NOT_STORED);
    tvType.setStoreTermVectors(true);
    MergePolicy mp =
        new FilterMergePolicy(NoMergePolicy.INSTANCE) {
          @Override
          public MergeSpecification findMerges(CodecReader... readers) {
            MergeSpecification spec = new MergeSpecification();
            spec.add(
                new OneMerge(readers) {
                  @Override
                  public Sorter.DocMap reorder(CodecReader view, Directory dir, Executor executor)
                      throws IOException {
                    // expected: a clone that reads doc 0's term vectors; actual:
                    // NullPointerException
                    assertNotNull(view.getTermVectorsReader().clone().get(0));
                    return null;
                  }
                });
            return spec;
          }
        };
    try (Directory src = newDirectory();
        Directory dest = newDirectory()) {
      try (IndexWriter w = new IndexWriter(src, new IndexWriterConfig())) {
        Document withTv = new Document();
        withTv.add(new Field("f", "a", tvType));
        w.addDocument(withTv);
        w.commit();
        w.addDocument(new Document());
      }
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w =
              new IndexWriter(
                  dest,
                  new IndexWriterConfig()
                      .setMergePolicy(mp)
                      .setMergeScheduler(new SerialMergeScheduler()))) {
        w.addIndexes(
            (CodecReader) r.leaves().get(0).reader(), (CodecReader) r.leaves().get(1).reader());
      }
    }
  }
}
Command
cp $REPRO/TestMergedViewTermVectorsClone.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestMergedViewTermVectorsClone -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
clone() returns a reader whose get(0) returns doc 0's term vectors.
Observed
NullPointerException at SlowCompositeCodecReaderWrapper$SlowCompositeTermVectorsReaderWrapper.clone(SlowCompositeCodecReaderWrapper.java:233), thrown out of addIndexes.

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

More files, environment, full output175 words

TestMergedViewTermVectorsClone (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests). new IndexWriterConfig() with SerialMergeScheduler, so the merge runs on the calling thread and the exception comes straight out of addIndexes. About 1 s. It runs in LuceneTestCase's randomized environment (codec and directory vary with the seed); the failure does not depend on it: every seed tried failed (DEADBEEF, and 1234ABCD and 77 in the final repro check's runs). The getter it calls, CodecReader#getTermVectorsReader(), is public but @lucene.internal; the codec trigger does not need it. It is the hunt's minimal test after the code formatter, which only re-wrapped two lines; it passes checkGoogleJavaFormat and can serve as the regression test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.9.0 to 10.6.0 (7 releases run): the same scenario fails; 9.8.0: API absent
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)

Observed output

TestMergedViewTermVectorsClone > testCloneWhenOneSegmentHasNoTermVectors FAILED
    java.lang.NullPointerException
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4A8CFE97FA2AAD8A]:0)
        at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:214)
        ...
        at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:652)
        at org.apache.lucene.index.SlowCompositeCodecReaderWrapper$SlowCompositeTermVectorsReaderWrapper.clone(SlowCompositeCodecReaderWrapper.java:233)
        at org.apache.lucene.index.TestMergedViewTermVectorsClone$1$1.reorder(TestMergedViewTermVectorsClone.java:45)
        at org.apache.lucene.index.IndexWriter.addIndexesReaderMerge(IndexWriter.java:3562)
        at org.apache.lucene.index.IndexWriter$AddIndexesMergeSource.merge(IndexWriter.java:3463)
        at org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:38)
        at org.apache.lucene.index.IndexWriter.addIndexes(IndexWriter.java:3312)
        at org.apache.lucene.index.TestMergedViewTermVectorsClone.testCloneWhenOneSegmentHasNoTermVectors(TestMergedViewTermVectorsClone.java:68)

Why it looks unintended

  • TermVectorsReader.clone() promises "a clone that one caller at a time may use to read term vectors", with no precondition.
  • The class stores the null itself and skips it in get, prefetch, checkIntegrity and close; clone(), from the same commit, is the outlier.
  • For unsupported operations this class throws UnsupportedOperationException; upstream fixed the same pattern for points in this wrapper (#12859).
The full argument185 words
  • Contract. TermVectorsReader.clone(): "Create a clone that one caller at a time may use to read term vectors" (TermVectorsReader.java:45-47); no precondition, no exception documented.
  • The class contradicts itself. The null entry is a state the view creates (SlowCompositeCodecReaderWrapper.java:206-213) and handles in four methods (:225-264). Unsupported operations in this class throw UnsupportedOperationException (:366, :371, :491).
  • History. getTermVectorsReader(), clone(), the null-checked get and checkIntegrity and the null-skipping close all arrived in f7cab164501 (#12622, 2023-11-23); prefetch came later with its own guard (#13758, 10.0.0). Nothing has touched clone() since. No javadoc, CHANGES or MIGRATE entry says clone() is unsupported on this view.
  • Tests and precedent. Upstream TestSortingCodecReader clones the term-vectors reader of a wrapper view (SortingCodecReader over SlowCodecReaderWrapper) and asserts on the clone (TestSortingCodecReader.java:229-237), so cloning a merge-oriented wrapper's term-vectors reader is exercised; that view wraps a single segment with no null sub-reader, so the test does not cover this case. Maintainers fixed the same pattern elsewhere: #12858/#12859 (null PointValues in this wrapper, 9.9.0) and #14123/#14125 (null vectors reader in SortingCodecReader).
The devil's-advocate check122 words

The strongest case for "not a bug", made by the cross-model reviewer (OpenAI GPT-5.6), which reproduced the NPE and agreed on the root cause: the trigger calls CodecReader#getTermVectorsReader(), which is @lucene.internal (CodecReader.java:44-50), inside an @lucene.experimental hook, and normal merges call getMergeInstance(), not clone().

Why it does not settle it: the codec trigger uses no internal getter, only the codec API and the public MergeState.termVectorsReaders; @lucene.internal and @lucene.experimental speak to API stability, not to a documented method throwing NPE on a state the class creates itself; and a caller that checks for null first still gets the NPE. A maintainer may still call it out of scope or too minor, a fair outcome for a Low bug.

Suggested fix

Recommended Null guard in clone() (fix A)

Map a null sub-reader to null in clone(): .map(r -> r == null ? null : r.clone()). With it the repro passes with assertions on and off, and 61 tests of the closest suites pass.

          Arrays.stream(readers)
              .map(r -> r == null ? null : r.clone())
              .toArray(TermVectorsReader[]::new),
All options and trade-offs2 options ยท 285 words

Fix A was built and checked through gradle on 5adff78bb168; B was not run. The same missing-sub-reader pattern in this wrapper's vector copy() is registered as LFV-269 (a different method, trigger and version range); the two are related hardening in the same class and could be considered together.

Null guard in clone() (fix A) (validated by the checks) (recommended)

In SlowCompositeTermVectorsReaderWrapper.clone() (SlowCompositeCodecReaderWrapper.java:230-235), clone only the non-null sub-readers and keep null for the others, as get, prefetch, checkIntegrity and close already assume.

          Arrays.stream(readers)
              .map(r -> r == null ? null : r.clone())
              .toArray(TermVectorsReader[]::new),

Trade-offs

None found: the clone then returns null for documents of a segment without term vectors, exactly like the original reader. The change is confined to clone().

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off: 1 test, 1 failure) and passes with it (assertions off).
  • With the fix and assertions on: the repro (1), TestSortingCodecReader (3), TestGetVectorCountCodecWrappers (1), TestAddIndexes (39), TestIndexWriterMerging (13) and TestTermVectors (4): 61 tests, 0 failures. checkGoogleJavaFormat passes. The full core suite was not run; only OpenJDK 25.0.3 on Linux aarch64.
  • Earlier: the hunt's shadow-class runs of the same change pass the minimal repro with -ea and -da; the independent reproducer's own null-tolerant clone() passes its forceMerge(1) test.

Fill missing sub-readers with an empty reader (not run)

When the view is built, put an empty TermVectorsReader (whose get returns null) where a segment has none.

Trade-offs

Removes the null checks everywhere, but is a larger change to a class that already handles null consistently in four methods. Not recommended.

What the checks validated

Not run.

Downstream note

Downstream products are affected only if their own merge-policy or codec code clones this reader; documents and queries alone never reach it.

How it was found

A proof-obligation audit of SlowCompositeCodecReaderWrapper's term-vectors reader against the TermVectorsReader.clone contract: every method must cope with the null sub-reader the view stores for a segment without term vectors. clone() does not; a public-API JUnit test then witnessed the exception.

The method in full98 words

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

  • The finder listed the states the view can be in (a null entry for each segment without term vectors) and checked each method of the term-vectors wrapper against them; only clone() fails the obligation.
  • The finder then wrote a public-API JUnit test and the one-line null guard that makes it pass.
  • An independent reproducer, an executed cross-model review and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran the reproducer's test with a fresh seed ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No matching prior report found in apache/lucene issues, PRs and commits, the mailing lists, downstream trackers or the web (searched 2026-11-03). Nearest: #12858/#12859 (points in this wrapper, fixed) and #14123/#14125 (vectors in SortingCodecReader).

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #12622 (the introducing PR; its 28 review comments never discuss clone() or a missing term-vectors sub-reader), #12858/#12859 (null PointValues in this wrapper, fixed in 9.9.0), #14123/#14125 (null vectors reader in SortingCodecReader), #14092 (this wrapper's vector copy). No open PR has a hunk on the term-vectors clone(). Limits: keyword search; GitHub search does not index every review comment (those of #12622 were read through the API); 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 (45 queries each, different phrasings) and 27 manual searches (issues and PRs in all states, three downstream repos); the 28 review comments of #12622no report; #12858/#12859 and #14123/#14125 are the nearest
Open PRs2026-11-03the changed files of all 410 open apache/lucene PRs; the patches of 8 of the 12 that touch a related file, including all 3 that touch SlowCompositeCodecReaderWrapper; PRs updated since 2026-11-01none changes the term-vectors clone()
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@ (the scripted searches' queries plus 5 searches over all lucene.apache.org lists), Elasticsearch, OpenSearch and Solr trackers, 3 web searchesnothing about this bug

History

Introduced with the class itself in #12622 (f7cab164501, 2023-11-23), first released in 9.9.0; 9.8.0 has no such API. Every executed release since fails, through 10.6.0 and main. Not fixed upstream.

Versions tested5 rows

Introduced by f7cab1645017 on main (squash-merge of #12622 "Add a merge policy wrapper that performs recursive graph bisection on merge.", Adrien Grand, 2023-11-23; branch_9x copy 6b1538b33136). The same change created SlowCompositeCodecReaderWrapper and added OneMerge#reorder, so it is not a regression: the trigger API arrived with the defect.

The version runs used the hunt's earlier one-test variant of the same scenario; 9.9.0 and 9.11.1 a copy with the two-argument reorder(CodecReader, Directory) those releases have; 10.5.2 the independent reproducer's forceMerge(1) test. By reading, every release tag from 9.9.0 to 10.6.0 has the unguarded line, and so do upstream main 1c2072f0284 (2026-11-02), branch_11x, branch_10_6 and branch_9_12.

Versions and branches executed, with results
Version or commitVerdictResult
9.8.0 API absentdoes not compile: no merged view, no OneMerge#reorder
9.9.0 JUnit, -ea and -dafails, same NPE
9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 JUnitfails, same NPE
10.6.0 JUnit, -ea and -dafails, same NPE
main 5adff78bb168 JUnit, -ea and -dafails, same NPE

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6: reproduced it, but judged it out of contract), a devil's-advocate verifier, an adjudication, an impact probe, an audit and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts251 words

The cross-model review ran on an older main, ea57360912ed; its test was re-run on 5adff78bb168 (mixed case fails, control passes). Every other main run used 5adff78bb168.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test fails on main (assertions on and off; 2 more seeds with assertions on), 10.6.0 and 9.12.3; the null guard makes it pass.
independent reproducer realOwn forceMerge(1) test fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; passes with its own null-tolerant clone().
cross-model review, refute lens (OpenAI GPT-5.6, executed) not real (high), on reach onlyReproduced the NPE with its own test (assertions on and off), control passes, agrees on the root cause; argues the trigger is outside the supported API (see the devil's advocate).
devil's-advocate verifier realNo intended or documented reading survives; found the codec trigger and the no-term-vectors-at-all case.
adjudication confirmed (medium), LowRe-ran the reproducer's test with a fresh seed, assertions on and off: fails.
separate re-run, impact probe, audit and final repro check fail / tragic close / crash / minorFresh seeds fail on main; the probe shows the tragic close on forceMerge; the audit downgraded the impact from index unusable to crash; the final repro check kept the one-test repro and set the impact to minor.
this doc's gradle fix check reproduced; fix A validatedFails without the fix (on, off); passes with it; 61 tests of 5 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,390 words

Finding record

LFV-1218, snapshot of 2026-11-06

Registry title
The term-vectors reader of the merged view that IndexWriter hands to OneMerge#reorder (SlowCompositeCodecReaderWrapper) throws NullPointerException from clone() as soon as one merged segment has no term vectors, although the same class handles that null sub-reader in get, prefetch, checkIntegrity and close; on the merge path this closes the IndexWriter (since 9.9.0)
Area
core / index: SlowCompositeCodecReaderWrapper (merged CodecReader view handed to OneMerge#reorder), term vectors
Priority
Low, rank 679: Cloning the term-vectors reader of the merged view handed to OneMerge#reorder throws NullPointerException when a merged segment has no term vectors, and on the merge path that closes the IndexWriter, drops uncommitted changes and fails again after a reopen, in every release since 9.9.0. Only custom reorder or codec code makes that clone and nothing in Lucene does, so it is contrived despite the severe effect.
Impact class
minor
Impact note
Hits only applications with their own merge code that clones the merged view's term-vectors reader (a custom @lucene.experimental OneMerge#reorder calling view.getTermVectorsReader().clone(), the getter being @lucene.internal, or a custom codec whose TermVectorsWriter.merge clones MergeState.termVectorsReaders during a reordering merge; nothing in Lucene does either): then every merge or addIndexes(CodecReader...) of two or more segments of which at least one has no term vectors (an index with no term vectors at all included) throws NullPointerException with -ea and -da, no scale needed; on the forceMerge/background-merge path IndexWriter treats it as a tragic event, closes itself and drops uncommitted changes, and the same merge fails again after a reopen, while addIndexes just throws; no wrong result and no corruption of committed data; in every release from 9.9.0 to 10.6.0 and on main.
Summary
You write a merge policy whose OneMerge#reorder clones the term-vectors reader of the merged view it receives, and merge (or addIndexes) a segment with term vectors together with one in which no document has a term-vector field, expecting a reader that returns the first segment's term vectors, as the uncloned reader does. Instead clone() throws NullPointerException at SlowCompositeCodecReaderWrapper.java:233: the view stores a null sub-reader for the segment without term vectors (:206-213), and clone() maps TermVectorsReader::clone over that array (:230-235) without the null check that get, prefetch and checkIntegrity have and that close gets from IOUtils.close. On the merge path the exception closes the IndexWriter as a tragic event, and it fails the same way after a reopen.
Introduced
f7cab1645017d863331b42900581b67d3591e2da (main; squash-merge of apache/lucene#12622 'Add a merge policy wrapper that performs recursive graph bisection on merge.' by Adrien Grand (jpountz), parent 76fe6bdbc16); the same change is on branch_9x as 6b1538b33136a4737ad0d22b3633e62f3d7f362b (same author and author date), which 9.9.0 contains. That one change creates SlowCompositeCodecReaderWrapper with the unguarded clone() next to the null-checked get() and checkIntegrity() and the null-skipping close(), and adds OneMerge#reorder [read: git log -S 'TermVectorsReader::clone' over lucene/core/src/java in the full history finds only these two commits; git blame -w -M -C attributes getTermVectorsReader (:206-213) and clone() (:230-235) to f7cab164501; exec: 9.8.0 has neither the class nor OneMerge#reorder (compile error), 9.9.0 fails]. Not a regression: the trigger API arrived with the defect. Later commits on the file (24 on main, e.g. #13758 adding a null-guarded prefetch, #13289 adding the Executor parameter to reorder) never touched clone(). (2023-11-23 (author date = commit date 2023-11-23T13:25:00+01:00 on main; the branch_9x commit 6b1538b3313 was committed 23 s later))
First affected version
9.9.0 [exec] (tag 2023-12-04; fails with -ea and -da, run with a copy of the test that differs only in the two-argument reorder(CodecReader, Directory) signature that 9.9.0-9.11.x have); 9.8.0 has no SlowCompositeCodecReaderWrapper and no OneMerge#reorder [exec: compile error]
Affected versions
9.9.0 through 10.6.0 (4ae946e5a9b) and unreleased main 5adff78bb168 (12.0.0-SNAPSHOT): 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.6.0 [read: git tag --contains f7cab164501 and 6b1538b3313 over releases/lucene/*; each tag has the unguarded '.map(TermVectorsReader::clone)' line]. [exec] fail: main 5adff78bb168 with -ea and -da (finder, the independent reproduction, the GPT reviewer's test re-run on 5adff78, the intended-behaviour check, adjudication, re-run), 10.6.0 (-ea and -da), 10.5.2, 10.0.0, 9.12.3, 9.12.0, 9.11.1 and 9.9.0 (-ea and -da; 9.9.0 and 9.11.1 with the two-argument reorder copy). Not run: 9.9.1, 9.9.2, 9.10.0, 9.11.0, 9.12.1, 9.12.2 and 10.1.0-10.5.1 (identical line by reading each tag). Not fixed upstream: the line is unchanged on upstream main 1c2072f0284 (2026-11-02), branch_11x, branch_10_6 and branch_9_12. Not new in 10.6 (9.9.0 and 10.5.2 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 disputed it (the adjudication ruled it real), 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 intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches with different phrasings (45 queries each over apache/lucene issues and PRs, commits, Elasticsearch/OpenSearch/Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), 27 manual GitHub searches (issues and PRs in all states, incl. three downstream repos), 5 searches over all lucene.apache.org lists, 3 web searches, the 28 review comments of #12622 read through the API, a file-list scan of all 410 open apache/lucene PRs (12 touch a related file; the patches of #16879, #16847, #16571, #13342, #15430, #16864, #13948 and #12636 have no term-vectors clone hunk) and the PRs updated since 2026-11-01: no issue, PR, commit, list thread or JIRA entry reports or fixes the clone() NullPointerException. Nearest items, not this bug: #12858/#12859 (null PointValues in this wrapper's getValues, fixed in 9.9.0), #14123/#14125 (null vectors reader in SortingCodecReader, fixed there only, 2025), #14092 (this wrapper's vector copy, the LFV-269 area), and the introducing PR #12622 (s1monw's 'filter non null' review remark is a style comment on another loop, not a report). Unfixed on upstream main 1c2072f0284, branch_11x, branch_10_6 and branch_9_12; the newest commit on the file is 874ba4d39af (#16647, author date 2026-09-08).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree, seed DEADBEEF: the repro without the fix (assertions on and off: fails) and with fix A (assertions off: passes; assertions on, with 5 closest suites: 61 tests, 0 failures), plus checkGoogleJavaFormat (passes). The hunt ran the same scenario on 9.9.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (fail) and 9.8.0 (API absent). Upstream main 1c2072f0284 was read on 2026-11-03. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:43-45 (at 5adff78bb168) (class javadoc: 'primarily targeted at merging, not searching'), :206-213 (getTermVectorsReader maps CodecReader::getTermVectorsReader over the inputs, null for an input without term vectors), :225-228 (close via IOUtils.close, which skips nulls), :230-235 (clone maps TermVectorsReader::clone with no null check; NPE at :233; fix site), :237-244, :246-253, :255-264 (checkIntegrity, prefetch and get test for the null sub)
  • lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:246-253 (at 5adff78bb168) (getTermVectorsReader returns null when the segment has no term vectors, else a fresh clone)
  • lucene/core/src/java/org/apache/lucene/codecs/TermVectorsReader.java:45-47 (at 5adff78bb168) (clone(): 'Create a clone that one caller at a time may use to read term vectors', no precondition)
  • lucene/core/src/java/org/apache/lucene/index/CodecReader.java:44-50 (at 5adff78bb168) (getTermVectorsReader: 'Expert: retrieve underlying TermVectorsReader', @lucene.internal)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:311-328 (at 5adff78bb168) (OneMerge#reorder receives a merged view and an executor 'to parallelize the reordering logic', @lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5064-5112 (at 5adff78bb168) (merge: any Throwable from mergeMiddle reaches tragicEvent(t, "merge") at :5111)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3376-3393 (at 5adff78bb168) (addIndexes(CodecReader...) rethrows a merge exception; only an Error is a tragic event)
  • lucene/core/src/java/org/apache/lucene/index/MergeState.java:150-152 (at 5adff78bb168) (merges take getTermVectorsReader() then getMergeInstance(), not clone())
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:831-834 (at 5adff78bb168) (the sorting view's term-vectors clone delegates to the wrapped reader)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:43-57, (at 5adff78bb168) :206-264
  • lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:246-253 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:831-834 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:80-86 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3561-3562, (at 5adff78bb168) :5556-5557
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133 (at 5adff78bb168) (overrides reorder)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:311-328 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/MergeState.java:150-152 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3312, (at 5adff78bb168) :3376-3393, :3472-3473, :5055, :5064-5112, :5574-5575
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/TermVectorsReader.java:45-47 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/CodecReader.java:44-50 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:206-264, (at 5adff78bb168) :366, :371, :491
  • lucene/core/src/test/org/apache/lucene/index/TestSortingCodecReader.java:229-237 (at 5adff78bb168)
  • https://github.com/apache/lucene/pull/12622, https://github.com/apache/lucene/pull/12859, https://github.com/apache/lucene/pull/14125
Prior reports
  • https://github.com/apache/lucene/issues/12858, https://github.com/apache/lucene/issues/14123, https://github.com/apache/lucene/pull/14092
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/SlowCompositeCodecReaderWrapper.java:206-264 (at 5adff78bb168)

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's index and the code at 5adff78bb168 (SlowCompositeCodecReaderWrapper.java:206-264).

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.