All findings · Lucene finding · LFV-097 · dense-long-docvalues-page-not-rolled-back

IndexWriter#addBatch: a throwing dense LongColumn cursor gives later documents another document's doc value

If a dense LongColumn's cursor throws part-way through IndexWriter#addBatch, the batch is rejected but values from pages it already filled stay buffered with no document. Later documents of that field in the segment then get another document's doc value (42 reads back as -1), persisted, with CheckIndex clean.

Summary

Priority
Medium, rank 150A throwing dense LongColumn cursor leaves values buffered so every later document of that field in the segment is silently stored with another document's value, surviving merges with CheckIndex clean, but it is an error path of an experimental API whose one known downstream user keeps it off.
Impact
data corruption
Since
10.5.0 (June 2026; introduced by #15990, merged May 2026)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: Z3 model of the page loop, then a failure-injection sweep

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 buffers a field's doc values in pages of 256 and registers a dense batch's documents only after the whole bulk copy returns. A cursor that throws after a page was filled leaves that page's values counted but registers no documents for them, and flush pairs documents with values by position.

Summary card: Lucene IndexWriter#addBatch: a dense LongColumn cursor that throws shifts the doc values of later documents. User code: 255 documents with v = 0 to 254, a 2-document batch (v = -1, -2) whose cursor throws on the 2nd value, then documents with 42, 43, 44. Expected: docs 257, 258, 259 read 42, 43, 44. Actual: they read -1, 42, 43 and 44 is never written; silent: it survives forceMerge(1) and CheckIndex is clean. What happens inside: the pending value buffer against docsWithField, with -1 left by the failed batch: fill call 1 puts -1 into the last free slot of page 0; fill call 2 throws, so docsWithField.addRange never runs; the batch's docs 255 and 256 are deleted but -1 stays in pending; flush gives the k-th doc id the k-th value. Notes on SORTED_NUMERIC, reach and affected versions, and a fix sketch: register one doc id per value that reached pending, as SortedDocValuesWriter.addDenseOrdinalValues does.
Six steps from 255 documents and a 2-document batch whose cursor throws, through the -1 left in the buffer with no document, to documents 257-259 reading -1, 42, 43; it ends on a summary card. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Add 255 documents with a NUMERIC doc value v = 0 to 254 (default IndexWriterConfig and codec). Doc values are buffered in pages of 256 values, so the current page has one free slot.
  2. Call addBatch with 2 documents: a dense LongColumn "v" whose LongValuesCursor returns -1, then throws on its 2nd value. The exception propagates and the 2 batch documents are deleted, as expected.
  3. Add a document with v = 42. You expect it to read back 42, as Lucene's own recovery test asserts for documents indexed after a throwing cursor.
  4. Instead it reads -1. The -1 filled the page's last slot and stayed buffered with no document, so each later document gets the value one position earlier: 42, 43, 44 read back as -1, 42, 43.
  5. No error: CheckIndex is clean and the wrong values survive commit, forceMerge(1) and reopen. Reproduced on 10.5.0, 10.5.1, 10.5.2 and main; turning assertions off changes nothing.

Who is affected

Applications that use the experimental IndexWriter#addBatch (10.5.0 and later) with a dense LongColumn feeding NUMERIC or SORTED_NUMERIC doc values, whose own cursor throws mid-batch, and that keep indexing into the same writer. Default configuration and codec. Lucene's own code never throws there, so it is rare; the one known downstream user has its batch path off by default.

Mechanism, terms and numbers637 words

IndexWriter#addBatch (experimental, since 10.5.0) copies a dense LongColumn into the field's NUMERIC or SORTED_NUMERIC doc-values buffer in bulk, one 256-value page at a time, and registers the batch's documents only after the whole copy has returned. If the application's LongValuesCursor throws after at least one page was filled during the call, the batch is rejected correctly (the exception propagates, the writer stays open, the batch's documents are deleted), but the values of the filled pages stay buffered with no document. At flush the k-th document with the field is paired with the k-th buffered value, so every later document of that field in the same in-memory segment is stored with the value of a document that many positions earlier (for a SORTED_NUMERIC field with an earlier multi-valued document: the counts stay right, the values come from other documents). Nothing throws, CheckIndex is clean, and the values survive commit, reopen and forceMerge(1). Default configuration and codec; it needs an application cursor that throws, so it is rare. The sibling dense SORTED path and the stock flat writer of the new dense vector path (#16734) keep documents and values aligned; the fix is a few lines of bookkeeping.

Terms

  • Columnar addBatch. IndexWriter#addBatch(ColumnBatch) (experimental, 10.5.0) indexes a batch column by column. A DENSE LongColumn has a value for every document and hands it over through a LongValuesCursor that the application writes (LongColumn.values(), document/column/LongColumn.java:79-88).
  • The doc-values buffer. NumericDocValuesWriter and SortedNumericDocValuesWriter keep the values of the in-memory segment in a PackedLongValues.Builder (pages of 256 values, PackedLongValues.java:34, :66) and the documents that have the field in a docsWithField set.
  • Non-aborting exception. An exception that rejects the current documents but keeps the writer and the in-memory segment for later documents.

The bug, step by step (main a9779ef3c193)

  1. IndexingChain.processDenseLongColumn runs the doc-values pass first and calls addDenseValues on the field's writer (IndexingChain.java:1076-1093).
  2. addDenseValues calls pending.add(cursor) and only after it returns registers the batch's documents: docsWithField.addRange, lastDocID / currentDoc, and pendingCounts for SORTED_NUMERIC (NumericDocValuesWriter.java:81-94, SortedNumericDocValuesWriter.java:73-102).
  3. PackedLongValues.Builder.add(LongValuesCursor) packs a full page, asks the cursor for at most the rest of the current page with fillDocValues, then advances pendingOff and size (PackedLongValues.java:265-279). When a later fillDocValues call throws, the values of the earlier calls stay counted.
  4. DocumentsWriterPerThread.updateBatch treats the exception as non-aborting: it deletes the batch's documents and keeps the segment (DocumentsWriterPerThread.java:349-354, :399-409).
  5. The NRT reader, the flush and the index sorter read the buffer through BufferedNumericDocValues, which takes the next document of docsWithField and the next buffered value (NumericDocValuesWriter.java:192-198; single-valued SORTED_NUMERIC uses it too, SortedNumericDocValuesWriter.java:192-199). With k left-over values every later document gets the value k positions earlier (up to the first k get the rejected batch's values); the last k values are never read, so nothing throws. Once an earlier document of a SORTED_NUMERIC field was multi-valued, the per-document counts stay right but the values are read from the same shifted stream (SortedNumericDocValuesWriter.java:192-199, :279-289).

The numbers

Writer state from an executed probe: after documents 0-254, 255 values and 255 documents. The 2-document batch: fill call 1 (offset 255, length 1) returns -1, the page is packed, fill call 2 (offset 0, length 1) throws: 256 values, still 255 documents. Documents 257-259 (42, 43, 44) are then paired with -1, 42 and 43; the good document's 44, the last buffered value, is never read. Other executed cases: a 257-document SORTED_NUMERIC batch failing on its last value leaves 256 values (the next document reads -1 instead of 42); a 600-document batch failing at value 520 leaves 512 (42, 43, 44 read -1, -2, -3). Not affected: a throw inside the first fill call, the sparse (tuple) long path, the dictionary SORTED dense path, and a flush right after the failure.

Exposure and likelihood338 words
  • Who is hit. Users of the experimental addBatch / updateBatch with a DENSE LongColumn for NUMERIC or SORTED_NUMERIC doc values (including a LongField-style schema with points plus SORTED_NUMERIC; the doc-values pass runs first), whose LongValuesCursor throws a RuntimeException after the batch crossed a 256-value page boundary of the field's buffer, and who keep adding documents with that field before the segment flushes. updateBatch keeps the writer and the in-memory segment for that, and Lucene's own recovery test indexes more documents afterwards (after a commit()); the public addBatch contract says nothing about unchecked exceptions.
  • Default or opt-in. Default IndexWriterConfig and codec, no assertions needed; reproduced with NoMergePolicy, the default TieredMergePolicy and the randomized test configuration. The opt-in part is the experimental API and a cursor that throws: Lucene ships no LongValuesCursor outside tests, so the cursor is application code, for example one that decodes or validates its source lazily and fails on a bad row.
  • Scale. 255 earlier documents and a 2-document batch are enough. The shift equals the number of values in the completed fill calls: 1, 256 and 512 in the executed cases.
  • The damage. Documents whose own add calls succeeded carry another document's value: sorting, doc-values queries, facets and aggregations on that field, and an index sort (probe: the good document reads -1 with the sort on the field itself or on another field). Points stay correct, so points and doc values of one field disagree; LongField.newExactQuery can return wrong hits when IndexOrDocValuesQuery runs on doc values. CheckIndex is clean; the values survive commit, reopen and forceMerge(1). Repair means finding and reindexing the affected documents; for a doc-values-only field that is not an index-sort field, updateNumericDocValue / updateSortedNumericDocValue can also rewrite them (IndexWriter.java:1962, :2037), but not for a LongField-style field with points.
  • Exposure today. The API shipped in 10.5.0 (June 2026). The only downstream user found, Elasticsearch's batch-indexing path, is unreleased, behind a feature flag and a setting that defaults to off, with no known throw site here.

Minimal repro

The test shows that when a dense NUMERIC LongColumn's values cursor throws during addBatch after 255 earlier documents of the field, the value it already delivered stays buffered with no document, so the next document of the field is stored with that value.

TestDenseLongColumnFailure.java (JUnit test)90 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.NumericDocValuesField;
import org.apache.lucene.document.column.Column;
import org.apache.lucene.document.column.ColumnBatch;
import org.apache.lucene.document.column.LongColumn;
import org.apache.lucene.document.column.LongTupleCursor;
import org.apache.lucene.document.column.LongValuesCursor;
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 TestDenseLongColumnFailure extends LuceneTestCase {

  public void testFailedDenseBatchShiftsLaterValues() throws Exception {
    Column failing =
        new LongColumn("v", NumericDocValuesField.TYPE, Column.Density.DENSE) {
          @Override
          public LongTupleCursor tuples() {
            throw new UnsupportedOperationException();
          }

          @Override
          public LongValuesCursor values() {
            return new LongValuesCursor(2) {
              int pos = 0;

              @Override
              public long nextLong() {
                if (pos++ == 1) {
                  throw new IllegalStateException("data source failed");
                }
                return -1;
              }
            };
          }
        };
    ColumnBatch batch =
        new ColumnBatch() {
          @Override
          public int numDocs() {
            return 2;
          }

          @Override
          public Iterable<Column> columns() {
            return List.of(failing);
          }
        };
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      for (int i = 0; i < 255; i++) {
        Document doc = new Document();
        doc.add(new NumericDocValuesField("v", i));
        w.addDocument(doc);
      }
      expectThrows(IllegalStateException.class, () -> w.addBatch(batch));
      Document good = new Document();
      good.add(new NumericDocValuesField("v", 42));
      w.addDocument(good);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        LeafReader leaf = getOnlyLeafReader(reader);
        NumericDocValues values = leaf.getNumericDocValues("v");
        assertTrue(values.advanceExact(leaf.maxDoc() - 1));
        // expected: the last document reads its own value 42; actual: -1 from the failed batch
        assertEquals(42, values.longValue());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-097/, 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.TestDenseLongColumnFailure
Expected vs actual
Expected the document added after the failed batch to read back its own value 42; actual: expected:<42> but was:<-1> (-1 is the failed batch's value), with -ea and -da.

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

More files, environment, full output34 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:<42> but was:<-1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.index.TestDenseLongColumnFailure.testFailedDenseBatchShiftsLaterValues(TestDenseLongColumnFailure.java:86)

Why it looks unintended

  • Lucene's design note: a non-aborting exception may partially update the index, but "These updates are consistent".
  • Lucene's own recovery test throws from a dense cursor and asserts exact later values; its 6-value batch never fills a page, and it commits right after the failure.
  • The dense SORTED path and the stock flat vector writer (#16734) keep documents and values aligned.
The full argument410 words
  • Design note (DocumentsWriter.java:76-79): "All other exceptions ("non-aborting exceptions") can still partially update the index structures. These updates are consistent, but, they represent only a part of the document seen up until the exception was hit. When this happens, we immediately mark the document as deleted so that the document is always atomically ("all or none") added to the index." updateBatch follows it (DocumentsWriterPerThread.java:349-354). addDocument: "if an Exception is hit ... then the index will be consistent" (IndexWriter.java:1465). The addBatch javadoc (:1528-1536), the column javadocs, CHANGES and MIGRATE never ask for a flush after a failed batch.
  • Lucene's tests deliberately throw from a dense cursor and check the writer afterwards. ColumnBatchTestUtil has throwing-cursor helpers "so tests can exercise the failure-cleanup path in DocumentsWriterPerThread#updateBatch" (:589-591), including ThrowingDenseLongColumn (:638-642). TestColumnBatchIndexing#testLongValuesCursorThrowMidBatchRecovers (:740-792) asserts 777, 111 and 222 for the documents indexed afterwards. That shows intent to keep the writer usable, not a written contract for same-segment recovery. It misses the bug because its 6 values throw inside the first fill call and because it commits right after the failure (:760-762, "so the cleanup-induced fully-deleted segment is pruned"). With a 300-value batch failing at 280 the test passes with that commit() and fails without it: expected:<777> but was:<1>.
  • Two sibling paths do it right. SortedDocValuesWriter.addDenseOrdinalValues (:156-182, #16101) registers exactly the processed documents in a finally; a Z3 model that counts values and documents found no batch of up to 4096 values where the counts differ after a throw. The dense vector path merged in #16734 writes the rule down: "The cursor may throw while it is being consumed ... the writer must remain in a consistent state: every doc ID it has recorded must have its vector ... record doc IDs and vectors only after fill has returned" (KnnFieldVectorsWriter.java:46-50), and its new test keeps the failed and the recovery batch in one segment on purpose (TestColumnBatchIndexing.java:886-922). That holds for the stock flat writer; LFV-579, a separate defect, shows HNSW and scalar-quantized wrappers over a custom flat writer that fall out of step.
  • History. #16270 made batch exception handling match updateDocuments. #16553, backported to 10.5.2, is a precedent: a non-aborting exception left indexing-chain state inconsistent (a stored-fields frame left open), so that a later flush failed; reviewers called it a corruption issue and pulled it into the 10.5.2 release candidate.
The devil's-advocate check208 words

The strongest case for "works as designed" (independent checkers, both still concluding "real"): the API, LongColumn and LongValuesCursor are experimental; the cursor "produces exactly size() values" (LongValuesCursor.java:22-23), so a cursor that throws within range is a broken producer; PackedLongValues.Builder promises no atomicity; addBatch declares only IOException; and the upstream test flushes before indexing again, as if only post-flush recovery were intended.

Why it fails: the documents that end up wrong are later ones whose own add calls succeeded, and no contract lets a successful add store another document's value. The same javadoc requires implementations to throw on overrun (:25-26, :57-58), so exceptions belong to the cursor protocol (that javadoc covers overrun, not an in-range failure, so the contract stays ambiguous), and Lucene's own helper throws in range on purpose. updateBatch keeps the segment and nothing says to flush; the test's commit() is there, by its comment, to prune the deleted segment. A stale comment in testDenseColumnCountMismatchThrows ("avoid the partially-written DV entries", TestColumnBatchIndexing.java:291-292) dates from a PR revision that wrote values and documents together, so it is about values on deleted documents, not misalignment. Verdict: a defect (a correctness bug of an experimental API; a maintainer may still label it an enhancement).

Suggested fix

Recommended Register exactly the values that reached the buffer (validated)

Remember pending.size() before the bulk add and, in a finally, register that many of the batch's own (already deleted) documents and pendingCounts entries, as the dense SORTED path does. With it the 3 tests pass, and so do the 20 other closest test classes, all 8,892 core-module tests and :lucene:core:check -x test.

final long sizeBefore = pending.size();
try {
  pending.add(cursor);
} finally {
  // Register a doc for every value that reached the buffer, even if the cursor threw part-way,
  // so that values stay aligned with docsWithField.
  final int added = Math.toIntExact(pending.size() - sizeBefore);
  if (added > 0) {
    docsWithField.addRange(firstDocID, firstDocID + added);
    lastDocID = firstDocID + added - 1;
  }
  updateBytesUsed();
}
All options and trade-offs4 options ยท 621 words

One fix was built and validated through gradle; the others are design alternatives that were not built.

Register exactly the values that reached the buffer (validated) (recommended)

In both addDenseValues methods, remember pending.size() before the bulk add and, in a finally, register as many documents (and, for SORTED_NUMERIC, pendingCounts entries) as values were added, like SortedDocValuesWriter.addDenseOrdinalValues. The registered doc ids are the batch's own documents, which updateBatch deletes right after, so no live document gains a value. +34/-23 lines in two files; the snippet is the NumericDocValuesWriter half.

final long sizeBefore = pending.size();
try {
  pending.add(cursor);
} finally {
  // Register a doc for every value that reached the buffer, even if the cursor threw part-way,
  // so that values stay aligned with docsWithField.
  final int added = Math.toIntExact(pending.size() - sizeBefore);
  if (added > 0) {
    docsWithField.addRange(firstDocID, firstDocID + added);
    lastDocID = firstDocID + added - 1;
  }
  updateBytesUsed();
}

Trade-offs

  • The rejected batch's deleted documents keep values until a merge drops them, as with the per-document path; nothing reads them through live docs.
  • Cost: constant bookkeeping per non-empty dense batch (two size() reads, a subtraction, a Math.toIntExact and a branch); no added work per value on the success path and no extra copy.
  • It relies on Builder.size() counting exactly the completed fill calls, which add(LongValuesCursor) does by construction (PackedLongValues.java:274-276).
  • The finally also runs updateBytesUsed() after a failure; the stock code skips it, so the in-memory segment's RAM accounting misses the kept values until the writer's next successful update.

What the checks validated

  • stock, the test, seed DEADBEEF, assertions on: 1 classes, 3 tests, 3 failures, 0 errors, 0 skipped (the 3 tests fail).
  • stock, the test, assertions off: 1 classes, 3 tests, 3 failures, 0 errors, 0 skipped.
  • stock, :lucene:core:check -x test: BUILD SUCCESSFUL.
  • fix, the test, assertions on: 1 classes, 3 tests, 0 failures, 0 errors, 0 skipped.
  • fix, the test, assertions off: 1 classes, 3 tests, 0 failures, 0 errors, 0 skipped.
  • fix, the test, seed CAFEBABE, tests.iters=10: 1 classes, 30 tests, 0 failures, 0 errors, 0 skipped.
  • fix, closest suites, assertions on: 21 classes, 909 tests, 0 failures, 0 errors, 62 skipped.
  • fix, closest suites, assertions off: 21 classes, 909 tests, 0 failures, 0 errors, 62 skipped.
  • fix, :lucene:core:check -x test: BUILD SUCCESSFUL.
  • fix, all of :lucene:core:test, seed DEADBEEF: 749 classes, 8,892 tests, 0 failures, 0 errors, 322 skipped.
  • Repro harness, fix shadowed on main a9779ef3c193: the finder repro 2 of 2 pass, the paste-ready test 3 of 3, the index-sort probe 2 of 2.
  • Earlier: the finder's fix check, the independent reproduction's 6 tests (judge re-run), the cross-model reviewers' tests with 77 and 72 relevant existing tests, the security-probe tests and the finder repro at the introducing commit all pass with the fix.
  • The cross-model review's own persistence test, run by this doc's fact check on main a9779ef3c193 (assertions on): stock 1 of 2 fail (expected:<7> but was:<1000>), with the fix shadowed 2 of 2 pass.

Make PackedLongValues.Builder.add(LongValuesCursor) atomic

On a throw, restore size, pendingOff and the packed pages.

Trade-offs

Not built. Harder than it looks: the loop may already have packed a page that also holds earlier documents' values, so a rollback has to undo a pack or keep a copy.

Fill into a scratch array first, then add

Copy the whole batch out of the cursor before touching the buffer.

Trade-offs

Not built. Atomic and simple, but every value is copied once more, which gives up the point of the bulk path.

Treat a cursor exception as aborting

Discard the in-memory segment.

Trade-offs

Not recommended: it drops every document since the last flush, against updateBatch's non-aborting handling (#16270) and the recovery Lucene's own test checks.

Downstream note

Not a security issue, since only the application's own cursor can throw there, but a multi-tenant service that builds such a cursor from request data could see doc-values filters and sorts apply rejected values to whichever documents are indexed next.

How it was found

Semi-formal. A hunt AI tool modelling the columnar dense writers suspected the page loop. A bounded Z3 model found the smallest failing case (255 values in the page, a 2-document batch failing on its 2nd value) and none for batches fitting the page's free slots. A failure-injection sweep on the real jar and a JUnit repro confirmed it.

The method in full208 words

Method: SMT (Z3) model of the dense doc-values page loop, then failure injection on the real jar.

  • The hunt unit covered the columnar addBatch path. Reading the dense writers while modelling them raised the hypothesis; the model (dense_commit.py) encodes PackedLongValues.Builder.add(LongValuesCursor) and the writers' bookkeeping. Property W1, "dense long DV page loop failure-atomic", is violated (sat) with the smallest witness 255 values already in the page and a 2-document batch failing on its 2nd value (1 value kept, 0 documents); no violation for batches that fit in the current page's free slots (unsat). Property W3 found no violation for the dictionary SORTED dense path (batches up to 4096 values). The model is bounded (W1: up to 4 pages, 1,024 values) and counts values and documents only, not buffer contents or RAM accounting.
  • A failure-injection sweep on the stock jar then threw from the cursor at every point of a grid: 301 of 605 points violated for dense NUMERIC and for dense SORTED_NUMERIC, with assertions on and off; 0 of 605 for the sparse long paths.
  • The finder wrote the 2-test JUnit repro and a fix; verifiers then reproduced it with their own tests, and an adjudication re-ran their tests and the finder's with new seeds (Independent checks).

Prior reports, history and checks

Prior reports

No prior report of the long doc-values defect (searched 2026-10-26, re-checked 2026-10-27). Closest: #16734, merged 2026-10-26, which states the same rule for the new dense vector path only.

Searches5 searches

No issue, PR, list thread, JIRA entry or downstream report describes this bug. #16734 states the invariant; because its review discussed the same failure mode for the vector path, a maintainer may see this as a known pattern.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories (Elasticsearch, OpenSearch, Solr)2026-10-26a scripted prior-report search, 4 runs (71 queries in the main run); 24 manual searches; the changed files of all 412 open PRs and of 624 closed PRs since 2026-04-15 (follow-up count)no report; no open PR changes the dense long path
Review threads2026-10-26#15990 (7 comments, 11 reviews, 20 inline comments) and #16101 read in fullno discussion of a throwing dense long cursor
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web2026-10-2620 list queries, 4 JIRA queries, 6 recorded web queries, direct lists.apache.org searchesno report; the column-API design thread does not discuss exceptions
GitHub issues and PRs (apache/lucene)2026-10-27re-check: LongValuesCursor, "addBatch doc values", "addBatch exception", "columnar addBatch"; commits to the three filesno new item; no commit to the three files since 2026-06-14
Related items2026-10-26read#16734 (the same rule for vectors, requested in review), #15990 / #16066 (introduced it), #16101 (sibling done right), #16553 (precedent), #16270 (batch exception handling)

History

Introduced with the columnar API by #15990 (3d89568, 2026-05-14) and released in 10.5.0 through its backport #16066; the three methods are unchanged since. Affects 10.5.0 to 10.5.2, 10.6.0 (by reading) and main.

Versions tested6 rows

Introduced by 3d89568155799e2e1efe132f67967d9725f4bd60, "Add experimental columnar indexing api (#15990)" (Tim-Brooks, merged 2026-05-14; parent 227255fdb08a), released through the branch_10x backport 3ec3bff01b15 (#16066, same day; the same diff of the three files, git patch-id --stable b091896e1e7b). The method bodies are identical at 3d89568 and at a9779ef3c193. branch_10x, branch_10_5 and branch_10_6 carry the same code (read 2026-10-26).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready JUnit 3 of 3 fail through gradle and the harness, assertions on and off; index-sort probe 2 of 2
main ea57360912ed affectedfinder repro 2 of 2 fail, assertions on and off
10.5.0, 10.5.1, 10.5.2 affectedfinder repro 2 of 2 fail (10.5.0 and 10.5.2 also with assertions off); paste-ready JUnit 3 of 3 on each (10.5.2 also with assertions off)
3d89568 (#15990) introducing commitfinder repro 2 of 2 fail, assertions on and off; 0 of 2 with the fix (javac-built jars of a git archive snapshot with substituted test-framework dependencies, run with main's dependency graph)
227255fdb08a (parent), 10.4.0, 9.12.3 no APIthe columnar API does not exist; the test does not compile
fix (scratch worktree of a9779ef3c193) not affectedthe paste-ready test, the closest suites, all of :lucene:core:test and :lucene:core:check -x test pass

Independent checks

Confirmed by an adjudication (high confidence, no dissent) after an independent reproducer, a cross-model reviewer (OpenAI GPT-5.6) with the works-as-designed lens, a devil's-advocate verifier and a root-cause review, which all ran their own tests; the adjudication re-ran them with new seeds. This doc re-ran it through gradle on main.

Checks and verdicts311 words

Every check that ran Java reproduced it; the refute-lens review gave no verdict, and its own test, re-run by the finder, fails on main and passes with the fix.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn tests written from the claim: 3 of 6 fail on main (assertions on and off), 10.5.0 and 10.5.2 (600-document batch failing at 520: [42, 43, 44] read [-1, -2, -3]); the 3 controls pass; CheckIndex clean; the fix passes 6 of 6.
Works-as-designed review (OpenAI GPT-5.6, the cross-model review) and its re-run real, highRated the intended-behaviour case moderate but insufficient; its test fails on main and 10.5.2, a sparse sibling passes; the re-run added a persistence test (the good document reads 1000 instead of 7 after commit, close and reopen).
Intended-behaviour devil's advocate real, highNo intended argument survives; showed that the upstream recovery test passes only because of its commit() (expected:<777> but was:<1> without it).
Root-cause review (OpenAI GPT-5.6) real, highAgrees with the root cause and fix site; its tests fail on main (including SORTED_NUMERIC with pendingCounts active); the fix passes them and 77 existing tests.
Refute review (OpenAI GPT-5.6) no verdictIts own test, re-run by the finder, fails on main at the page boundary and passes with the fix once an over-strict assertion is relaxed.
Adjudication confirmed, highRe-ran the independent reproduction's tests with a new seed (3 of 6 fail, 6 of 6 pass with the fix) and the finder repro (2 of 2 fail).
This doc reproduced, fix validatedPaste-ready test 3 of 3 fail through gradle on main with assertions on and off; with the fix it passes, as do the 21 closest classes including it (909 tests) and all of the core module (8,892 tests), and :lucene:core:check -x test is clean.

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

Finding record

LFV-097, snapshot of 2026-11-06

Registry title
When a dense LongColumn's values cursor throws during addBatch, the NUMERIC/SORTED_NUMERIC doc values it already delivered stay buffered with no document, so later documents of that field in the same segment are stored with another document's value
Area
index / columnar addBatch doc values (dense NUMERIC and SORTED_NUMERIC writers)
Priority
Medium, rank 150: A throwing dense LongColumn cursor leaves values buffered so every later document of that field in the segment is silently stored with another document's value, surviving merges with CheckIndex clean, but it is an error path of an experimental API whose one known downstream user keeps it off.
Impact class
data corruption
Impact note
Hits applications that use the experimental IndexWriter#addBatch (10.5.0 and later) with a dense LongColumn feeding NUMERIC or SORTED_NUMERIC doc values (LongField's schema included) whose own values cursor throws a RuntimeException after the field's buffer has completed a 256-value page during the call, and that keep indexing into the same writer before the next flush, the non-aborting recovery path that Lucene's own recovery test exercises; default IndexWriterConfig and codec, ordinary scale (255 earlier documents plus a 2-document batch suffice); every later document of that field in the in-memory segment is then persisted with a wrong value, CheckIndex stays clean, and only reindexing the affected documents repairs it.
Summary
You index a dense LongColumn as NUMERIC or SORTED_NUMERIC doc values with IndexWriter#addBatch, its values cursor throws part-way (for example after 255 earlier documents, a 2-document batch whose cursor fails on its 2nd value), and you keep adding documents to the same writer. You expect what Lucene's own recovery test asserts: the exception propagates, the batch's documents are deleted, and a document added afterwards with value 42 reads back 42. Instead the values the cursor delivered before the throw stay in the doc-values buffer with no document attached, so the good document reads back -1 (a value of the rejected batch) and every later document of that field in the segment is shifted by the same number of values, which survives commit, reopen and forceMerge(1) while CheckIndex finds nothing wrong.
Introduced
3d89568155799e2e1efe132f67967d9725f4bd60 (#15990 'Add experimental columnar indexing api', main; parent 227255fdb08a), released through the branch_10x backport 3ec3bff01b15 (#16066), whose patch for the three methods is the same (git patch-id b091896e1e7b) [exec: the finder repro fails 2 of 2 at 3d89568 with -ea and -da and passes there with the fix shadowed; it does not compile at the parent, which has no columnar API; read: git log -S / -L and blame attribute every line of NumericDocValuesWriter.addDenseValues, SortedNumericDocValuesWriter.addDenseValues and PackedLongValues.Builder.add(LongValuesCursor) to this commit, unchanged since]. The bug is as old as the API. (2026-05-14 (#15990 merged into main, #16066 into branch_10x))
First affected version
10.5.0 [exec, -ea and -da]
Affected versions
10.5.0, 10.5.1 and 10.5.2 (latest 10.5.x release) [exec: finder repro fails 2 of 2; 10.5.0 and 10.5.2 also with -da]; main from 3d89568 (2026-05-14) through ea57360912ed [exec at both ends, -ea and -da]; the unreleased branch_10x and branch_10_5, and branch_10_6 (10.6.0, released 2026-10-29), carry the same code for the three methods [read, 2026-10-26]; not applicable: every release before 10.5.0, including 10.4.0 and 9.12.3, where the columnar API does not exist (compile error) [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, 4 runs (finder, verifier, two follow-up runs) over GitHub issues, PRs and commits of apache/lucene, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 24 manual read-only gh searches on apache/lucene plus downstream-repository searches; the review threads of #15990 (7 comments, 11 reviews, 20 inline comments) and #16101 read in full; the changed-file lists of all 412 open apache/lucene PRs and of 624 closed PRs since 2026-04-15; direct lists.apache.org searches; 8 web queries; search gaps: builds@ full text and private channels); related prior items, none of them this bug: #16734 (merged 2026-10-26 as a9779ef3c19; on main and branch_11x, not on branch_10_6; dense vector path for addBatch: a reviewer asked that the writer 'should only record doc IDs after fill returns', and the PR documents and implements that rule for vectors only; the long doc-values path is neither mentioned nor changed; different fix site; the item that states the invariant), #15990 and its backport #16066 (introduced it; no review discussion of a throwing dense cursor), #16101 (sibling SORTED dense path, written with try/finally), #16553 (precedent: a non-aborting exception left stored-fields chain state inconsistent, treated as corruption and backported to 10.5.2), #16270 (aligned batch exception handling with the document path)
Lucene commit tested
main a9779ef3c193, runs of 2026-10-27: the 3-test paste-ready JUnit through gradle in a scratch worktree, with assertions on and off (3 of 3 fail), and through the repro harness on main (assertions on and off), 10.5.0, 10.5.1 and 10.5.2 (10.5.2 also with assertions off) (3 of 3 fail); the finder's 2-test repro on main with assertions on and off (2 of 2 fail) and with the fix shadowed (0 of 2); an index-sort probe (2 of 2 fail, 0 of 2 with the fix); the fix through gradle (section Suggested fix). Earlier, by the hunt and the follow-up: the introducing commit 3d89568, 10.5.0, 10.5.1, 10.5.2 and main ea57360912ed, with assertions on and off.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedLongValues.java:261-279 (Builder.add(LongValuesCursor): packIfFull, fillDocValues, then pendingOff/size advance per chunk)
  • lucene/core/src/java/org/apache/lucene/index/NumericDocValuesWriter.java:81-94 (addDenseValues: pending.add(cursor) before docsWithField.addRange and lastDocID), :176-200 (BufferedNumericDocValues.nextDoc pairs the next docsWithField doc with iter.next())
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValuesWriter.java:73-102 (addDenseValues: pending.add(cursor) before pendingCounts, docsWithField.addRange and currentDoc)
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:156-182 (addDenseOrdinalValues: try/finally registers exactly the processed docs)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1066-1102 (processDenseLongColumn: the DV pass runs before the points pass)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:314-359 (updateBatch: a processBatch exception is non-aborting, deleteLastDocs marks the batch deleted, the DWPT is kept)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:67-79 (non-aborting exceptions 'can still partially update the index structures. These updates are consistent')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1529-1539 (addBatch javadoc, @lucene.experimental, no exception-state statement)
  • lucene/core/src/java/org/apache/lucene/document/column/LongValuesCursor.java:22-25, 35, 58 ('produces exactly size() values'; implementations must throw on overrun)
  • lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java:739-790 (testLongValuesCursorThrowMidBatchRecovers: 6-value batch, w.commit() at :761 right after the failure, exact later values asserted)
  • lucene/core/src/test/org/apache/lucene/document/column/ColumnBatchTestUtil.java:513 (throwing-cursor helpers 'to exercise the failure-cleanup path in DocumentsWriterPerThread#updateBatch'), :566 (ThrowingDenseLongColumn)
  • SECURITY.md:28, 42, 50 (threat model)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:969-972, 1068-1104 (dense dispatch; DV pass first)
  • lucene/core/src/java/org/apache/lucene/index/NumericDocValuesWriter.java:81-94 (addDenseValues), 176-200 (BufferedNumericDocValues)
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValuesWriter.java:73-102 (addDenseValues), 192-199, 215-217 (single-valued read path)
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedLongValues.java:34, 66, 265-289 (page size; add(LongValuesCursor); packIfFull)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:314-359, 392-409 (updateBatch; deleteLastDocs does not flush)
  • lucene/core/src/java/org/apache/lucene/document/column/LongColumn.java:79-88 (values() must be implemented for DENSE)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/column/LongColumn.java:79-88; grep of lucene/*/src/java for LongValuesCursor implementations (none)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1950-1978, 2020-2056 (doc-values updates: doc-values-only fields, not index-sort fields)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:67-80
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1462-1468, 1528-1539
  • lucene/core/src/java/org/apache/lucene/document/column/LongValuesCursor.java:21-67
  • lucene/core/src/test/org/apache/lucene/document/column/ColumnBatchTestUtil.java:588-700
  • lucene/core/src/test/org/apache/lucene/index/TestColumnBatchIndexing.java:278-300, 740-792, 886-922
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:149-182
  • lucene/core/src/java/org/apache/lucene/codecs/KnnFieldVectorsWriter.java:40-63; lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99FlatVectorsWriter.java:506-512
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:156-182

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

Repro files

About the diagrams

The alt text follows the scene's summary-card text.

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.