All findings · Lucene finding · LFV-096 · addbatch-tv-files-missing-without-rows

An addBatch that declares a new term-vector field but indexes no row leaves a commit that cannot be opened

In the experimental column API, a batch that declares a new term-vector field but indexes no row leaves a segment that promises term vectors it never wrote. After one addDocument, one empty batch and commit(), DirectoryReader.open throws CorruptIndexException (10.5.0 to main).

Summary

Priority
Medium, rank 152An empty, rejected or failing addBatch that declares a new term-vector field can leave a commit that DirectoryReader.open rejects, catastrophic but loud and an unusual edge sequence on an experimental API.
Impact
index unusable
Since
10.5.0 (#15990, merged 2026-05-14: the first version of the column API)introduced 2026-05-14
Affected
10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
semi-formal: a bounded-exhaustive harness comparing addBatch with row-by-row indexing

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

Lucene creates a segment's term-vector writer only when a document finishes indexing. The column API registers a batch's fields before it indexes any row, so a batch without rows can register a term-vector field and never create the writer.

Summary card: an empty or failed addBatch that adds a term-vector field leaves a commit that cannot be opened. Doc 0 is added, then addBatch with numDocs() = 0 and a text column body with term vectors. FieldInfo body says term vectors: yes, but no term-vector writer was created, so flush writes no .tvd, .tvx or .tvm file. commit() succeeds; DirectoryReader.open throws CorruptIndexException: No sub-file with id .tvd found in compound file _0.cfs.
A step-by-step example from the executed empty-batch repro on main: doc 0, an addBatch with 0 rows and a term-vector column body, flush, commit and open; it ends on a summary card. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Add 1 document with addDocument, then call addBatch with a batch of numDocs() == 0 whose only column, body, is a text field with term vectors that this segment has not seen yet.
  2. You expect the empty batch to change nothing: after commit(), DirectoryReader.open returns 1 document, as it does when the same new field arrives through addDocument.
  3. Instead commit() succeeds and DirectoryReader.open throws CorruptIndexException, caused by "No sub-file with id .tvd found in compound file _0.cfs". The segment's FieldInfos say body has term vectors; its compound file holds 12 entries, norms for body among them, but no term-vector file.
  4. Why: addBatch registers body ("has term vectors") before it indexes any row. The term-vector writer is created only when a document finishes, and flush writes term-vector files only if that writer exists. No row, no writer, no files.
  5. The same happens when the batch is rejected (a later column conflicts with the schema: IllegalArgumentException) or its cursor throws on the first value. With the default config, commit() and then close() throw instead.

Who is affected

Users of the experimental column API (addBatch, updateDocuments with a ColumnBatch; 10.5.0 to main), with the default config and codec. It takes a batch that declares a term-vector field new to the in-memory segment and indexes no row, another document already in that segment, and no later document indexed there before the flush.

Mechanism, terms and numbers635 words

IndexWriter#addBatch (the experimental column API, 10.5.0 and later) registers the field of every column in the segment's FieldInfos before it indexes any row. For a field with term vectors that only sets a flag; the term-vectors writer is created when the first document finishes, and flush writes term-vector files only if that writer exists. A batch that indexes no row (numDocs() == 0, a later column rejected by the schema check, or a cursor that throws on its first nextDoc()) therefore leaves FieldInfos that declare term vectors and no .tvd/.tvx/.tvm files. A document already in the same in-memory segment is flushed into that segment, and the commit cannot be opened: DirectoryReader.open throws CorruptIndexException, or, with the default config and the two failure triggers, commit() and close() throw. The per-document path is safe because it always finishes the document it started.

Terms

  • FieldInfos: the per-segment list of fields and what each one stores (postings, norms, term vectors, doc values, points, vectors). A reader opens one file set per feature that FieldInfos declare.
  • Term vectors: an optional per-document copy of a field's terms (FieldType#setStoreTermVectors), stored in .tvd, .tvx and .tvm files.
  • addBatch / ColumnBatch: the experimental column-oriented indexing API (10.5.0). A batch has numDocs() documents and a list of columns, one per field feature; each column hands out a cursor over (doc, value) pairs.
  • In-memory segment: the documents an IndexWriter thread has buffered since its last flush; a flush or commit writes them as one segment.

The bug, step by step (main a9779ef3c193)

  1. IndexingChain.processBatch (IndexingChain.java:717) first loops over the columns (:726-780), then calls initAndValidateFields (:782-785). In column order, that creates the FieldInfo of each new field (:692-693) or checks an existing field's schema (assertSameSchema, :695, which throws IllegalArgumentException on a conflict).
  2. Creating an indexed field calls PerField.setInvertState() (:1415-1416). For a field with term vectors it calls termVectorsWriter.setHasVectors() (:1916-1918), which only sets a flag (TermVectorsConsumer.java:112-114).
  3. The term-vectors writer is created only in TermVectorsConsumer.finishDocument (:117-128), when a document finishes. flush writes term vectors only if (writer != null) (:78-89).
  4. The batch path finishes documents only in its row loop (IndexingChain.java:889, termsHash.finishDocument at :937), and asks each row column for its first document before that loop (:875). No document is finished when (a) numDocs() is 0, (b) step 1 threw for a later column, after body was created, or (c) the cursor throws at :875.
  5. For (a) reserveDocs returns at once (DocumentsWriterPerThread.java:226-230); for (b) and (c) updateBatch treats the exception as non-aborting and marks the batch's reserved documents deleted (:349-354). The writer keeps going, and the earlier document is flushed into the same segment.
  6. SegmentCoreReaders opens the term-vectors reader whenever FieldInfos declare term vectors (SegmentCoreReaders.java:120-128) and turns the missing file into CorruptIndexException (:141-144).

The per-document path is safe: processDocument calls termsHash.startDocument() (IndexingChain.java:598) before it creates any FieldInfo and termsHash.finishDocument in its finally block (:662-681), so even a rejected document creates the writer, and flush fills empty entries.

The numbers

Default codec, NoMergePolicy, one document (id = "0"), then the batch. Right after the batch the in-memory state is body(tv=true), hasVectors=true, writer=null. The committed segment _0 has maxDoc 1 (empty batch), 2 (rejected batch, 1 deleted) or 4 (cursor fails, 3 deleted), 1 live document, and 12 or 16 compound sub-files: postings, norms (also for body), stored fields, FieldInfos and, for (b), doc values, but no term vectors. DirectoryReader.open throws. The same rejection through addDocument creates the writer, writes .tvd, .tvm and .tvx, and opens with 1 document.

Exposure and likelihood444 words
  • Who. Applications on IndexWriter#addBatch (@lucene.experimental, IndexWriter.java:1535) or updateDocuments(Term|Query, ColumnBatch), which reaches the same code (IndexWriter.java:1554, :1570; by code reading, not executed). Released in 10.5.0, 10.5.1, 10.5.2 and, by reading, 10.6.0 (branch_10_6 has the same code).
  • What it needs. A batch that declares a term-vector field new to the in-memory segment and indexes no row: empty, rejected by a later column's schema check, or a cursor that fails on its first value. At least one other live document in that segment. For a rejected batch, the term-vector column must be checked before the failing one. Default IndexWriterConfig and codec (also SimpleText, run with the empty batch only); deterministic; any scale. That another live document is needed was run for the empty batch; for the other two triggers it is read from the code. The three triggers are examples: any exception after the batch's FieldInfos are created and before its first row is finished does the same (code reading).
  • What masks it. A later document indexed in the same segment before it flushes creates the writer and repairs the segment: an addDocument (run) or, by code reading, a batch with an inverted column and a row. A later doc-values-only batch does not, and an index sort does not either (run with the empty batch).
  • What it does.
    • NoMergePolicy, or the default config with an empty batch: commit() succeeds and writes a commit no reader can open; with the default deletion policy it replaces the previous good commit.
    • Default config, rejected or failing-cursor batch: commit() throws CorruptIndexException (merge-on-commit opens the new segment); the writer stays open and not tragic; one more addDocument plus commit() fails again, and close() throws; on a new index nothing is ever committed. Only these two fail inside commit() because they leave the segment 50% and 75% deleted, so the default merge policy merges it at commit, which opens it (code reading). After an earlier good commit, rollback() closes the writer; a new writer opens the previous commit, without the document added since.
    • NRT: DirectoryReader.open(writer) throws the same exception.
    • CheckIndex reports the segment broken (numBadSegments=1, totLoseDocCount=1); -exorcise would drop it with its documents.
  • Loud, not silent. No wrong results; every failure is an exception (CorruptIndexException, or IndexNotFoundException when a new index never got a commit). Postings, stored fields and doc values of the documents are written; only the term-vector files are missing.
  • How likely. Rare in steady state: a pipeline whose batches always carry the term-vector column with rows heals itself. It sits on the error path, where one bad batch becomes an unopenable commit instead of one rejected batch.

Minimal repro

The test shows that an IndexWriter#addBatch with an empty batch (numDocs() == 0) that declares a new term-vector field, after one addDocument, commits a segment whose FieldInfos declare term vectors but that has no term-vector files.

TestEmptyColumnBatchTermVectors.java (JUnit test)80 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.Document;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.document.column.BinaryColumn;
import org.apache.lucene.document.column.Column;
import org.apache.lucene.document.column.ColumnBatch;
import org.apache.lucene.document.column.ObjectTupleCursor;
import org.apache.lucene.search.DocIdSetIterator;
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;
import org.apache.lucene.util.BytesRef;

public class TestEmptyColumnBatchTermVectors extends LuceneTestCase {

  public void testEmptyBatchWithTermVectorField() throws Exception {
    FieldType withTermVectors = new FieldType(TextField.TYPE_NOT_STORED);
    withTermVectors.setStoreTermVectors(true);
    Column body =
        new BinaryColumn("body", withTermVectors, Column.Density.SPARSE) {
          @Override
          public ObjectTupleCursor<BytesRef> tuples() {
            return new ObjectTupleCursor<>() {
              @Override
              public int nextDoc() {
                return DocIdSetIterator.NO_MORE_DOCS;
              }

              @Override
              public BytesRef value() {
                throw new IllegalStateException();
              }
            };
          }
        };
    ColumnBatch emptyBatch =
        new ColumnBatch() {
          @Override
          public int numDocs() {
            return 0;
          }

          @Override
          public Iterable<Column> columns() {
            return List.of(body);
          }
        };
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        w.addDocument(new Document());
        w.addBatch(emptyBatch);
      }
      // expected: the commit opens with 1 doc; actual: CorruptIndexException, no term-vector files
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        assertEquals(1, reader.numDocs());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-096/, 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.TestEmptyColumnBatchTermVectors
Expected vs actual
Expected DirectoryReader.open to open the commit with 1 document; actual: CorruptIndexException caused by No sub-file with id .tvd found in compound file "_0.cfs", with -ea and -da.

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

More files, environment, full output65 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.apache.lucene.index.CorruptIndexException: Problem reading index from ByteBuffersDirectory@f9f34a6 lockFactory=org.apache.lucene.store.SingleInstanceLockFactory@1437f163 (resource=ByteBuffersDirectory@f9f34a6 lockFactory=org.apache.lucene.store.SingleInstanceLockFactory@1437f163)
       org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:142)
       org.apache.lucene.index.SegmentReader.<init>(SegmentReader.java:95)
       org.apache.lucene.index.StandardDirectoryReader.createOrReuseSegmentReader(StandardDirectoryReader.java:296)
       org.apache.lucene.index.StandardDirectoryReader.createSegmentReaders(StandardDirectoryReader.java:239)
       [...]
     Caused by: java.io.FileNotFoundException: No sub-file with id .tvd found in compound file "_0.cfs" (fileName=_0.tvd files: [.nvd, .fnm, .nvm, .fdx, .fdt, .fdm])
       org.apache.lucene.codecs.lucene90.Lucene90CompoundReader.openInput(Lucene90CompoundReader.java:184)
       org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingTermVectorsReader.<init>(Lucene90CompressingTermVectorsReader.java:152)
       org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingTermVectorsFormat.vectorsReader(Lucene90CompressingTermVectorsFormat.java:96)
       org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:125)
       [...]

Why it looks unintended

  • Empty batches are legal and tested upstream (testEmptyBatch), and batch failures are designed to leave a usable writer (test*ThrowMidBatchRecovers).
  • The per-document path writes term-vector files even for a rejected document, and the same code writes norms "even if no documents actually succeed".
  • #13369 fixed the same FieldInfo-without-file state for points as a bug.
The full argument255 words
  • Commit contract. IndexWriter#commit: "Commits all pending changes ... such that a reader will see the changes" (IndexWriter.java:4189-4190).
  • Empty batches are legal. ColumnBatch#numDocs only requires doc ids "in the range [0, numDocs())" (ColumnBatch.java:28-31); reserveDocs returns at once for n == 0 (DocumentsWriterPerThread.java:228-230); upstream TestColumnBatchIndexing#testEmptyBatch (:924) adds an empty batch and checks that the "Writer still functions for follow-up work" (its batch has no columns).
  • Batch failures are non-aborting by design. updateBatch: "the iterator threw an exception that is not aborting / go and mark all docs from this block as deleted" (DocumentsWriterPerThread.java:350-353); #16270 aligned it with updateDocuments; upstream test*ThrowMidBatchRecovers (TestColumnBatchIndexing.java:682, :740, :794, :841) throw from cursors and assert the writer recovers. addDocument's javadoc: after an exception "the index will be consistent" (IndexWriter.java:1465).
  • The rule in the same code. setInvertState: "Even if no documents actually succeed in setting a norm, we still write norms for this segment" (IndexingChain.java:1912-1913). The broken segment does have norms for body; only term vectors lack the guarantee, and only on the batch path.
  • Precedent. #13369 (9.11.0, closing #13353): "Fix NRT opening failure when soft deletes are enabled and the document fails to index before a point field is written", the same FieldInfo-without-file state for points, fixed as a bug.
  • Untested corner. No upstream test combines term vectors with column batches (none of the 8 test files that use ColumnBatch). The #15990 review never mentions term vectors.
The devil's-advocate check201 words

The strongest case for "intended" (a cross-model reviewer rated it moderate): Lucene deliberately avoids term-vector files until a document uses them. LUCENE-2776 (2010) stopped creating the term-vector writer at flush when no document had used it, and at first let the reader tolerate exactly this state: "If all documents flushed in a segment had hit non-aborting exceptions, it's possible that FieldInfos.hasVectors returns true yet the term vector files don't exist." The API is @lucene.experimental, an empty batch with columns is odd, and a failing cursor is the caller's code.

Why it fails: LUCENE-2811 removed that reader tolerance 17 days later ("it should only be opened if they exist"), and today a missing term-vector file is corruption. Since then the per-document path has kept the invariant by finishing every document it starts, including rejected ones. Empty batches and non-aborting batch failures are accepted and tested upstream. @lucene.experimental allows API changes, not a commit that cannot be opened. Before LUCENE-2776, flush created the writer for exactly this case ("all documents seen in this segment hit non-aborting exceptions"); fix option A restores that guard, keyed on hasVectors. Verdict: a defect (the independent reproducer, cross-model reviews, the devil's advocate and the adjudication agree).

Suggested fix

Recommended A. TermVectorsConsumer.flush creates the writer when term vectors are declared (validated)

Recommended and validated: in TermVectorsConsumer.flush, create the term-vector writer when FieldInfos declare term vectors but no document created it, so flush writes an empty entry per document (5 lines). It fixes all three triggers; the repro and 305 tests in 18 related classes pass with assertions on and off.

// TermVectorsConsumer.flush, before "if (writer != null) {"
if (writer == null && hasVectors) {
  // A field with term vectors was registered (e.g. by an empty or rejected column batch) but no
  // document finished afterwards: still write (empty) term vectors, FieldInfos declare them.
  initTermVectorsWriter();
}
All options and trade-offs5 options ยท 600 words

Option A was applied in a scratch worktree of a9779ef3c193 and run; options B and D are design alternatives that were not run; C is not sufficient on its own.

A. TermVectorsConsumer.flush creates the writer when term vectors are declared (validated) (recommended)

If hasVectors is set and no document created the writer, create it in flush; the existing fill(numDocs) then writes an empty entry for every document. This is what the per-document path already writes after a rejected document. With an index sort, SortingTermVectorsConsumer.flush calls super.flush and overrides initTermVectorsWriter, so the same lines create its temporary writer.

// TermVectorsConsumer.flush, before "if (writer != null) {"
if (writer == null && hasVectors) {
  // A field with term vectors was registered (e.g. by an empty or rejected column batch) but no
  // document finished afterwards: still write (empty) term vectors, FieldInfos declare them.
  initTermVectorsWriter();
}

Trade-offs

  • A segment that registered a term-vector field but has no document with term vectors now gets small term-vector files with empty entries, instead of none. Format and reader are unchanged.
  • flush's assert numDocs > 0 still holds: a thread state with no documents is never flushed (guards at DocumentsWriterFlushControl.java:367, :584; asserts at DocumentsWriterPerThread.java:428, :444).
  • Open draft PR #16269 refactors TermVectorsConsumer and keeps the writer != null guard; a patch may need a rebase if it lands first.

What the checks validated

  • Scratch worktree of a9779ef3c193 (removed afterwards), gradle, seed DEADBEEF, assertions on and off. ./gradlew :lucene:core:tidy leaves the patch unchanged.
  • The repro (3/3) and the independent reproducer's 6 tests and 4 controls pass: 13/13 with assertions on and off (stock: 9 of 13 fail).
  • 18 test classes, 305 tests, 0 failures, 3 skipped, with assertions on and off: TestTermVectors, TestTermVectorsReader, TestTermVectorsWriter, TestColumnBatchIndexing, the 6 classes of org.apache.lucene.document.column, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestCheckIndex, TestLucene90TermVectorsFormat, TestCompressingTermVectorsFormat and this bug's 3 test classes (the repro, the independent reproducer's tests and its controls).
  • Extra check (one document, an empty term-vector batch, commit; then 1 document, termVectors().get(0) == null and a clean CheckIndex; once plain and once with an index sort, which uses SortingTermVectorsConsumer): stock 2 of 2 fail, option A 2 of 2 pass with assertions on and off.
  • Earlier hunt runs: the finder's, the independent reproduction's and separate tests and the 5-test security probe pass with it; shadowed over 10.5.0, 10.5.2 and the commit-built jar it passes the three scenarios.

B. Create the writer when the flag is set (not run)

Call initTermVectorsWriter() from setHasVectors(), or from processBatch right after initAndValidateFields when a term-vector field was registered.

Trade-offs

setInvertState() does not throw IOException today, so it needs plumbing; the batch-only variant fixes this caller and leaves the invariant implicit for the next one; term-vector outputs open at field registration instead of at flush.

C. Return early from processBatch when numDocs() == 0 (not sufficient)

Skips the empty batch before any field is registered.

Trade-offs

Fixes trigger (a) only: the rejected-batch and failing-cursor triggers stay broken. Only useful combined with A or B.

D. Do not leave FieldInfos behind from a batch that indexes no row (not run)

Create FieldInfos only once the row pass starts, or roll them back when the batch fails.

Trade-offs

A much larger change to processBatch, whose column pass needs the per-field state; field numbers are shared index-wide; and it would differ from the per-document path, which keeps the FieldInfo of a rejected document.

Rejected: let the reader tolerate missing term-vector files

What LUCENE-2776 did in 2010.

Trade-offs

LUCENE-2811 removed that tolerance; it would hide real corruption.

Downstream note

Not a security issue (the application builds the batch), but a service that forwards client-supplied batches to addBatch can turn one empty or malformed batch into a commit that no reader can open.

How it was found

A bounded-exhaustive harness indexed every small single-column batch shape both through addBatch and row by row and compared the two indexes: 29,408 scenarios, 32 of which left an index that could not be opened, all with a term-vector column, zero rows and one document before the batch. The other two triggers came from reading the code.

The method in full185 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier) on the real jar, a hunt unit (columnar addBatch).

  • Scope A: one column, 26 column specs (long, binary, dictionary, float and byte vector and token-stream columns; stored, inverted with every index option and term vectors with positions, offsets and payloads, every doc-values type, skip index, 1-D and 2-D points), dense and every sparse pattern for numDocs 0 to 3, a document before and/or after the batch, 4 writer configurations (default, index sort, parent field, soft deletes). Each scenario was indexed through addBatch and through the row-by-row reference, and the indexes were compared; with assertions on and off.
  • Result: 29,408 scenarios; 32 corrupt indexes on the batch side, exactly the term-vector specs with numDocs = 0 and one document before the batch. A mutant (a +1 on one value) was caught (256 mismatches), so the comparison is not vacuous.
  • The finder minimised it by hand into a JUnit; the rejected-batch and failing-cursor triggers came from reading processBatch afterwards. An independent reproducer, cross-model reviews (OpenAI GPT-5.6) and an intended-behaviour devil's advocate checked it before the adjudication confirmed it.

Prior reports, history and checks

Prior reports

No prior report: 5 search-tool runs (GitHub, commits, downstream repositories, four mailing lists, the JIRA archive, the web), 72 manual GitHub searches, 6 JIRA queries and the files of all 412 open PRs, 2026-10-25/26. Closest: #13369 (the points analogue) and LUCENE-2776 (2010 background).

Searches6 searches

No upstream issue, PR, commit, list thread, JIRA entry or downstream report describes this bug. Related items are listed below; none of them is this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), via the search tool2026-10-25/265 runs: 90 issue/PR, 12 commit and 8 downstream-repository queries (class and method names, symptom phrasing such as "No sub-file with id .tvd", batch-failure wording)no report; column-API PRs (#15990, #16066, #16115, #16117, #16168, #16219, #16270) and old term-vector issues
Manual read-only GitHub searches2026-10-25/2653 by the follow-up (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr, luceneutil) plus 15 by a verifier and 4 by the adjudicationno report; Elasticsearch's columnar indexing PRs call addBatch without term vectors
Open PRs2026-10-26the changed-file lists of all 412 open PRsdraft #16269 (TermVectorsConsumer refactor, keeps the writer != null guard, no behaviour change) and draft #16116 (processBatch); neither fixes it
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-25/2680 queries; the 9-mail dev@ design thread of the column API read in fullno report; term vectors appear only as future work
JIRA archive2026-10-25/2616 tool queries and 6 manual JQL queries; LUCENE-2776, LUCENE-2811 and LUCENE-2984 read in full2010-2011 history of the lazy writer (background, not this bug)
Git history2026-10-26TermVectorsConsumer.java and IndexingChain.java on main, branch_10x, branch_10_6 and branch_10_5no fix of this path

History

Introduced with the column API itself, #15990 (3d89568155799, merged 2026-05-14) and its backport #16066; first released in 10.5.0. Affects 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main; not a regression.

Versions tested6 rows

Introduced by #15990, "Add experimental columnar indexing api" (author @Tim-Brooks, merged by @ChrisHegarty on 2026-05-14 as 3d89568155799 on main), backported the same day by #16066 (3ec3bff01b15, branch_10x), which ships in 10.5.0. It combines two older pieces that were safe on their own: the flush that writes term vectors only when a document created the writer (LUCENE-2776, 2010; its if (writer != null) form from the LUCENE-2621 rewrite, 2011) and the setHasVectors() call at FieldInfo creation (LUCENE-9334, first released in 9.0.0). #15990 brought back a state the current per-document path no longer reaches: FieldInfos created without a finished document. On main, git blame of the up-front field creation points at the refactor #16219; the bug is older and reproduces at 3d89568155799. branch_10_6 and branch_10x have the same code (compared by content), so 10.6.0, released 2026-10-29, is affected.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected3 of 3 fail with assertions on and off (gradle and the repro harness)
main ea57360912ed affected3 of 3 fail with assertions on and off
10.5.0, 10.5.1, 10.5.2 affected3 of 3 fail (release jars; 10.5.0 and 10.5.2 also with assertions off)
3d89568155799 (#15990) introducing commita jar built from the commit fails all three scenarios (a main() form, assertions on and off)
10.4.0, 9.12.3 no column APInot applicable: the repro does not compile; the same rejection through addDocument works
option A on a9779ef3c193, 10.5.0, 10.5.2 and 3d89568155799 fixall three scenarios pass

Independent checks

Confirmed by an adjudication (high confidence) after an independent reproducer with its own test, cross-model reviews (OpenAI GPT-5.6) that ran their own tests, and an intended-behaviour devil's advocate; this doc re-ran it through gradle on main and validated the fix.

Checks and verdicts242 words

Every check reproduced it; nobody dissented.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn JUnit written from the claim only: 6 of 6 fail (3 triggers x 2 configurations) on main, 10.5.2 and 10.5.0, assertions on and off; 60 of 60 over 10 iterations; 4 controls pass; CheckIndex shows the segment declaring term vectors with no term-vector files; its own fix passes.
Refute review (OpenAI GPT-5.6) real, highIts own empty-batch test without compound files fails with assertions on and off and with a second seed; its one-row and later-addDocument controls pass; the fix passes its test and TestTermVectorsWriter.
Intended-lens review (OpenAI GPT-5.6) real, highRated the LUCENE-2776 argument moderate, then rejected it; its own test fails with the default and the SimpleText codec; the fix passes all three triggers.
Intended-behaviour devil's advocate realNo intended argument survives; its default-config test fails 2 of 2, including a sequence of column batches only; its per-document control passes.
Adjudication confirmed, highRe-ran the independent reproduction's test with new seeds (6 of 6 fail, assertions on and off), the independent reproduction's controls (4 of 4 pass) and the finder's repro (3 of 3 fail); the fix passes.
This doc's gradle run reproduces; fix validatedStock main a9779ef3c193: 13 run, 9 fail (the 4 controls pass), assertions on and off; option A: 13 of 13 pass, and 18 classes (305 tests) 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,196 words

Finding record

LFV-096, snapshot of 2026-11-06

Registry title
An addBatch that registers a new term-vector field but frames no rows leaves a segment whose FieldInfos declare term vectors with no term-vector files, so the committed index cannot be opened
Area
index / columnar addBatch (term vectors)
Priority
Medium, rank 152: An empty, rejected or failing addBatch that declares a new term-vector field can leave a commit that DirectoryReader.open rejects, catastrophic but loud and an unusual edge sequence on an experimental API.
Impact class
index unusable
Impact note
Applications on the experimental column API (IndexWriter#addBatch, 10.5.0 and later) that send a batch which declares a term-vector field new to the in-memory segment but frames no row (numDocs()==0, a batch rejected by the schema check of a later column, or a column cursor that throws before the first row) while that segment holds another live document and no later document is framed in it before the flush; default IndexWriterConfig and codec, ordinary scale, deterministic; then DirectoryReader.open fails for the whole index, or (default config, rejected-batch and failing-cursor triggers) commit() and close() keep throwing CorruptIndexException; released 10.5.0-10.5.2, and 10.6.0 by reading.
Summary
You add a document, then call IndexWriter#addBatch with a column batch that declares a new text field with term vectors but adds no row: the batch is empty, or it is rejected with IllegalArgumentException because a later column conflicts with the existing schema, or its column cursor throws before the first row. You expect the earlier document to commit and the index to open, as it does when the same rejection happens through addDocument. Instead the segment's FieldInfos say the field has term vectors but no term-vector files were written, so DirectoryReader.open on the commit throws CorruptIndexException ('No sub-file with id .tvd found in compound file'), or, with the default IndexWriterConfig, commit() itself throws.
Introduced
3d89568155799 (#15990 'Add experimental columnar indexing api', main), released through its branch_10x backport 3ec3bff01b15 (#16066) [exec: a lucene-core jar built from 3d89568155799 fails all three scenarios with -ea and -da and passes with the fix; read: git show, git log --all --diff-filter=A on ColumnBatch.java, git tag --contains]; not a regression: the defect is in the column API's first version. It combines two older pieces that were safe on their own: the lazily created term-vectors writer with the 'if (writer != null)' flush guard (873f19992445, LUCENE-2621, 2011) and the setHasVectors() call made when a term-vector FieldInfo is created (d03662c48bfc, LUCENE-9334, first released in 9.0.0) [read] (2026-05-14 (#15990 merged into main; backport #16066 merged into branch_10x the same day))
First affected version
10.5.0 [exec]
Affected versions
10.5.0, 10.5.1, 10.5.2 and main ea57360912ed [exec; 10.5.0, 10.5.2 and main also with -da], and the introducing commit 3d89568155799 [exec, main() form, -ea and -da]; not applicable to 10.4.0 and earlier, including 9.12.3 (no column API, the repro does not compile) [exec]; branch_10_6 (10.6.0, released 2026-10-29) and branch_10x have the same TermVectorsConsumer blob 832f0275fa9 and the same processBatch structure [read, heads of 2026-10-24; at the 10.6.0 tag 4ae946e5a9b the same blob and an identical processBatch, read 2026-10-31], so 10.6.0 (released 2026-10-29) is affected [read]; not fixed upstream as of main ea57360912ed [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (2026-10-25/26: a scripted prior-report search, 5 runs (the finder's, the intended-behaviour check's lists and JIRA run, and follow-up runs A-C) with 90 GitHub issue/PR, 12 commit, 8 downstream-repo, 80 mailing-list (dev@, java-user@, issues@, commits@), 16 JIRA-archive and 19 web queries; 53 manual read-only gh searches over apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr and luceneutil, plus the intended-behaviour check's 15 and the adjudication's 4; 6 manual JQL queries; commit history of TermVectorsConsumer.java and IndexingChain.java on main, branch_10x, branch_10_6 and branch_10_5; the changed-file lists of all 412 open PRs; the #15990 review thread and the 9-mail dev@ design thread read in full); related prior items, none of them this bug: #15990 (introduced it; its review never mentions term vectors), #16066 (its backport, ships in 10.5.0), #13353 and #13369 (the points precedent: the same FieldInfo-without-file invariant, fixed as a bug in 2024), #3850 (LUCENE-2776, 2010: made the writer lazy and briefly let the reader tolerate this state for the old per-document code) and #3885 (LUCENE-2811, removed that tolerance), #16553 (nearest fixed bug in the same code: stored-fields framing; in 10.5.2, which still fails), open PR #16269 (refactors TermVectorsConsumer, keeps the writer != null guard, no behaviour change; read from its diff only), open draft #16116 (touches processBatch, does not change this case)
Lucene commit tested
main a9779ef3c193 (2026-10-26): the 3-test JUnit, an independent reproducer's 6-test JUnit and its 4 controls through gradle with assertions on and off, stock (9 of 13 fail, the 4 controls pass) and with fix option A (13 of 13 pass); 18 test classes (305 tests) with option A, assertions on and off; an extra check of option A with CheckIndex and an index sort; the repro harness on a9779ef3c193, ea57360912ed, 10.5.0, 10.5.1, 10.5.2 and a jar built from the introducing commit 3d89568155799; a step-by-step probe of the in-memory state on a9779ef3c193. 10.4.0 and 9.12.3 are not applicable (no column API).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:715-716 (processBatch), :720-778 (first loop: column-type validation), :780-783 (initAndValidateFields before the row pass), :789-792 (row pass), :873 (first cursor nextDoc() before the row loop), :887-894 (row loop frames documents with termsHash.startDocument), :922-938 (finally: termsHash.finishDocument), :1850-1864 (setInvertState: norms comment at :1857-1858, setHasVectors at :1861-1863)
  • lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumer.java:72-90 (flush writes only if writer != null), :95-101 (fill), :103-110 (initTermVectorsWriter), :112-114 (setHasVectors sets a flag), :117-128 (finishDocument creates the writer)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCoreReaders.java:120-125 (term-vectors reader opened whenever FieldInfos declare term vectors), :141-144 (missing file -> CorruptIndexException)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:226-230 (reserveDocs returns early for n == 0), :314-358 (updateBatch; :350-353 marks the reserved docs deleted after a non-aborting exception)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1528-1539 (addBatch javadoc, @lucene.experimental at :1535)
  • lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java:885 (testEmptyBatch), :681, :739, :793, :840 (test*ThrowMidBatchRecovers)
  • Lucene git history (read-only, 2026-10-31): 4ae946e5a9b:lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumer.java is blob 832f0275fa9, as on main ea57360912ed; IndexingChain.processBatch is identical at 4ae946e5a9b and ea57360912ed
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java at a9779ef3c193 (:583-682, :685-699, :717-794, :862-944, :1372-1418, :1905-1919)
  • lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumer.java at a9779ef3c193 (:72-128)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCoreReaders.java at a9779ef3c193 (:120-144)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java at a9779ef3c193 (:226-230, :314-359)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java at a9779ef3c193 (:1528-1590)
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java at a9779ef3c193 (:82, :598) and IndexWriterConfig.java (:102, :108)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java at a9779ef3c193 (:1465, :4189-4190)
  • lucene/core/src/java/org/apache/lucene/document/column/ColumnBatch.java at a9779ef3c193 (:28-31)
  • lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java at a9779ef3c193 (:682, :740, :794, :841, :924)
  • lucene/CHANGES.txt at a9779ef3c193 (:2911-2912)
  • git history: commit 57a7862f829 (LUCENE-2776, 2010-11-26; TermVectorsTermsWriter.flush) and 1ca059706ee (LUCENE-2811, 2010-12-13)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SortingTermVectorsConsumer.java at a9779ef3c193 (:53-94)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java at a9779ef3c193 (:428, :444)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java at a9779ef3c193 (:367, :584)
History
  • lucene/CHANGES.txt at a9779ef3c193 (:911, 10.5.0 API Changes)

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

Repro files

About the diagrams

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.