All findings · Lucene finding · LFV-244 · tv-zero-term-field-corrupts-chunk-and-poisons-merges

addIndexes(CodecReader) with a term-vector field that has zero terms writes a chunk the default codec cannot read back, and every later merge that re-encodes the segment closes the IndexWriter

If addIndexes(CodecReader) copies a document whose term-vector field has no terms left, the default codec writes a chunk it cannot read back, and on current releases every later merge that re-encodes that segment closes the IndexWriter. Confirmed on 11 versions from 4.2.0 to main; CheckIndex at its default level reports clean.

Summary

Priority
Low, rank 319Needs an expert, application-written term-removing reader fed to addIndexes(CodecReader); normal indexing never produces the zero-term term-vector field.
Impact
data corruption
Since
4.2.0 (2013, LUCENE-4599)introduced 2013-01-21 · over 13 years
Affected
4.2.0 to 10.6.0 and main (11 versions run; 10.6.0 by reading)
Status
documented
Found by
an SMT (Z3) model of the term-vector chunk layout, then a failing test

AI-assisted page: this finding was first signalled by a formal tool's own output 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 copier behind addIndexes(CodecReader) hands the codec a field with zero terms. The default term-vector writer then stores per-field floats that its reader does not expect when no term of the chunk has offsets, and the reader lists the field but returns no terms for it, so the next merge counts one field too many.

Summary frame. Example 1: doc 0 has a = "secret" with term vectors and offsets, doc 1 has body = "here public text"; a filter drops "secret" from the postings and term vectors; addIndexes(SlowCodecReaderWrapper.wrap(filtered)), commit, read doc 1's term vectors. Expected: body = [here, public, text]. Actual: IOException: offset 0 is invalid, while CheckIndex at its default level reports clean. Example 2, the same filter with a without offsets, plus doc 2: deleteDocuments(id:2) plus commit merges the segment; doc 0 declares 1 field and writes 0; Wrong number of values added, expected: 2, got: 1, and the IndexWriter closes on every retry. A six-box flow: field a listed with 0 terms; addAllDocVectors calls startField(a, 0); the writer sees the OFFSETS flag and writes 2 floats, 8 bytes; the reader sees 0 offsets and does not read them; LZ4 from byte 21 fails with offset 0 is invalid; terms("a") is null, so merges fail. Affected 4.2.0 to 10.5.2 and main (executed on 11 versions); a fix sketch in addAllDocVectors passes.
The repro step by step: the filter leaves a listed with 0 terms, the copier forwards it, the writer adds 8 bytes of floats that the reader does not skip, the read of doc 1 fails, and the next merge counts 2 fields but writes 1. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Copy a 2-document index with addIndexes(SlowCodecReaderWrapper.wrap(r)), where r removes the term "secret" from field a in both the postings and the term vectors. Doc 0's a held only "secret" and stores offsets, so its term vectors now list a with 0 terms.
  2. You expect doc 1's term vectors to read back as body=[here, public, text]. Instead the read throws IOException: offset 0 is invalid: the writer stored 8 bytes (one float per field of the chunk) that the reader does not expect, so it decodes the term bytes from the wrong place. A chunk holds up to 128 documents; its other documents with term vectors fail the same way.
  3. Copy again with a not storing offsets, plus a doc 2. Delete doc 2 and commit(). You expect the merge to succeed. Instead it throws Wrong number of values added, expected: 2, got: 1 and the IndexWriter closes; every fresh writer whose merge re-encodes the segment fails the same way.
  4. Why the merge fails: the copied segment lists a for doc 0 but returns no terms for it, and the merge copier counts the field before it skips it. Normal indexing never writes a zero-term field, so ordinary indexes are not affected.
  5. No warning: addIndexes and commit succeed, and CheckIndex at its default level reports the index clean (here levels 2 and 3 report the segment broken; without offsets only level 3 does).

Who is affected

Applications that copy or rewrite an index through addIndexes(CodecReader) with their own reader that removes terms from term-vector fields: default codec and config, any size, 4.2.0 to 10.6.0 (10.6.0 by reading) and main. One document whose term-vector field holds only removed terms is enough. Normal indexing never produces it.

Mechanism, terms and numbers805 words

IndexWriter.addIndexes(CodecReader...) copies the term vectors of a reader that is not a same-format segment through the codec-agnostic copier TermVectorsWriter.addAllDocVectors. If the reader removes terms (a FilterLeafReader that drops a term from the postings and, consistently, from the term vectors), a document whose term-vector field held only removed terms reaches the codec as a field with numTerms = 0. The default Lucene90 term-vector writer then writes one 4-byte AvgCharsPerTerm float per field of the chunk because the field has the OFFSETS flag, while its reader reads those floats only when the chunk has at least one offset, so the chunk's other documents with term vectors (a chunk holds up to 128 documents) cannot be read. The same reader lists the zero-term field but returns null from terms(), so the next merge that re-encodes the segment (a deletion rules out the bulk copy once the merge policy picks the segment) declares a field it never writes and, on 9.0.0 and later, closes the IndexWriter, again for every new writer. CheckIndex at its default level reports the index clean. Normal indexing never produces a zero-term term-vector field; the trigger is an application-written term-removing reader. Confirmed on 11 versions from 4.2.0 (2013) to 10.5.2 and main. Before 9.0.0 (merges) and 10.4.0 (chunk reads), runs without assertions could fail silently: a merge wrote one document's term vectors under another, and chunk reads returned wrong bytes or threw ArrayIndexOutOfBoundsException.

Terms

  • Term vectors: an optional per-document copy of a field's terms (with positions, offsets, payloads), stored by the default codec in compressed chunks of up to 128 documents, flushed once their term bytes reach 4 KB (Lucene90TermVectorsFormat.java:68, :170).
  • The copier: TermVectorsWriter.addAllDocVectors, the codec-agnostic path that writes one document's term vectors from any Fields. addIndexes(CodecReader...) through SlowCodecReaderWrapper uses it, and so does any merge of a segment with deletions (Lucene90CompressingTermVectorsWriter.java:915-931, bulk-merge test :953-967).
  • A zero-term field: a document's term vectors list field a, but its Terms has no terms. A term-removing FilterLeafReader produces it when a document's field held only removed terms. The indexing chain never does: TermVectorsConsumerPerField.finish() skips fields with no terms (TermVectorsConsumerPerField.java:69-71).

The bug

  1. The copier declares before it looks. It calls startDocument(vectors.size()) (TermVectorsWriter.java:242-251; the javadoc at :64-69 promises startField will then be called that many times), forwards an empty Terms as startField(numTerms = 0) (:278-289), and skips a null Terms with continue ("FieldsEnum shouldn't lie...", :267-271).
  2. Writer and reader disagree about one chunk section. The writer's flushOffsets sets hasOffsets |= fd.hasOffsets for every field instance (Lucene90CompressingTermVectorsWriter.java:629) and, if set, writes one AvgCharsPerTerm float per distinct field (:643-657). The reader computes totalOffsets from the term frequencies of OFFSETS fields, to which a zero-term field adds nothing (Lucene90CompressingTermVectorsReader.java:551-561), and reads the floats only if (totalOffsets > 0) (:587-591).
  3. So the chunk is shifted. With a zero-term OFFSETS field and no other offset in the chunk, the reader starts decoding the LZ4-compressed term bytes 4 bytes per field too early. It sees a zero match offset and throws IOException("offset 0 is invalid") (LZ4.java:113-116, since #15570 in 10.4.0). The zero-term document itself reads back; the other documents of the chunk that have term vectors do not.
  4. And the next merge miscounts. The same reader lists the zero-term field in iterator() and size() (Lucene90CompressingTermVectorsReader.java:879-902, :948-950) but returns null from terms() ("no term", :918-921). A merge that re-encodes the segment gives that Fields to the copier, which declares the field and skips it. The writer sizes its packed field table for the declared total (Lucene90CompressingTermVectorsWriter.java:436-450, :483-498), and DirectWriter.finish() throws Wrong number of values added (:495); with no other field in the chunk, flushFieldNums fails first (:465-466). IndexWriter treats it as tragic.

The numbers

The independent reproduction's documents, default codec, main a9779ef3c193: doc 0 a = "secret" (term vectors with offsets), doc 1 body = "here public text" (no offsets). After the filtered addIndexes, the chunk's sections end at byte 21; the writer put 2 floats (fields a and body) as 8 zero bytes at [21, 29) and the compressed terms at 29; the reader starts decoding at 21. Decoding from 29 gives "herepublictext"; from 21 it throws. Without offsets and with a doc 2 that is then deleted, the commit's merge sees 2 declared fields (doc 0 a, doc 1 body) and 1 written: expected: 2, got: 1. CheckIndex: level 1 (default) clean; level 2 test: term vectors ERROR [offset 0 is invalid]; level 3 a NullPointerException on the null Terms; both report the whole segment as broken. Without offsets (the intended-behaviour check's probe), level 2 is clean and only level 3 fails.

Exposure and likelihood456 words
  • Who is exposed. Applications that copy, prune or rewrite an index through IndexWriter.addIndexes(CodecReader...) with their own reader that removes terms from fields that store term vectors (for example stripping a blocked vocabulary while copying an index without re-analysing the source). By reading, the same copier serves merges whose readers the application wraps (MergePolicy.OneMerge#wrapForMerge) and custom codec merges (not executed). Default codec and IndexWriterConfig, any index size.
  • The pattern is used. Lucene's own TestFilterLeafReader passes a term-filtering FilterLeafReader (only terms containing 'e') to addIndexes(SlowCodecReaderWrapper.wrap(reader)); it filters postings only. A 2016 java-user thread passes a filtering reader to addIndexes to archive part of an index. No Lucene-shipped reader produces the zero-term shape from an ordinarily written segment, and no downstream engine checked does.
  • What it takes. Once such a copy runs, ordinary content triggers it. The merge failure needs only the emptied field and a later merge that re-encodes the segment; a deletion rules out the bulk copy once the merge policy picks the segment; in the test, one deletion of three and a commit() with the default merge policy were enough. The unreadable chunk also needs the emptied field to store offsets and no other offset in its chunk.
  • What breaks. Up to 127 other documents of the chunk (those with term vectors) have unreadable term vectors (term-vector highlighting, term-vector "more like this" and term-vector APIs fail for them). On 9.0.0 and later every merge that re-encodes the segment closes the IndexWriter, and a fresh writer fails again, so indexing stops and the segment's partial deletions cannot be reclaimed by such a merge (a segment whose documents are all deleted is still dropped without one). A merge without deletions copies the bad chunk unchanged into the merged segment.
  • Silent until then. addIndexes and commit succeed; CheckIndex at its default level reports clean. With offsets, levels 2 and 3 report the whole segment broken; without offsets, level 2 is clean and level 3 fails. When it reports the segment broken, -exorcise would remove all its documents.
  • Older releases, without assertions. Before 9.0.0 the merge "succeeds" and writes one document's term vectors under another document; before 10.4.0 some chunk reads return wrong bytes and others throw ArrayIndexOutOfBoundsException (on 9.12.3 the security probe counted 30,572 exceptions and 10,388 silent wrong reads). With assertions on, these releases throw AssertionError.
  • What stays intact. The bug adds no damage to postings, stored fields or doc values beyond what the filter itself does (it removes the term from the postings on purpose; the postings writer drops the emptied field). One side effect: when the merge fails inside commit(), that commit's deletion is not committed.
  • How likely. Rare: an expert pattern, and the searches found no report since 2013.

Minimal repro

The test shows that addIndexes(CodecReader) from a reader whose term vectors list a field with zero terms (but offsets) writes a term-vector chunk that the default codec cannot read back, which damages another document of the chunk.

TestTermVectorsZeroTermField.java (JUnit test)121 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 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.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestTermVectorsZeroTermField extends LuceneTestCase {

  public void testOtherDocumentOfChunkReadsBack() throws Exception {
    FieldType offsets = new FieldType(TextField.TYPE_NOT_STORED);
    offsets.setStoreTermVectors(true);
    offsets.setStoreTermVectorOffsets(true);
    FieldType plain = new FieldType(TextField.TYPE_NOT_STORED);
    plain.setStoreTermVectors(true);
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(src, new IndexWriterConfig())) {
        Document d0 = new Document();
        d0.add(new Field("a", "x", offsets));
        w.addDocument(d0);
        Document d1 = new Document();
        d1.add(new Field("b", "y", plain));
        w.addDocument(d1);
      }
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w =
              new IndexWriter(dst, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        LeafReader emptyVectorA =
            new FilterLeafReader(getOnlyLeafReader(r)) {
              @Override
              public TermVectors termVectors() throws IOException {
                TermVectors tv = in.termVectors();
                return new TermVectors() {
                  @Override
                  public Fields get(int doc) throws IOException {
                    return new FilterFields(tv.get(doc)) {
                      @Override
                      public Terms terms(String field) {
                        Terms t = in.terms(field);
                        if (field.equals("a") == false) {
                          return t;
                        }
                        return new FilterTerms(t) {
                          @Override
                          public TermsEnum iterator() {
                            return TermsEnum.EMPTY;
                          }

                          @Override
                          public long size() {
                            return 0;
                          }

                          @Override
                          public long getSumTotalTermFreq() {
                            return 0;
                          }

                          @Override
                          public long getSumDocFreq() {
                            return 0;
                          }

                          @Override
                          public int getDocCount() {
                            return 0;
                          }
                        };
                      }
                    };
                  }
                };
              }

              @Override
              public CacheHelper getCoreCacheHelper() {
                return null;
              }

              @Override
              public CacheHelper getReaderCacheHelper() {
                return null;
              }
            };
        w.addIndexes(SlowCodecReaderWrapper.wrap(emptyVectorA));
      }
      try (DirectoryReader r = DirectoryReader.open(dst)) {
        String doc1;
        try {
          doc1 = r.termVectors().get(1, "b").iterator().next().utf8ToString();
        } catch (IOException e) {
          doc1 = e.toString();
        }
        // expected: doc 1's term vector "y" reads back; actual: IOException "offset 0 is invalid"
        assertEquals("y", doc1);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-244/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestTermVectorsZeroTermField
Expected vs actual
Expected: doc 1's term vector for field b reads back as y. Actual: expected:<[y]> but was:<[java.io.IOException: offset 0 is invalid]>.

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

More files, environment, full output36 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<[y]> but was:<[java.io.IOException: offset 0 is invalid]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.TestTermVectorsZeroTermField.testOtherDocumentOfChunkReadsBack(TestTermVectorsZeroTermField.java:117)

Why it looks unintended

  • TermVectorsWriter.startDocument promises startField will be called numVectorFields times; the copier in the same class declares a field, then skips it.
  • No javadoc forbids an empty Terms; CheckIndex's slow-level postings and term-vector checks report no error on the filtered reader, and the postings writer simply drops the emptied field.
  • The default codec writes a segment it cannot read.
The full argument341 words
  • Lucene's own contract. startDocument: "startField(...) will be called numVectorFields times" (TermVectorsWriter.java:64-69). addAllDocVectors declares vectors.size() (:251) and skips a null Terms (:268-271).
  • No numTerms >= 1 anywhere. startField: "startTerm will be called numTerms times" (:74-80). Fields, Terms, TermVectors, addIndexes(CodecReader...) (only "empty segments are dropped", IndexWriter.java:3254-3275) and SlowCodecReaderWrapper ("Wraps arbitrary readers for merging", SlowCodecReaderWrapper.java:37-40) state no such rule. The test framework's AssertingTermVectorsFormat checks that the declared counts match, not that terms exist.
  • Empty Terms from filtering are anticipated. Terms.getMax(): "empty: only possible from a FilteredTermsEnum..." (Terms.java:157, :174, both in getMax()); getMin() "returns null when there are no terms" (:139-141). CheckIndex tolerates the postings "ghost field" (CheckIndex.java:2244-2249) and has the TODO "really the codec should not return a field from FieldsEnum if it has no Terms... but we do this today" (:1519-1522).
  • The format expected zero-term fields. The reader's numTerms == 0 branch ("no term", Lucene90CompressingTermVectorsReader.java:918-921) dates from the format's first commit in 2013, yet the reader keeps listing the field.
  • Lucene's own checks accept the input. CheckIndex.testPostings and testTermVectors at MIN_LEVEL_FOR_SLOW_CHECKS report no error on the filtered reader (they are not a complete validator of arbitrary readers); the postings path drops the same emptied field and stays clean.
  • Input versus output. The filter lists the field and returns a non-null, empty Terms, which nothing forbids. After the copy, the Lucene90 reader lists the field and returns null from terms(), which conflicts with Fields.terms: "This will return null if the field does not exist" (Fields.java:38). The shape the merge copier then mishandles is Lucene's own output.
  • Tests and precedent. TestParallelReaderEmptyIndex.testEmptyIndexWithVectors: "Adding this index to another index should not throw any exception" (weak precedent: ordinary indexing has already omitted the empty field there). LUCENE-1918 (#2993, 2009) fixed an addAllDocVectors crash on unusual addIndexes input as a bug. 4.1.0, and 4.2.0 with the older Lucene40 term-vector format, handle the same input.
The devil's-advocate check212 words

The strongest case for "not supported" (built by the devil's-advocate checker and the cross-model reviewer): the codec SPI and Fields are @lucene.experimental; the indexing chain and MemoryIndex enforce numTerms > 0 on purpose (TermVectorsConsumerPerField.java:69-71; MemoryIndex filters numTokens > 0 in its Fields iterator and size(), MemoryIndex.java:1834, :1898; a comment there, "ensure infos.numTokens > 0 invariant; needed for correct operation of terms()", stood from 2005 until LUCENE-7091 removed it in 2016); the copier's comment "FieldsEnum shouldn't lie..." shows its author assumed a listed field has terms; and in #9450 / LUCENE-8403 maintainers declined "filtered" term vectors. So "your reader should hide a field once it has no terms left". The cross-model reviewer rated this argument moderate.

Why it fails: the restriction is written nowhere; Lucene's own Lucene90 reader produces exactly such a listing, which its own copier and CheckIndex (level 3) then cannot handle; LUCENE-8403 was about term vectors that disagree with the postings, while this filter keeps them identical; and the result is not a rejected input but a segment the default codec cannot fully read and that closes every writer whose merge re-encodes it. An IllegalArgumentException at addIndexes time would be a defensible answer; silent corruption is not. Verdict: a defect (adjudication: confirmed, high confidence).

Suggested fix

Recommended The copier forwards only fields with at least one term, and declares exactly that many

In TermVectorsWriter.addAllDocVectors, first collect the fields that have at least one term, call startDocument with that count, and write only those (+32/-26, one file). The codec never sees a zero-term field and the declared count always matches. The test and the closest suites pass; a chunk already written with offsets stays unreadable.

final List<String> fieldNames = new ArrayList<>();
final List<Terms> fieldTerms = new ArrayList<>();
final List<Integer> fieldNumTerms = new ArrayList<>();
for (String fieldName : vectors) {
  final Terms terms = vectors.terms(fieldName);
  if (terms == null) continue;
  int numTerms = (int) terms.size();   // counted with the iterator when -1
  if (numTerms == 0) continue;
  fieldNames.add(fieldName); fieldTerms.add(terms); fieldNumTerms.add(numTerms);
}
final int numFields = fieldNames.size();
startDocument(numFields);
// then write exactly these fields
All options and trade-offs4 options · 759 words

One fix was built and validated in a scratch worktree of main a9779ef3c193; a codec-side hardening was run and is not enough alone; two more options are listed.

Workaround until fixed: a term-removing reader should hide a term-vector field whose remaining Terms is empty (leave it out of the per-document Fields iterator and size()). Not executed.

The copier forwards only fields with at least one term, and declares exactly that many (validated by the checks) (recommended)

In TermVectorsWriter.addAllDocVectors (TermVectorsWriter.java:235-328), first collect the fields whose Terms is non-null and has at least one term (counting with the iterator when size() is -1, as the code already does), call startDocument with that count, then write those fields. This is what the indexing chain already guarantees (TermVectorsConsumerPerField.java:69-71) and what the postings writer does with an emptied field. It is the common copier of Lucene's shipped non-bulk term-vector merge paths (a third-party codec that overrides merge(MergeState) would need its own check). It removes both symptoms: no zero-term instance is written, and a later merge of a segment whose reader lists a null-Terms field no longer miscounts. The finder's patch, with its now-unused java.util.Iterator import removed.

final List<String> fieldNames = new ArrayList<>();
final List<Terms> fieldTerms = new ArrayList<>();
final List<Integer> fieldNumTerms = new ArrayList<>();
for (String fieldName : vectors) {
  final Terms terms = vectors.terms(fieldName);
  if (terms == null) continue;
  int numTerms = (int) terms.size();   // counted with the iterator when -1
  if (numTerms == 0) continue;
  fieldNames.add(fieldName); fieldTerms.add(terms); fieldNumTerms.add(numTerms);
}
final int numFields = fieldNames.size();
startDocument(numFields);
// then write exactly these fields

Trade-offs

  • Terms are counted in the first loop instead of the second, so the number of passes is unchanged; it keeps one document's field names, Terms objects and term counts in three small lists. Not benchmarked; this is already the slow per-document path.
  • Already-written segments (two-phase probe: written by stock main, merged with the fix): without offsets the merge now succeeds and CheckIndex at level 3 is clean; with offsets the chunk is already unreadable, so the merge still fails reading it (offset 0 is invalid, tragic). The fix cannot repair those bytes; rebuilding the segment from the source documents avoids it, and salvage with a tolerant reader was not investigated.

What the checks validated

  • Gradle in the scratch worktree, -Ptests.seed=DEADBEEF: the new test passes 3 of 3 with assertions on and off.
  • The closest suites, gradle with the fix, seed DEADBEEF: core *TermVector*, TestAddIndexes, TestFilterLeafReader, TestParallelReaderEmptyIndex, TestParallelLeafReader, TestCheckIndex and the new test, 19 classes and 151 tests, 0 failures with assertions on and off; codecs *TermVector* (SimpleText, whose merges use the same copier), 20 tests, 0 failures, 1 skipped; :lucene:core:check -x test (google-java-format, ecjLint, forbiddenApis) passes.
  • Repro harness with the fix shadowed: the new test 3/0 with -ea and -da; the independent reproduction's test 3/0 with -ea and -da (registration check). Earlier, on a separate build host: 92 upstream tests (TestTermVectors, TestTermVectorsWriter, TestTermVectorsReader, TestAddIndexes, TestLucene90TermVectorsFormat) pass with -ea.

Write AvgCharsPerTerm under the reader's condition (executed: not enough alone)

In Lucene90CompressingTermVectorsWriter.flushOffsets, hasOffsets |= fd.hasOffsets && fd.numTerms > 0; (+2/-1), so the floats are written only when the reader will read them (totalOffsets > 0).

// the reader reads AvgCharsPerTerm only if the chunk has an offset (totalOffsets > 0)
hasOffsets |= fd.hasOffsets && fd.numTerms > 0;

Trade-offs

Run with the new test (-ea and -da): doc 1 now reads back as [here, public, text], but CheckIndex (level 3) still fails on the listed zero-term field and both merge tests fail as before. It only changes what the writer emits for zero-term OFFSETS instances, which normal indexing never produces. Useful next to the recommended fix, so that no other caller of the codec API can produce the mismatched layout.

Reject the input

Throw IllegalArgumentException from addAllDocVectors (or the Lucene90 startField) on numTerms == 0 or on a listed field with null Terms.

Trade-offs

Defensible, but it turns a valid filtered copy into an error the application must work around, and it leaves an already-written segment with a null-Terms listing permanently unmergeable. Not run.

Make the Lucene90 reader stop listing zero-term fields

Skip numTerms == 0 instances in TVFields.iterator() and size().

Trade-offs

Fixes the merge miscount for existing segments too, but not the layout mismatch, and touches the read path. Not run. Separately, CheckIndex.testTermVectors could report a listed field with null Terms instead of throwing a NullPointerException (CheckIndex.java:4360-4362).

Downstream note

Only an application's own term-removing reader can trigger it (no Lucene-shipped reader, and none of the Elasticsearch, OpenSearch or Solr merge and addIndexes paths read for this check, removes term-vector terms), and on releases before 10.4.0 run without assertions the misreads are silent and can carry fragments of neighbouring documents' terms.

How it was found

An AI tool's SMT model (Z3) of which optional sections the Lucene90 term-vector writer emits and its reader expects found one disagreement: an OFFSETS field with zero terms. The AI tool then found a public-API path that produces such a field, addIndexes with a term-removing reader, and found the merge failure by running it.

The method in full190 words

Method: SMT model checking with Z3 4.12.2 (the hunt's formal tier), by an AI tool working the hunt's unit on the compressing term-vector format, on main ea57360912ed.

  • The model encodes, per field instance of one chunk, the flags (positions, offsets, payloads), the term count and the term frequencies, and for each optional section of the chunk when the writer emits it and when the reader reads it, each mapped to file:line.
  • Obligation L1, "AvgCharsPerTerm presence agrees for every chunk the codec API accepts", is satisfiable for 1 to 4 field instances; the counterexample is an OFFSETS instance with numTerms == 0. L2, the same under numTerms >= 1 (what the indexing chain guarantees), and L3, the block value counts, are unsatisfiable.
  • The AI tool then searched for a public-API path that delivers numTerms == 0 to the writer, found addIndexes(CodecReader) with a term-filtering reader that CheckIndex accepts, and found the second symptom, the poisoned merge, by running it.
  • Independent checks followed: a reproducer with its own filter and tests, a cross-model review, a devil's-advocate checker, an adjudication, a history sweep and a security probe (see the checks below).

Prior reports, history and checks

Prior reports

No prior report found (two automated runs of 51 queries, 73 GitHub searches, JIRA, four mailing lists, downstream projects, all 413 open PRs; 2026-10-26). Closest: LUCENE-8403, a declined request for filtered term vectors; LUCENE-8277, open, about validating addIndexes input; LUCENE-1918, a fixed 2009 crash in the same copier.

Searches5 searches

Verdict: new. 30 hits were read; none is this bug. Not fixed upstream: no commit changes the conditions, and no open PR changes flushOffsets, the AvgCharsPerTerm conditions, the numTerms handling or TermVectorsWriter.java.

Closest items: #9450 / LUCENE-8403 "Support 'filtered' term vectors" (closed Won't Fix, 2019: another contributor's uncommitted patch there hit reader and CheckIndex trouble with fields emptied by its own modified writer, a different malformed shape; maintainers declined term vectors that disagree with the postings; nobody reports stock Lucene writing an unreadable chunk or a segment whose merges fail). #9324 / LUCENE-8277 "Better validate CodecReaders in addIndexes" (open; its check runs without slow checks and would not reject this input). #2993 / LUCENE-1918 (fixed 2009: an addAllDocVectors crash on unusual addIndexes input, pre-4.0 code). #15570 (2026-01) introduced the offset 0 is invalid message this bug now shows.

Gaps: GitHub search reads only the first page per query; the mailing lists were searched through the archive API; one web query was refused (HTTP 429); private security reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26two prior-art runs (16 issue/PR and 3 commit queries each) and 73 GraphQL searches: addAllDocVectors, flushOffsets, charsPerTerm, AvgCharsPerTerm, "offset 0 is invalid", "Wrong number of values added", empty or ghost term-vector fields, SlowCodecReaderWrapper term vectorsno report; related #9450, #9324, #2993, #15570
Open PRs (apache/lucene)2026-10-26the touched files of all 413 open PRsnone touches TermVectorsWriter.java; #1003, #14397, #14716, #15194 and #16480 touch the Lucene90 term-vector files without changing these conditions
JIRA archive2026-10-264 archive queries per run and 9 REST queries (AvgCharsPerTerm, empty term vector, FilterCodecReader); resolutions of LUCENE-8403, 8277, 1918no report
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2620 queries per run through the archive APIno report; a 2016 java-user thread uses a filtering reader with addIndexes (documents, not terms)
Downstream projects and the web2026-10-26Elasticsearch, OpenSearch, Solr, lucene-solr, Lucene.NET; 14 web queriesno relevant hit

History

Introduced in 4.2.0 (2013) by LUCENE-4599, the compressing term-vector format, which LUCENE-4733 made the default: 4.1.0 passes, 4.2.0 fails, and 4.2.0 with the older Lucene40 format passes. Every executed release from 4.2.0 to 10.5.2 and main fails. Not fixed upstream.

Versions tested7 rows

Introduced by 5837fe83bd8 (2013-01-21, @jpountz, "LUCENE-4599: New compressed TVF impl: CompressingTermVectorsFormat", issue #5664; Subversion era, no pull request; branch_4x backport 4a62a579a70, the commit in the 4.2.0 tag): the AvgCharsPerTerm write and read conditions and the reader's numTerms == 0 -> null branch all date from it. Made the default by 94b17a28de5 (2013-02-02, LUCENE-4733, #5798; branch_4x e3710747d87). The copier's declare-first, skip-null behaviour is older (873f1999244, 2011, LUCENE-2621) and was harmless with the Lucene40 format, whose reader returns an empty Terms for such a field.

Only the versions below were run. Git pickaxe shows the same code fragments (hasOffsets |= fd.hasOffsets, totalOffsets > 0, charsPerTerm) carried through the format's copies and refactors (LUCENE-5969, LUCENE-9705, #15712), so the releases in between appear affected.

Symptom changes, not fixes: 873ac5f162a (LUCENE-9907, #72, 9.0.0) moved the field tables to DirectWriter, whose count check makes the poisoned merge throw instead of silently writing one document's term vectors under another; #15570 (473dd7b5865, 10.4.0) made LZ4 reject offset 0, so the shifted read throws instead of returning wrong bytes.

All runs on Linux aarch64 with JDK 25.0.3; release jars SHA-1 checked; each with assertions on and off.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 3 fail (gradle and harness, assertions on and off; 30/30 over random seeds)
10.5.2 affectedthis doc's test 3 of 3 fail: offset 0 is invalid, Wrong number of values added
9.0.0, 9.4.2, 9.12.3 affectedchunk read: AssertionError with assertions, ArrayIndexOutOfBoundsException or wrong bytes without; merge: Wrong number of values added, tragic
4.2.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 8.11.4 silent without -eawith assertions both fail; without, doc 1 reads wrong bytes (4.2.0: 6 NUL bytes), doc 2 throws ArrayIndexOutOfBoundsException, and the merge "succeeds" but writes one document's term vectors under another
4.2.0 with the destination's term-vector format set to Lucene40 controlpasses: the failure follows the compressing format
4.1.0 not affectedpasses (Lucene40 term-vector format is the default)
fix on main a9779ef3c193 not affectedthe test 3/3 pass; closest suites pass

Independent checks

An independent reproducer, a devil's-advocate checker and an adjudication confirmed it with their own runs; an OpenAI GPT-5.6 review (reading only, it could not run code) agreed on the root cause. This doc re-ran it on a9779ef3c193 with a new single-file test and validated the fix.

Checks and verdicts514 words

The verifier checks ran on main ea57360912ed; this doc and the main-refresh re-runs used a9779ef3c193 (none of the 7 commits in between touches the term-vector, LZ4, DirectWriter, CheckIndex or IndexWriter files). The cross-model review ran reading only, because its runner could not execute code.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateZ3 model L1 SAT; JUnit fails 2 of 3 on main (-ea, -da) and 10.5.2, and 2 of 2 (its 9.x variant without the control) on 9.12.3 and 9.5.0; its control (CheckIndex on the filtered reader) passes. Its own CheckIndex assertion ran at level 0 and was vacuous.
cross-model review no verdict5 attempts failed (reading only).
Independent reproducer realOwn filter and tests written from the claim: 3 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3; 30 of 30 over random seeds; control passes at the slow CheckIndex level; two fresh writers fail again; CheckIndex by level 1/2/3.
Refute review (OpenAI GPT-5.6, reading only) not real (procedural), mediumIt could not run Java, and it voted not real on procedure only; it agreed on the root cause ("strongly corroborates both reported failure mechanisms"). Its one concrete objection (the finder's filter did not override prepareSeekExact) was re-tested with a filter that does: still fails.
Intended-behaviour devil's advocate realBuilt the strongest misuse case and rebutted it; re-ran on main and 10.5.2; its probe shows docs 1 and 2 unreadable and the 3-document segment broken at CheckIndex levels 2 and 3.
Adjudication confirmed, highRe-ran the independent reproduction's test with a fresh seed: 3 of 3 fail with -ea and -da; asked for the CheckIndex level, the symptom conditions and the version wording to be stated.
History sweep introduced in 4.2.0Fails on 4.2.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 8.11.4, 9.0.0, 9.4.2, 9.12.3, 10.5.2 and main; passes on 4.1.0 and on 4.2.0 with the Lucene40 format.
Security screen not security-sensitiveApplication-only trigger, out of scope of SECURITY.md; probe of 1,000 random chunk layouts: silent cross-document misreads only on releases before 10.4.0 without assertions.
Main refresh still reproducesThe finder's and the independent reproduction's tests fail on a9779ef3c193 as on ea57360912ed.
This doc's runs reproduced on a9779ef3c193; fix validatedNew 3-test JUnit: 3/3 fail through gradle and the harness (assertions on and off), 30/30 over random seeds, 3/3 on 10.5.2 and 9.12.3; with the fix 3/3 pass and the closest suites pass; the codec-side alternative fixes only the read; a segment already written with offsets stays unreadable.
Doc fact checks wording fixedAn independent fact check (it re-ran the test, the fix and the model), a cross-model fact check (OpenAI GPT-5.6, reading only) and a diagram check confirmed the bug, the mechanism and the fix, and found wording that went beyond the evidence: the headline's merge scope, the version notes, the chunk bounds, the CheckIndex levels without offsets, a quote from a comment that no longer exists, and two summary-card and step-5 labels. All were fixed in this doc, the brief and the diagrams.

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

Finding record

LFV-244, snapshot of 2026-11-06

Registry title
addIndexes(CodecReader) from a reader whose term vectors list a field with zero terms writes a term-vector chunk that the default codec cannot read back, and every later merge that re-encodes the segment closes the IndexWriter
Area
core / codecs: term vectors (TermVectorsWriter.addAllDocVectors merge copier; Lucene90 compressing term-vector writer and reader), reached through IndexWriter.addIndexes(CodecReader)
Priority
Low, rank 319: Needs an expert, application-written term-removing reader fed to addIndexes(CodecReader); normal indexing never produces the zero-term term-vector field.
Impact class
data corruption
Impact note
Applications that pass their own term-removing reader (a FilterLeafReader or FilterCodecReader that drops terms from the postings and term vectors, an expert pattern) to IndexWriter.addIndexes(CodecReader...) or to a merge whose readers they wrap are hit, with the default codec and IndexWriterConfig, at any index size and in every release since 4.2.0, as soon as one document's term-vector field is left listed with zero terms: every later merge that re-encodes that segment (a plain delete plus commit is enough) closes the IndexWriter tragically, on every retry (before 9.0.0 with assertions off it instead silently writes one document's term vectors under another document), and when that field stores offsets and nothing else in its chunk of up to 128 documents has offsets, the other documents' term vectors in the chunk are unreadable, while CheckIndex at its default level reports the index clean; normal indexing never produces such a field.
Summary
You copy an index with IndexWriter.addIndexes(CodecReader...) through a reader that removes some terms consistently from the postings and the term vectors (CheckIndex at its strictest level accepts this reader), so one document's term-vector field is left with zero terms. You expect the new segment to read back and merge like any other, as the postings do (the postings writer simply drops the emptied field). Instead the default term-vector format writes per-field AvgCharsPerTerm floats that its own reader does not expect, so when the emptied field stores offsets the other documents of the chunk throw 'offset 0 is invalid' on read, and because the reader lists the emptied field but returns null for it, the next merge that re-encodes the segment fails with 'Wrong number of values added' and closes the IndexWriter, on every retry.
Introduced
5837fe83bd813b021d133c9f806cbcf215d24f67 ('LUCENE-4599: New compressed TVF impl: CompressingTermVectorsFormat', Adrien Grand (jpountz), 2013-01-21, Subversion r1436556, no pull request; LUCENE-4599 = apache/lucene#5664; branch_4x backport 4a62a579a70): the writer writes AvgCharsPerTerm when any field instance of the chunk has the OFFSETS flag while the reader reads it only when the chunk has an offset occurrence, and the reader returns null from terms() for a listed zero-term field; made the default term-vector format by 94b17a28de5 (LUCENE-4733 = apache/lucene#5798, 2013-02-02; branch_4x e3710747d87). Latent precondition: 873f1999244 (LUCENE-2621 = apache/lucene#3695, rmuir, 2011-11-16) added the codec-agnostic merge copier TermVectorsWriter.addAllDocVectors with startDocument(Fields.size()) before the fields are inspected, the null-Terms skip and startField(numTerms = 0) for an empty Terms, harmless with Lucene40TermVectorsFormat [exec: 4.1.0 and the 4.2.0 control]. Symptom change, not the bug: 873ac5f162a (LUCENE-9907, apache/lucene#72, 2021-04-15, first released in 9.0.0) moved flushFields/flushFlags to DirectWriter, whose count check makes the poisoned merge throw instead of silently writing wrong-document term vectors [read: git blame -w -C -C -M and git log -S in the full-history clone; exec: follow-up history probes] (2013-01-21 (compressing term-vector format, LUCENE-4599); 2013-02-02 (made the default, LUCENE-4733); the merge-copier part dates from 2011-11-16 (LUCENE-2621))
First affected version
4.2.0 [exec] (tag 2013-03-11; the first release whose default term-vector format is the compressing one; on the 4.x API the trigger is addIndexes(IndexReader...) with a FilterAtomicReader, from 5.0.0 addIndexes(CodecReader...) with SlowCodecReaderWrapper); 4.1.0 is not affected [exec], and the 4.2.0 jars with only the destination term-vector format switched back to Lucene40TermVectorsFormat are not affected either [exec]
Affected versions
every release from 4.2.0 through 10.5.2, 10.6.0 (by reading) and main ea57360912ed. [exec] with a filter that removes the term from both the postings and the term vectors: 4.2.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 8.11.4, 9.0.0 and 9.4.2 (follow-up history probes, -ea and -da), 9.5.0 and 9.12.3 (finder src-9x), 10.5.2 and main (-ea and -da): all fail; the independent reproduction's independent test fails on main (-ea, -da), 10.5.2 (-ea) and 9.12.3 (-ea, -da); the security probe also ran 10.3.2 and 10.4.0 (-da). Symptoms differ by version: before 9.0.0 with -da the re-encoding merge succeeds but writes one document's term vectors under another document (4.2.0-8.11.4), from 9.0.0 it throws 'Wrong number of values added' and closes the writer; chunk reads before 10.4.0 with -da return silently wrong bytes (including other documents' term bytes on 9.12.3 and 10.3.2) or throw ArrayIndexOutOfBoundsException, from 10.4.0 (#15570) they throw IOException 'offset 0 is invalid'. [read] every release in between has the same lines by blame; 5.0.0-5.2.x not executed (older Terms#iterator API). Not affected: 4.1.0 and older [exec 4.1.0; no compressing format before 4.2.0]. Not fixed on main [exec 2026-10-29: main 645d4fe14a2 fails with -ea and -da, same messages; #16919 changed only the reader's unknown-flags branch, which no variant reaches]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b have the same TermVectorsWriter and reader files [read]. All runs on aarch64 with JDK 25.0.3.
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 and voted “not real” while agreeing with the root cause (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
formal: Z3
Upstream and prior reports
none found (2026-10-26, plus the finder's interrupted scripted prior-report search; the prior-report record verdict new: two scripted prior-report searches of 51 queries each over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, 73 GitHub GraphQL searches including downstream repositories, 9 JIRA REST queries, the touched files of all 413 open apache/lucene PRs and a local git pickaxe; 30 hits listed, none is this bug); related prior items, not duplicates: #9450 / LUCENE-8403 (closed Won't Fix 2019: a feature request for term vectors that omit terms; another contributor's uncommitted patch there hit reader and CheckIndex trouble with fields emptied by its own modified writer, which declared all terms and then skipped some, and maintainers declined term vectors that disagree with the postings, which this trigger avoids), #9324 / LUCENE-8277 (open since 2018: validate CodecReaders in addIndexes; its proposed check runs without slow checks and would not reject this input), #2993 / LUCENE-1918 (fixed 2009: a precedent, the same kind of addAllDocVectors crash on unusual addIndexes input in pre-4.0 code), #15570 (merged 2026-01: introduced the 'offset 0 is invalid' message this bug now shows); not fixed upstream: ea57360912ed is the upstream main HEAD of 2026-10-26, and no open PR changes flushOffsets, the AvgCharsPerTerm conditions, the numTerms handling or TermVectorsWriter.java
Lucene commit tested
main a9779ef3c193 (2026-10-26): this doc's JUnit through gradle in a scratch worktree (assertions on and off) and through the hunt repro harness (assertions on and off, 30 of 30 over 10 random-seed iterations); the 10.5.2 and 9.12.3 release jars; the recommended fix with the closest core and codecs suites, a codec-side alternative, and a two-phase probe over a segment written by stock main. The verifiers, the history sweep (4.1.0 to 10.5.2) and the security probe ran on main ea57360912ed; none of the 7 commits in between touches the term-vector, LZ4, DirectWriter, CheckIndex or IndexWriter files (one adds a vector-values override to SlowCodecReaderWrapper, which moves its cited javadoc by one line), and the main-refresh step re-ran the finder's and the independent reproduction's tests on a9779ef3c193 with the same failures.

Lucene and upstream sources

Finding record
  • a Lucene main checkout @ ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/TermVectorsWriter.java:235-289 (startDocument at 251, null-Terms skip at 268-270); lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:629 (hasOffsets |= fd.hasOffsets), 643-656 (AvgCharsPerTerm written), 464-465 (flushFieldNums assert and fieldNums[numDistinctFields - 1]), 495 (DirectWriter finish in flushFields); lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsReader.java:587-591 (read only if totalOffsets > 0), 918-921 (numTerms == 0 -> null); lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:69-74 (the indexing chain skips zero-term fields); lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90TermVectorsFormat.java (chunk of 1 << 12 bytes or 128 documents)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/TermVectorsWriter.java:64-69 (startDocument javadoc), :74-80 (startField javadoc), :235-328 (addAllDocVectors; :242-251 count and startDocument, :259 loop, :267-271 null skip, :278-289 numTerms and startField, :326 assert)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsWriter.java:318-340 (startDocument, startField), :383-421 (flush), :436-450 (flushNumFields), :465-466 (flushFieldNums), :483-498 (flushFields, :495 finish), :623-657 (flushOffsets; :629, :643, :654-657), :915-931 (merge, :929 addAllDocVectors), :953-967 (canPerformBulkMerge)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsReader.java:551-561 (totalOffsets), :587-591 (AvgCharsPerTerm read), :879-902 (iterator), :918-921 (terms returns null), :948-950 (size)
  • lucene/core/src/java/org/apache/lucene/util/compress/LZ4.java:113-116; lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90TermVectorsFormat.java:170; lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:69-71
Who is affected
  • lucene/core/src/test/org/apache/lucene/index/TestFilterLeafReader.java:35-53, :156-161
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4128-4141, :6429-6434 (fully deleted segments are dropped)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/TermVectorsWriter.java:64-80, :251, :267-271
  • lucene/core/src/java/org/apache/lucene/index/Fields.java:22-46 (:38 terms javadoc); lucene/core/src/java/org/apache/lucene/index/Terms.java:98-103, :157, :174; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3254-3275; lucene/core/src/java/org/apache/lucene/index/SlowCodecReaderWrapper.java:37-40
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1519-1522, :2244-2249
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingTermVectorsReader.java:918-921
  • lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingTermVectorsFormat.java:119-144
  • lucene/core/src/test/org/apache/lucene/index/TestParallelReaderEmptyIndex.java:71-74
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:4360-4362

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

Repro files

About the diagrams

Made from an executed tracing probe of the independent reproduction's documents on main a9779ef3c193 (chunk sections, byte ranges, reader reads, merge exceptions, retries, and the fix shadowed in). Re-rendered on 2026-10-27 after the diagram check: the summary card labels its two examples, and step 5 states what the fix does for segments written before it (without offsets they merge again; a chunk written with offsets stays unreadable) and what the codec-side change alone does. The alt text follows the final PNG.

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.