All findings · Lucene finding · LFV-094 · sortedset-dictionary-ordinal-rejected-after-doc-opened

A SORTED_SET DictionaryColumn tuple that addBatch rejects leaves an empty document behind, which can make the next commit fail and close the IndexWriter

If the experimental IndexWriter#addBatch rejects a document's first tuple in a SORTED_SET DictionaryColumn, the writer looks healthy but keeps an empty document. The next commit can fail and close the writer, losing uncommitted documents, or, with assertions off, write a segment that fails CheckIndex or cannot be opened.

Summary

Priority
Medium, rank 153A rejected SORTED_SET DictionaryColumn tuple in addBatch looks clean but makes the next flush close the writer and lose uncommitted docs, or with -da commit an unreadable field, an error path of the experimental addBatch API.
Impact
data corruption
Since
10.5.0 (#16101, 2026)introduced 2026-05-26
Affected
10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
semi-formal: a Z3 model and a failure-injection sweep of the addBatch doc-values writers

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

addOrdinalTuples opens the new document before it reads and checks the ordinal. If a document's first tuple is rejected, that document stays open with 0 values, and the next value for the field, from a later document, records it with a value count of 0. The flush code does not allow a count of 0.

Summary card: Lucene addBatch, a rejected SORTED_SET ordinal leaves an empty document, and the next commit can close the writer. The example: a batch whose cursor yields one tuple, doc 0 with ordinal 5, is rejected with an IllegalArgumentException while the writer stays open; then a document with tags p and q is added and committed. Inside, the doc-values writer opens doc 0 before it checks the ordinal; doc 0 is marked deleted but stays open; doc 1's first value closes doc 0 with a count of 0; at commit the assert ordCount > 0 fails (-ea) or an empty array is read (-da); the flush error is aborting, and the writer closes. Expected: the commit succeeds and doc 1 has tags p and q. Actual: commit() throws, isOpen() is false, and uncommitted documents are lost. With -da and an earlier tags value, the commit succeeds instead but keeps the empty document, and CheckIndex or opening the field can then fail. Fix sketch: switch documents only after the ordinal check, as addValue does.
A rejected batch, then a valid document: the doc-values writer opens doc 0 before it checks ordinal 5, doc 1's first value closes doc 0 with 0 values, and the commit fails and closes the writer. Select the still to open it at full size. Generated with AI, checked against the code.
  1. With the experimental IndexWriter#addBatch, add a one-document batch whose SORTED_SET DictionaryColumn "tags" has a 2-entry dictionary [x, y] and one tuple: doc 0, ordinal 5. One document is already committed and 5 more are buffered.
  2. Lucene rejects the batch: IllegalArgumentException: ordinal 5 is out of range [0, 2). The writer stays open with no tragic exception, as after any rejected document. You expect it to leave only consistent state behind (at most a deleted document).
  3. Add a valid document with tags p and q, then call commit(). You expect the commit to succeed, with 7 documents in the index.
  4. Instead commit() throws (AssertionError with assertions on, ArrayIndexOutOfBoundsException with them off, because no earlier document in this segment has tags), and the writer closes itself. The reopened index has 1 of 7 documents. The doc-values writer had opened the batch's document before it checked the ordinal, so the flush met a document with 0 values.
  5. With assertions off, when an earlier document in the segment already has a tags value, the commit succeeds instead but keeps the empty document. Example: a 1-value document, the rejected batch, then a 2-value document; opening tags throws NullPointerException. Other layouts fail CheckIndex, or show nothing. A cursor whose ordValue() throws, with every ordinal valid, does the same as a bad ordinal.

Who is affected

Users of the experimental IndexWriter#addBatch with a doc-values-only SORTED_SET DictionaryColumn, when a document's first tuple is rejected or throws and they keep indexing. With assertions on, and with them off only if that document comes first in its segment, the writer closes and loses uncommitted documents. Otherwise the commit can leave a field that fails CheckIndex or cannot open.

Mechanism, terms and numbers900 words

IndexWriter#addBatch (experimental, since 10.5.0) indexes documents column by column. For a SORTED_SET DictionaryColumn, Lucene range-checks the ordinal of every (docID, ordinal) tuple. It rejects an ordinal outside the dictionary with IllegalArgumentException, treats that as a non-aborting exception (the batch's documents are marked deleted) and leaves the writer open.

But SortedSetDocValuesWriter.addOrdinalTuples switches to the new document before it reads and checks the ordinal. When the rejected tuple is the first one of a document, that document stays open with no values, and the next value for the field, from a later document, records it with a value count of 0.

With assertions on, the next commit always fails (AssertionError). With them off it can fail only when the rejected document is the segment's first with the field, as in the repro, where it fails with ArrayIndexOutOfBoundsException. A failed flush is a tragic event, so the writer closes and every uncommitted document is lost: 1 of 7 survives in the repro. Otherwise, with assertions off, the commit succeeds but keeps the empty document: the field then throws NullPointerException on open or fails CheckIndex, or, when no document has more than one value, shows nothing. A cursor whose ordValue() throws on a document's first tuple, with every ordinal valid, does the same.

The single-valued SORTED path validates first. The fix is to switch documents after the check, as addValue already does.

Terms

  • addBatch: IndexWriter#addBatch(ColumnBatch) (experimental, since 10.5.0) adds N documents given as columns, one field at a time.
  • DictionaryColumn: a column of string values given as a fixed dictionary plus, per document, ordinals into it. For SORTED_SET doc values its OrdinalsTupleCursor yields (docID, ordinal) pairs; a document with 3 values yields 3 pairs with the same docID.
  • The doc-values writer (SortedSetDocValuesWriter) buffers one in-memory segment's values. It keeps one open document (currentDoc) and its values. When a value for another document arrives, finishCurrentDoc() records the open one: its value count and its doc ID in docsWithField.
  • Non-aborting exception: an exception that rejects the document(s) being added but keeps the writer and its buffered documents. Lucene marks the rejected documents deleted. An aborting exception, such as any failure during a flush, is tragic: the writer rolls back to the last commit and closes.

The bug

  1. Every SORTED_SET DictionaryColumn, dense or sparse, goes to SortedSetDocValuesWriter.addOrdinalTuples (IndexingChain.java:1244-1248). Only sparse columns were executed. A column that is also stored or indexed reads every ordinal in the row pass first (ColumnFieldAdapter.java:79-81), so an out-of-range ordinal fails there and never reaches this loop: doc-values-only columns are the exposed ones.
  2. For each tuple the loop first switches documents: if (docID != currentDoc) { finishCurrentDoc(); currentDoc = docID; } (SortedSetDocValuesWriter.java:126-129). Only then does it call cursor.ordValue() (:130) and range-check the ordinal (:131-140).
  3. When the first tuple of a document is rejected, or ordValue() throws, currentDoc already names the new document, which has 0 values.
  4. The exception is non-aborting. DocumentsWriterPerThread.updateBatch only marks the batch's documents deleted (:349-354), and the writer's currentDoc stays where it is.
  5. The next value for the field, from any later document, calls finishCurrentDoc(). That records the rejected document with a count of 0 (:162-193).
  6. At flush, BufferedSortedSetDocValues.nextDoc asserts ordCount > 0 (:360), so with assertions on every layout fails. With assertions off, the Lucene90 consumer reads a value that is not there (Lucene90DocValuesConsumer.java:403, 1005), but only when the empty document is the first with the field in the segment; otherwise the flush succeeds (see the numbers below).
  7. Any exception during a flush is aborting (DocumentsWriterPerThread.java:585-587, 594-604), so a failed flush closes the writer.

addValue, the per-document and BinaryColumn path into the same writer, validates first and switches after (:84-105). The single-valued SortedDocValuesWriter.addOrdinalTuples also records the document only after it translates the ordinal (SortedDocValuesWriter.java:139-142).

The numbers

Default IndexWriterConfig and codec. 1 committed document, 5 buffered, a rejected 1-document batch (dictionary [x, y], ordinal 5), then a document with tags p, q:

  • commit() throws AssertionError (-ea) or ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 (-da);
  • isOpen() is false;
  • the reopened index has 1 of 7 documents.

With -da and NoMergePolicy, take a 1-value document, the rejected batch, then a 2-value document. The commit succeeds and keeps the 0-value document. The writer has written the field as multi-valued, but omitted the ord addresses, because 3 values = 3 documents (Lucene90DocValuesConsumer.java:924-933). Opening the field then throws NullPointerException: Cannot read field "numBlocks" because "meta" is null (Lucene90DocValuesProducer.java:2303). With 3 values in the last document, CheckIndex fails instead ("docValueCount=0 for docID=1"); an integrity-level CheckIndex also fails for [2] | rejected | [2] and [1] | rejected | [2, 2, 2]. With 1 value in every document nothing is visible: the field is written as single-valued, and the deleted document gets a stale copy of the previous document's ordinal (SortedSetSelector.java:165-168). So -da does not always fail. The hunt's sweep read back the live data of 589 of 605 -da layouts exactly, and the other 16, its cells where the rejected document came first, failed at commit; its CheckIndex ran without a level set and skipped the doc-values check, so the 589 do not show a clean index.

Exposure and likelihood280 words
  • Who is hit.
    • Applications that call IndexWriter#addBatch with a SORTED_SET DictionaryColumn that only feeds doc values, when the first tuple of some document is rejected (an ordinal outside the dictionary) or its ordValue() throws, and that catch the exception and keep indexing.
    • Default IndexWriterConfig and codec; dense and sparse columns take the same path (only sparse ones were executed). A column that is also stored or indexed reads every ordinal in the row pass first, so an out-of-range ordinal fails there instead.
    • SORTED DictionaryColumns and BinaryColumns that feed SORTED_SET are not affected, nor is a bad tuple after a valid tuple of the same document.
  • Why it bites.
    • The rejection looks clean: isOpen() is true and getTragicException() is null.
    • The damage shows up at the next flush or commit. Its exception, an AssertionError or ArrayIndexOutOfBoundsException inside the codec, does not point back at the batch.
    • The rollback discards every document since the last commit, from every thread that shares the writer.
  • Persisted damage with -da, the production default. When an earlier document in the segment already has the field, the commit succeeds and keeps the 0-value document.
    • Some layouts then commit a segment whose SORTED_SET field cannot be opened, sorted on or merged. An independent probe saw a later writer's forceMerge(1) fail on it and report an unrecoverable error.
    • Other layouts fail CheckIndex (integrity level); when no document has more than one value, nothing is visible.
  • How likely. Rare: an opt-in experimental API, an error path, and no known downstream user. A read-only code search of Elasticsearch, OpenSearch and Solr found no SORTED_SET DictionaryColumn builder; Elasticsearch uses the column package through BinaryColumn, which is not affected.

Minimal repro

The test shows that after IndexWriter#addBatch rejects a SORTED_SET DictionaryColumn whose only tuple has an out-of-range ordinal, committing a later ordinary document with two SORTED_SET values fails.

TestRejectedSortedSetOrdinalBreaksCommit.java (JUnit test)88 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.SortedSetDocValuesField;
import org.apache.lucene.document.column.Column;
import org.apache.lucene.document.column.ColumnBatch;
import org.apache.lucene.document.column.DictionaryColumn;
import org.apache.lucene.document.column.OrdinalsTupleCursor;
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 TestRejectedSortedSetOrdinalBreaksCommit extends LuceneTestCase {

  public void testCommitAfterRejectedOrdinal() throws Exception {
    DictionaryColumn column =
        new DictionaryColumn(
            "tags",
            SortedSetDocValuesField.TYPE,
            Column.Density.SPARSE,
            List.of(new BytesRef("x"))) {
          @Override
          public OrdinalsTupleCursor tuples() {
            return new OrdinalsTupleCursor() {
              int doc = -1;

              @Override
              public int nextDoc() {
                doc = doc == -1 ? 0 : DocIdSetIterator.NO_MORE_DOCS;
                return doc;
              }

              @Override
              public int ordValue() {
                return 1;
              }
            };
          }
        };
    ColumnBatch batch =
        new ColumnBatch() {
          @Override
          public int numDocs() {
            return 1;
          }

          @Override
          public Iterable<Column> columns() {
            return List.of(column);
          }
        };
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      // ordinal 1 is out of range: expected a rejection that leaves no trace; actual: commit fails
      expectThrows(IllegalArgumentException.class, () -> w.addBatch(batch));
      Document doc = new Document();
      doc.add(new SortedSetDocValuesField("tags", new BytesRef("p")));
      doc.add(new SortedSetDocValuesField("tags", new BytesRef("q")));
      w.addDocument(doc);
      try {
        w.commit();
      } catch (Throwable t) {
        throw new AssertionError("expected commit to succeed after the rejection, got " + t, t);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-094/, 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.TestRejectedSortedSetOrdinalBreaksCommit
Expected vs actual
Expected: the commit succeeds (the rejected batch leaves no trace); actual: the rejected document was already opened and is flushed with zero values, so commit throws (AssertionError ordCount > 0 with -ea, ArrayIndexOutOfBoundsException with -da).

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

More files, environment, full output45 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
    => java.lang.AssertionError: expected commit to succeed after the rejection, got java.lang.AssertionError
       org.apache.lucene.index.TestRejectedSortedSetOrdinalBreaksCommit.testCommitAfterRejectedOrdinal(TestRejectedSortedSetOrdinalBreaksCommit.java:84)
     Caused by: java.lang.AssertionError
       org.apache.lucene.index.SortedSetDocValuesWriter$BufferedSortedSetDocValues.nextDoc(SortedSetDocValuesWriter.java:360)
       org.apache.lucene.codecs.lucene90.Lucene90DocValuesConsumer.isSingleValued(Lucene90DocValuesConsumer.java:961)
       org.apache.lucene.codecs.lucene90.Lucene90DocValuesConsumer.addSortedSetField(Lucene90DocValuesConsumer.java:977)
       org.apache.lucene.codecs.perfield.PerFieldDocValuesFormat$FieldsWriter.addSortedSetField(PerFieldDocValuesFormat.java:127)
       org.apache.lucene.index.SortedSetDocValuesWriter.flush(SortedSetDocValuesWriter.java:301)
       [...]

Why it looks unintended

  • Lucene itself checks the ordinal and rejects it as a non-aborting IllegalArgumentException, leaving the writer open and usable.
  • Non-aborting exceptions must leave consistent state (the DocumentsWriter design note); upstream tests require indexing to continue after a cursor throws mid-batch.
  • The SORTED version of the same test passes, and a cursor that throws with valid ordinals hits the same bug.
The full argument232 words
  • Lucene validates this input on purpose.
    • The range check is in the code (SortedSetDocValuesWriter.java:131-140).
    • The dense cursor documents it: "The indexing path validates this on every call and throws IllegalArgumentException on violation" (OrdinalsCursor.java:50-52).
    • TestColumnBatchDictionaryColumn#testOutOfRangeOrdSparseThrows asserts the rejection on the sparse tuple path, but only for SORTED (:391-407). Re-typed to SORTED_SET, its close() fails.
  • The design note. Non-aborting exceptions "can still partially update the index structures. These updates are consistent, ... we immediately mark the document as deleted so that the document is always atomically ("all or none") added to the index" (DocumentsWriter.java:76-79). addDocument promises that after an Exception "the index will be consistent" (IndexWriter.java:1465); addBatch's short javadoc (:1528-1536) does not restate it.
  • Recovery tests. The four TestColumnBatchIndexing#*ThrowMidBatchRecovers tests (:682, 740, 794, 841) commit and keep indexing after a cursor throws mid-batch.
  • Siblings validate first.
    • SortedSetDocValuesWriter.addValue (:84-105).
    • SortedDocValuesWriter.addOrdinalTuples (:123-146), added by the same commit.
    • That commit (#16101) also gave SortedDocValuesWriter.addDenseOrdinalValues a try/finally that keeps the writer consistent when the cursor throws (:169-182).
  • History.
    • #16101 has one approving review and no discussion of the order.
    • #16553 (2026-09-01) fixed the same class of defect in stored fields: a non-aborting exception left per-document state, and the next flush failed.
    • In #16734, a reviewer asked that the dense vector path record doc IDs only after validation.
The devil's-advocate check169 words

The strongest case for "intended". OrdinalsTupleCursor says only that each ordinal "must be in" range (OrdinalsTupleCursor.java:26, 46-47), without the dense cursor's "validates and throws" sentence. addBatch and the cursor are @lucene.experimental. So, the argument goes, the caller broke a precondition, and what follows is undefined. An independent reviewer from another model family (OpenAI GPT-5.6) rated this "moderate" and still judged the bug real (high).

Why it fails.

  • Lucene does not treat the ordinal as undefined behaviour: it checks it, throws a non-aborting exception and keeps the writer open.
  • A cursor whose ordValue() throws with every ordinal valid breaks no precondition, and hits the same defect.
  • The SORTED version of the same test passes.
  • No javadoc, CHANGES or MIGRATE entry says that a failed addBatch needs rollback().

One honest limit: the upstream recovery tests throw from nextDoc() or the dense fill, not from ordValue(), and none uses a DictionaryColumn. So the ordValue() case rests on the general non-aborting contract, not on a word-for-word test.

Suggested fix

Recommended A. Switch documents only after ordValue() and the range check (recommended)

In addOrdinalTuples, read and range-check the ordinal first, and switch documents only after that, as addValue does (+6/-4 lines, the same statements reordered). With it, the new test passes 6 of 6, and 11 test classes, including the new one, pass (254 tests, 0 failures), with assertions on and off.

      assert docID >= currentDoc;
      // Read and validate the ordinal before switching documents, like addValue: if ordValue()
      // throws or the ordinal is rejected, no document may be left open without values.
      int ord = cursor.ordValue();
      if (ord < 0 || ord >= dictionary.size()) {
        throw new IllegalArgumentException(/* ... is out of range [0, n) */);
      }
      if (docID != currentDoc) {
        finishCurrentDoc();
        currentDoc = docID;
      }
All options and trade-offs3 options ยท 579 words

Option A is the recommended fix and was run through gradle. B is a design alternative that was not run. C is a safety net, run against the new test only.

A. Switch documents only after ordValue() and the range check (recommended) (validated by the checks)

Move if (docID != currentDoc) { finishCurrentDoc(); currentDoc = docID; } below the range check in SortedSetDocValuesWriter.addOrdinalTuples. A rejected or throwing first tuple then leaves currentDoc on the previous, complete document. A rejected later tuple leaves the open document with at least one value: a consistent, deleted document.

      assert docID >= currentDoc;
      // Read and validate the ordinal before switching documents, like addValue: if ordValue()
      // throws or the ordinal is rejected, no document may be left open without values.
      int ord = cursor.ordValue();
      if (ord < 0 || ord >= dictionary.size()) {
        throw new IllegalArgumentException(/* ... is out of range [0, n) */);
      }
      if (docID != currentDoc) {
        finishCurrentDoc();
        currentDoc = docID;
      }

Trade-offs

  • No extra work per tuple; the same statements in a different order.
  • It covers both triggers, the rejected ordinal and a throwing ordValue(). After the switch the loop still calls dictionary.get(ord), hash.add and ArrayUtil.grow. For a dictionary that the DictionaryColumn constructor checked and that is not changed later, these fail only at hard size limits (ArithmeticException at the byte pool's 2 GB limit, IllegalArgumentException beyond the maximum array length). Those are non-aborting too and could still leave a document open; option C covers that case.
  • It does not add the missing tuple doc-ID checks of a separate bug in the same loop, which needs its own change.

What the checks validated

  • Scratch worktree of main a9779ef3c193, gradle, seed DEADBEEF, -Ptests.asserts=true and =false.
  • The new test passes 6 of 6.
  • 11 classes, 254 tests, 0 failures, 3 skipped: org.apache.lucene.document.column.* (7 classes, including TestColumnBatchDictionaryColumn and the new test), TestColumnBatchIndexing, TestDocValuesIndexing, TestIndexWriterExceptions and TestDocValues.
  • Static checks pass: ecjLintMain, ecjLintTest, forbiddenApis and checkGoogleJavaFormat.
  • Earlier, the same reordering made the finder's, the independent reproducers', the cross-model reviewer's and the security probe's tests pass, with -ea and -da.
  • Not run: the full ./gradlew check, other modules, x86 and benchmarks.

B. Restore the writer state when a tuple fails

Wrap the loop body in try/finally, like SortedDocValuesWriter.addDenseOrdinalValues (SortedDocValuesWriter.java:169-182). When a tuple fails while the open document has no values, leave the writer with no open document.

Trade-offs

Broader: it covers any exception after the switch. The "no open document" state already exists: currentDoc == -1, which finishCurrentDoc() skips (SortedSetDocValuesWriter.java:163-165). Since the previous document has already been recorded, B sets currentDoc back to -1; setting it back to the previous document would record that document twice. More code than A. Not run.

C. Never record a document with 0 values (safety net) (validated by the checks)

In finishCurrentDoc(), return early when currentUpto == 0. Then docsWithField and the value counts never list an empty document, whatever left it open (+5 lines).

Trade-offs

One more branch per document. It hides the wrong order instead of fixing it: currentDoc still names the rejected document. Good as defence in depth next to A, weaker on its own.

What the checks validated

run on its own, without A: the new test 6 of 6 and TestColumnBatchDictionaryColumn 25 of 25 pass with assertions on and off; the wider suites of A were not run for C

Downstream note

An application that fills a SORTED_SET DictionaryColumn from request data, or decodes it lazily in a cursor that can throw, can avoid it by checking every ordinal and decoding fully before addBatch; no downstream product is known to build such columns.

How it was found

Semi-formal. While modelling the addBatch doc-values writers, a hunting AI tool saw the order in the code. A small Z3 model showed that a document with 0 ordinals after a rejected one is reachable. A failure-injection sweep on the real jar then hit it in all 605 layouts with assertions on.

The method in full140 words
  • The hunt unit. It covered the column-oriented addBatch pass. Its AI tool modelled the commit state of the column-pass doc-values writers in Z3 (dense_commit.py). Check W4 asked whether a document can be registered with 0 ordinals after a rejected ordinal: reachable (sat).
  • The sweep. A failure-injection sweep on the real jar, "dict SORTED_SET sparse bad ordinal", varied the layout around one bad ordinal over 605 grid points. With -ea all 605 failed. With -da, 16 failed at commit with ArrayIndexOutOfBoundsException, and in 589 the live data read back exactly. Its CheckIndex ran without a level set and skipped the doc-values check, so the 589 do not show a clean index.
  • The candidate. The AI tool then wrote the JUnit repro and the fix sketch. An independent reproducer found the -da unreadable-field layouts and the throwing-cursor trigger.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26): 51 tool queries over GitHub, the mailing lists, the JIRA archive and the web; full-text scans of every PR and issue since the API landed; 13 manual searches including Elasticsearch, OpenSearch and Solr. Related, not this bug: #16101 (introduced it), #16553 (same class, stored fields).

Searches5 searches

No issue, PR, review comment, list post, JIRA entry or downstream report describes this bug, and no open PR touches addOrdinalTuples. DictionaryColumn was merged on 2026-05-26, so any report must fall after that date. A search cannot prove that something was never reported.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@; JIRA archive; web2026-10-26a search tool, 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive, 6 web), plus the finder's earlier 21-query runonly #16101 and its commit as high overlap
GitHub (GraphQL, independent of search ranking)2026-10-26changed files of all 412 open PRs and of 650 closed PRs updated since 2026-05-02; full text of 800 PRs and 240 issues updated since 2026-05-20no report; no open PR touches addOrdinalTuples
GitHub manual searches (apache/lucene, Elasticsearch, OpenSearch, Solr)2026-10-2613 searches, e.g. addOrdinalTuples, OrdinalsTupleCursor, "SORTED_SET" ordinal addBatch, DictionaryColumn, ColumnBatch exceptionno report
lists.apache.org2026-10-26dev@ and java-user@ (columnar, addBatch, DictionaryColumn; the whole "[DISCUSS] Column-oriented indexing API for IndexWriter" thread); builds@, issues@, commits@no report
Related items (read)2026-10-26bodies, reviews and comments#16101 (introduced it; one LGTM, no discussion), #16228 (last change, order kept), #16734 (a reviewer asked to record doc IDs only after validation in the vector path), #16553 and #7111 / LUCENE-6049 (same class, other code)

History

Introduced by #16101, which added DictionaryColumn (merged 2026-05-26, first released in 10.5.0); the method's first version already had the wrong order. 10.5.0, 10.5.1, 10.5.2 and main fail with -ea and -da; 10.4.0 and earlier have no addBatch.

Versions tested5 rows

Introduced by e79c6780627f (#16101 "Add DictionaryColumn for SORTED / SORTED_SET dvs", by Tim-Brooks, merged 2026-05-26, milestone 10.5.0; branch_10x backport 8a9e55f254d1). git blame, git log -L and git log -S show that the method's first version already switched documents before the check. The only later change, #16228 (scratch array), keeps the order. No build was made at the commit or its parent, which lacks the API; the releases bracket it. branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x and branch_10_5 have the same code (read-only check of the branch heads, 2026-10-24).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnit through gradle: 4 of 6 fail with assertions on and off, the 2 controls pass; the finder's JUnit fails
main ea57360912ed affectedthe finder's JUnit fails with -ea and -da; the -da unreadable-field layout reproduces
10.5.0, 10.5.1, 10.5.2 affectedthe finder's JUnit fails with -ea (AssertionError) and -da (ArrayIndexOutOfBoundsException); the -da unreadable-field layout reproduces on 10.5.0 and 10.5.2
10.4.0, 9.12.3 API absentcompile errors: no org.apache.lucene.document.column package and no IndexWriter#addBatch
fix option A on a9779ef3c193 fix6 of 6, and 254 tests in 11 classes, pass with assertions on and off

Independent checks

Independent reproducers and a cross-model reviewer (OpenAI GPT-5.6) each called it real and reproduced it with their own tests, with assertions on and off; the adjudication re-ran the first reproducer's tests and confirmed it (high). Other cross-model reviews ended without a verdict. This doc's gradle runs reproduce it on main a9779ef3c193.

Checks and verdicts322 words

All checks before this doc ran on main ea57360912ed. The refute and root-cause reviews by OpenAI GPT-5.6 stopped without a final answer. The finder re-ran the refute reviewer's own test: the invalid-first-tuple case fails, and its control passes.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateThe JUnit fails 1 of 1 with -ea and -da, also on 10.5.2; the fix sketch passes. First take: crash.
Cross-model reviews, refute and root-cause lenses (OpenAI GPT-5.6) no verdict The refute reviewer's own test, re-run by the finder: the invalid-first-tuple case fails, the invalid-second-tuple control passes, and both pass with the fix.
Independent reproducer realIts own public-API tests fail 3 of 5 with -ea and -da, 40 of 40 executions (20 iterations of 2 tests), also on 10.5.0 and 10.5.2. It found the -da unreadable-field and CheckIndex layouts and the throwing-cursor trigger. Its own fix passes.
Works-as-designed review (OpenAI GPT-5.6) real (high)Rated the "must be in range" argument moderate; its own recovery test and fix test reproduce on main with -ea and -da.
Devil's advocate realThe upstream test re-typed to SORTED_SET fails at close(). Default-config tragic test: the writer closes and the reopened index keeps 1 document. The fix passes 5 of 5.
Adjudication confirmed (high)Re-ran the independent reproduction's tests (3 of 5 fail with -ea and -da) and the -da layout test (3 of 4 fail); the fix passes all. Impact class: data corruption.
Follow-up and security probe release bracket; not security-sensitive10.5.0-10.5.2 fail; 10.4.0 and 9.12.3 lack the API. In the probe, a later writer's forceMerge(1) fails on the damaged segment; all pass with the fix.
This doc's gradle runs reproduced; fix A validatedOn main a9779ef3c193: 4 of 6 fail with assertions on and off, and the controls pass. With fix A, 6 of 6 pass, and so do 254 tests in 11 classes.

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

Finding record

LFV-094, snapshot of 2026-11-06

Registry title
A SORTED_SET DictionaryColumn tuple that addBatch rejects (out-of-range ordinal, or ordValue() throws) leaves an empty document in the doc-values buffer, so the next flush fails and closes the IndexWriter, or with -da commits a segment whose field cannot be read
Area
index / columnar addBatch (DictionaryColumn SORTED_SET doc values, SortedSetDocValuesWriter)
Priority
Medium, rank 153: A rejected SORTED_SET DictionaryColumn tuple in addBatch looks clean but makes the next flush close the writer and lose uncommitted docs, or with -da commit an unreadable field, an error path of the experimental addBatch API.
Impact class
data corruption
Impact note
Hits only applications that call the experimental IndexWriter#addBatch (first released in 10.5.0) with a SORTED_SET DictionaryColumn and have the first tuple of some document rejected (an ordinal outside the dictionary) or have their own cursor's ordValue() throw there; default IndexWriterConfig, codec and directory, ordinary scale (one bad tuple); the rejection itself looks clean, but the next flush or commit of that in-memory segment closes the writer as a tragic event and loses every uncommitted buffered document (with -ea and -da), and with assertions disabled some value layouts instead commit a segment whose SORTED_SET field cannot be opened, sorted on or merged, or that fails CheckIndex; no downstream product is known to build SORTED_SET DictionaryColumns.
Summary
You add a batch with IndexWriter#addBatch whose SORTED_SET DictionaryColumn gives the first value of some document an ordinal outside the dictionary (or whose cursor throws while returning that ordinal); Lucene rejects the batch with an IllegalArgumentException, the writer stays open with no tragic exception, and you keep indexing. You expect the rejected batch to leave no trace, as Lucene promises for non-aborting exceptions (the batch's documents are marked deleted and the index stays consistent), so a later document with two SORTED_SET values commits and reads back normally. Instead the doc-values writer had already opened the rejected document before it checked the ordinal and records it with zero values, so the next commit fails (AssertionError with -ea, ArrayIndexOutOfBoundsException with -da), closes the writer and loses all uncommitted documents, or, with -da and some value layouts, succeeds but writes a segment whose field throws NullPointerException when opened or fails CheckIndex.
Introduced
e79c6780627f (#16101 'Add DictionaryColumn for SORTED / SORTED_SET dvs', main; branch_10x backport 8a9e55f254d1, contained in releases 10.5.0-10.5.2), which added SortedSetDocValuesWriter.addOrdinalTuples with the document switch before the ordinal check [read: git blame, git log -L and git log -S in the full-history clone; the defect is in the method's first version, and its only later change (#16228, scratch array) keeps the order; releases bracket it: 10.4.0 cannot compile the repro, 10.5.0 fails (exec); no build at the commit or at its parent, which lacks the API] (2026-05-26 (#16101 merged))
First affected version
10.5.0 [exec]
Affected versions
10.5.0, 10.5.1, 10.5.2 and main ea57360912ed [exec: the finder repro fails on each with -ea and -da; the independent reproduction's tests fail on 10.5.0 and 10.5.2 with -ea and -da; the unreadable-segment layout test fails on 10.5.0, 10.5.2 and main with -da]; branch_10_6 (10.6.0, released 2026-10-29), branch_10x and branch_10_5 have the same code [read: read-only GitHub contents check of the branch heads, 2026-10-26]; not applicable: 10.4.0 and every earlier release, including 9.12.3 (no org.apache.lucene.document.column package and no IndexWriter#addBatch; the repro does not compile) [exec]
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: Z3, code reading
Upstream and prior reports
none found (2026-10-26: a scripted prior-report search, 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web) plus the finder's earlier 21-query run; 13 manual read-only gh searches, including Elasticsearch, OpenSearch and Solr; a GraphQL scan of the changed files of all 412 open PRs and 650 closed PRs updated since 2026-05-02 and of the full text of 800 PRs and 240 issues updated since 2026-05-20; lists.apache.org dev@, java-user@, builds@, issues@ and commits@); related prior items, none of them this bug: #16101 (introduced it; one approving review, no comments; its out-of-range tests use SORTED only), #16228 (last change to the method, order unchanged), #16734 (merged 2026-10-26 as a9779ef3c19; on main and branch_11x, not on branch_10_6; a reviewer asked for the same 'record doc IDs only after validation' rule in the dense vector fill path; does not touch doc values), #16553 (same bug class in stored fields, fixed 2026-09-01), #7111 / LUCENE-6049 (2014, same class, different code); no open PR touches addOrdinalTuples
Lucene commit tested
main a9779ef3c193 (2026-10-26, tested 2026-10-27): this doc's 6-test JUnit through gradle with -Ptests.asserts=true and =false. On stock main 4 tests fail and the 2 controls pass. With fix option A the test passes 6 of 6, and 11 test classes pass (254 tests, 0 failures, 3 skipped); the static checks pass too. Option C: run on its own, without A: the new test 6 of 6 and TestColumnBatchDictionaryColumn 25 of 25 pass with assertions on and off; the wider suites of A were not run for C. Also re-run: the finder's JUnit, which still fails (repro harness with -ea; the diagram's re-run with -ea and -da). Main ea57360912ed and the releases 10.5.0, 10.5.1 and 10.5.2 fail through the repro harness with -ea and -da; 10.4.0 and 9.12.3 lack the API. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SortedSetDocValuesWriter.java:84-105 (addValue validates before it switches documents), :115-159 (addOrdinalTuples: document switch :126-129 before ordValue() :130 and the range check :131-140), :162-193 (finishCurrentDoc records the opened document with its count), :360 (assert ordCount > 0)
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:123-145 (the SORTED tuple path translates the ordinal before it records the document) and :156-183 (addDenseOrdinalValues try/finally at :176)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1221-1245 (processDictionaryColumn: every SORTED_SET DictionaryColumn goes through addOrdinalTuples)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:314-358 (updateBatch; deleteLastDocs on a non-aborting exception at :349-354)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:67-79 (non-aborting exceptions: 'These updates are consistent', the document is marked deleted)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465 (addDocument: 'the index will be consistent'), :1528-1539 (addBatch, @lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/document/column/OrdinalsTupleCursor.java:26, 28, 46 ('must be in' range; @lucene.experimental) and OrdinalsCursor.java:49-52 ('The indexing path validates this on every call and throws IllegalArgumentException on violation')
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2302-2303 (getSortedSet reads ordsEntry.addressesMeta, which is null when the writer omitted the addresses)
  • lucene/core/src/test/org/apache/lucene/document/column/TestColumnBatchDictionaryColumn.java:391-406 (testOutOfRangeOrdSparseThrows: SORTED only, then close()) and lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java:681, 739, 793, 840 (the four *ThrowMidBatchRecovers tests; none for DictionaryColumn)
  • lucene/CHANGES.txt:900 (GITHUB#16101 in the 10.5.0 section)
  • SECURITY.md:28, 32, 42, 50 (threat model)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SortedSetDocValuesWriter.java:84-105, 115-159 (switch :126-129, ordValue :130, check :131-140), 162-193, 226-229, 356-373
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:123-146
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1223-1257 (SORTED_SET :1244-1248)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:314-358 (deleteLastDocs :349-354), 585-587, 594-604
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:398-403, 919-933, 955-969, 971-1005
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2302-2303
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114-1135; search/SortedSetSelector.java:165-168; document/column/ColumnFieldAdapter.java:57-62, 79-81; index/IndexingChain.java:791-794
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1158-1165, 1203-1208 (BinaryColumn SORTED_SET uses addValue), 1244-1248
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/column/OrdinalsCursor.java:50-52; OrdinalsTupleCursor.java:26, 28, 46-47
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:67-79; index/IndexWriter.java:1465, 1528-1539, 1575-1590
  • lucene/core/src/test/org/apache/lucene/document/column/TestColumnBatchDictionaryColumn.java:391-407
  • lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java:682, 740, 794, 841
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:123-146, 156-183
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:169-182; index/SortedSetDocValuesWriter.java:162-193
  • lucene/core/src/java/org/apache/lucene/document/column/DictionaryColumn.java:64-88; util/ByteBlockPool.java:199; util/ArrayUtil.java:145-150
History
  • lucene/CHANGES.txt:911, 916 (10.5.0 section)

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

Repro files

About the diagrams

Summary card (bug.png): a rejected one-document batch, then a document with two values, then commit(). Made in parallel with this doc from the executed runs.

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.